Compare commits

...
Author SHA1 Message Date
Paul Rastoinandprastoin cb5235e5be Fix cross version upgrade for 2.8 (#20927) 2026-05-26 17:59:46 +02:00
bb5c2bd00c Fix/restore channel association scalar field metadata (#20920)
[#20836](https://github.com/twentyhq/twenty/pull/20836) dropped the
channel objects but even though
calendarChannelId/messageChannelId/messageFolderId already existed in
compute standard flat field, there was never an upgrade command to readd
them on the surviving association objects

so existing workspaces lost the field metadata (columns survived) and
import workers throw
```Error: Unknown error importing calendar events for calendar channel <REDACTED> in workspace <REDACTED>: Query runner already released. Cannot run queries anymore.```

This PR adds that command

---------

Co-authored-by: prastoin <[email protected]>
2026-05-26 14:58:33 +00:00
a2673da164 i18n - translations (#20924)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-26 17:03:31 +02:00
WeikoandGitHub 08682fb3f5 Various fixes - some AI findings (#20921)
App permissions tab:
- The fallback uuidv4() for a marketplace field was generated twice, so
id and universalIdentifier could diverge; it's now computed once and
reused as it seemed to be the intention (even though I don't really
think it's a good idea)
- Renamed buildobjectMetadataItemsFromMarketplaceApp →
buildObjectMetadataItemsFromMarketplaceApp to follow camelCase.

Morph relation validation:
- Fixed the user-facing message "At least one relation is require" →
"...is required"
- Typos in the related test descriptions (Morh → Morph, samefield → same
field) and their snapshots.

Docs
- The UUID field-type row in views.mdx only listed IS; updated to the
full set supported by FILTER_OPERANDS_MAP (IS, IS_NOT, IS_EMPTY,
IS_NOT_EMPTY).
2026-05-26 14:43:50 +00:00
53d4e92dda [Website] Generate release notes manifest at build time (#20913)
Removed the releases page’s runtime dependency on `fs` and
`process.cwd()` by introducing a build-time manifest generator: release
notes still live as markdown under `src/content/releases`, but a new
script now parses their frontmatter/content, validates that each note
has a release, title, and preview image (and that the image actually
exists), sorts the notes, and emits a typed `generated-release-notes.ts`
file that the app imports at runtime.

Updated the releases loader to return that generated data, changed the
menu releases preview and release JSON-LD to use explicit typed fields
(`title`, `previewImage`) instead of scraping markdown with regex at
runtime, wired the generator into Nx so it runs automatically before
`dev`, `build`, and `typecheck`, and fixed two stale image references in
the release MDX files that the new validation exposed.

---------

Co-authored-by: prastoin <[email protected]>
2026-05-26 16:46:11 +02:00
Rashad KaranouhandGitHub 53392f9a16 feat(twenty-partners): partnerContent catalog + TFT import improvements (#20904)
## Summary

Two related threads for the internal `twenty-partners` app:

1. **Redesign `partnerQuote` → `partnerContent`.** The object was
mis-modeled as a sales/pre-invoice doc (`amount`, opportunity link). In
TFT it's actually a marketing-content catalog — customer quotes, case
studies, partner quotes, logos — moving through a production lifecycle.
This renames it in place and reshapes it to mirror TFT's
`CustomerContent`.
2. **Import tooling improvements** to the TFT importer + multi-env
workflow.

## Changes

**Schema (`partnerContent`)**
- Rename `partnerQuote` → `partnerContent` (object, view, nav, relation
fields, identifiers).
- Add `contentType` MULTI_SELECT `[CUSTOMER_QUOTE, CASE_STUDY,
PARTNER_QUOTE, LOGO]` and `interview` LINKS.
- Add `customerCompany` / `customerPerson` relations; keep `partner`;
drop the `opportunity` link (TFT has none).
- Drop `amount`; rename the FILES field `quoteFile` → `documents`
(`attachments` is a reserved morph-relation name).

**Importer (`import-from-tft.ts`)**
- Import the full content catalog (all types), not just `PARTNER_QUOTE`.
- Map TFT `partnerTimezone` → `region`, default
`languagesSpoken=[ENGLISH]`, and set `deploymentExpertise=[SELF_HOST]`
when scope includes `HOSTING_ENVIRONMENT`.
- Filter to partner-relevant records only: opportunities linked to a
partner (20 of 164), content linked to a partner (10 of 22). Drops
general sales-pipeline / customer-only noise.
- Dedupe companies by **normalized domain** (Twenty's unique key), not
just name — fixes duplicate-entry crashes when the same company arrives
under different names.
- Progress logging throughout.

**Tooling**
- `purge-soft-deleted` script (soft-deleted rows block re-imports via
unique constraints).
- Multi-env script variants (`*:prod`) selected via `ENV_FILE`.

## Testing

Verified on a local Twenty instance and on `partner.twenty.com`:
- 122 partners, 20 partner-linked opportunities, 10 partner-linked
content (all types), 229 domain-deduped companies.
- Schema confirmed via metadata introspection; `yarn twenty typecheck`
clean.

## Notes

- Renaming an installed object isn't a pure in-place migration on a
server that already had `partnerQuote` — the working path is `uninstall
→ deploy → install` (safe here: prod had no data).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-26 14:09:57 +00:00
7e7bb81586 i18n - translations (#20911)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-26 12:13:49 +02:00
3703b3e2f6 i18n - translations (#20910)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-26 12:06:57 +02:00
Charles BochetandGitHub e72d10f550 chore(security): bump esbuild to ^0.28.0 to clear CVE-2025-68121 (CVSS 10.0) (#20902)
## Summary

Follow-up to #20876. That PR bumped `esbuild` to `^0.27.3` to address
the Go-stdlib CVEs the self-hoster reported, but only one of the two Go
CVEs is actually fixed at that level. This PR closes the remaining gap.

### Why 0.27.3 wasn't enough

`esbuild` ships a Go-built binary inside the `@esbuild/<platform>`
packages. The vulnerability lives in the bundled Go toolchain, not in
any JavaScript. Verified by reading the Go `buildinfo` section from
`node_modules/@esbuild/<platform>/bin/esbuild`:

- `[email protected].7` → built with **Go 1.23.8**
- `[email protected].0` → built with **Go 1.26.1**

CVE-2024-24790 (IPv6 zone parsing) is fixed in Go 1.21.11 / 1.22.4, so
0.27.x covers it.

**CVE-2025-68121** (crypto/tls cert validation bypass via TLS session
resumption, **CVSS 10.0 / Critical** per
[NVD](https://nvd.nist.gov/vuln/detail/cve-2025-68121)) is fixed only in
Go 1.24.13, 1.25.7, and 1.26.0-rc.3+. Go 1.23.x is past Go's support
window and will not receive this fix. So `[email protected].x` still ships a
Go binary that Trivy correctly flags as vulnerable.

### Reachable risk in Twenty

Low. `esbuild` does not use `crypto/tls` at runtime — it reads files,
parses, transforms, and writes. The vulnerable code path is dead code
inside the binary, present but never executed. The scan finding is what
we are clearing, not an exploitation risk.

### Fix

Bump `twenty-client-sdk`'s `esbuild` from `^0.27.3` to `^0.28.0`
(resolves to 0.28.0, built with Go 1.26.1).

### Verification

Ran `yarn workspaces focus --production twenty twenty-server
twenty-emails twenty-shared twenty-client-sdk` (the same install the
Dockerfile uses) and confirmed:

- `node_modules/esbuild/` resolves to `[email protected].0` (single copy)
- The bundled `node_modules/@esbuild/<platform>/bin/esbuild` binary
reports `go1.26.1` in its `buildinfo`

## Test plan

- [x] `nx typecheck twenty-server` passes
- [x] `nx build twenty-client-sdk` passes (esbuild's `build()` API is
stable across 0.27 → 0.28)
- [x] Production focus install shows Go 1.26.1 in the shipped binary
- [ ] CI green
- [ ] Re-run Trivy against the resulting image; confirm CVE-2025-68121
no longer appears
2026-05-26 09:49:25 +00:00
martmullandGitHub 7ca9081efa Add application installation validation modale (#20907)
## After

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/231d4f0d-6052-4c4e-9a2a-0244d2b3832e"
/>
2026-05-26 09:48:31 +00:00
015dca95fc Edit actor chip icon style & read-only behavior (#18552)
https://github.com/user-attachments/assets/925f4380-e3e2-430d-a8e3-7e1242298900

Removed background color from icons for consistency
Removed chip hover state as chips are not navigable
Updated read-only design (text/secondary on chip hover)

---------

Co-authored-by: Félix Malfait <[email protected]>
2026-05-26 09:38:39 +00:00
Paul RastoinandGitHub 076c05cbd0 File service uniformize not found behavior and stream management (#20891)
# Introduction

closes https://github.com/twentyhq/private-issues/issues/485
2026-05-26 09:23:48 +00:00
Félix MalfaitandGitHub 82c565f7dc perf(website): cache prerendered pages in CF Cache API (withRegionalCache) (#20905)
## Summary
Recovers most of the TTFB the EKS→Cloudflare migration lost on
`twenty.com`. OpenStatus's P50 chart shows the regression clearly: TTFB
went from ~50–80ms (pre-migration, CF edge cache HIT) to ~250–350ms
(post-migration, every request hits Worker → R2 → respond).

## Why the existing Cache Rule stopped working
The zone-level `Twenty Website - Aggressive cache` Cache Rule was
correctly configured and was the reason pre-migration TTFB was low. It
still exists, still has `cache: true`, Edge TTL 1d. But it doesn't apply
to Worker responses on a Worker custom domain:

- **Pre-migration** request flow: `edge → Cache Rule lookup → HIT
(~20ms) / MISS → origin → cache the response`
- **Post-migration**: `edge → Worker runs first (custom domain) → Worker
generates synthetic response from R2 → return`

Cache Rules cache responses obtained via `fetch()` from the Worker, not
synthetic responses constructed inside the Worker. OpenNext for SSG
pages reads prerendered HTML from R2 and returns it — that's synthetic.
So the rule has no insertion point.

This is structural to how CF Workers handle custom domains; not a
misconfiguration on your side.

## The fix
`open-next.config.ts`:
```ts
const incrementalCache = withRegionalCache(r2IncrementalCache, {
  mode: 'long-lived',
});
const baseConfig = defineCloudflareConfig({ incrementalCache });
```

OpenNext-native wrapper. The Worker still runs per request (~5–20ms
execution), but the ISR cache lookup goes through CF's per-region Cache
API (~5–20ms) instead of R2 (~50–150ms). For pages whose prerender
doesn't change between requests, that's the bulk of the TTFB recovered.

## Measured impact (live before/after on twenty.com today)
| URL | Before (avg of 3) | After cold (first 2 hits/region) | After
warm |
|---|---|---|---|
| `/` | 322ms | 600–640ms | **110–125ms** |
| `/pricing` | 267ms | 630–690ms | **104–110ms** |
| `/why-twenty` | 250ms | 175–270ms | **100–175ms** |

First 1–2 hits per CF region after this deploys will be slower than
baseline (regional Cache API populating from R2), then it sustains.
Steady state is significantly better than pre-fix.

## What this doesn't recover
Pre-migration `cf-cache-status: HIT` was ~20–30ms because the Worker
wasn't invoked at all. We can't get there without either:
- Moving SSG hosting off the Worker (back to a static origin Cache Rules
would cover)
- OpenNext gaining a "publish responses to caches.default" mode (doesn't
exist today)

Realistic-best on CF Workers + OpenNext is around the ~80–130ms range
we're now seeing.

## Live state
Already deployed to both prod (Version `40dfaa1a-...`) and dev (Version
`b45cc2de-...`) ahead of opening this PR, so the OpenStatus chart should
start improving immediately. This PR makes `main` reflect the change.
2026-05-26 09:20:55 +02:00
martmullandGitHub 44bae39538 Publish new cli tool version (#20903)
publish 2.8.0
2026-05-26 07:06:04 +00:00
WeikoandGitHub 1188ea9cd5 chore(server): remove unused REST→GraphQL HTTP bridge (#20897)
## Summary

Removes the unused `RestApiService` HTTP bridge that posted to
`/graphql` and `/metadata` from inside the server.
2026-05-25 19:56:32 +00:00
f721420705 i18n - docs translations (#20898)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-25 20:50:59 +02:00
96d247e94d i18n - translations (#20895)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-25 19:20:56 +02:00
WeikoandGitHub 90f711361c Add definePermissionFlag for app-defined permission flags (#20887)
## Context
Adds the SDK plumbing for apps to declare custom permission flags and
the server-side manifest pipeline to persist them.

```typescript
import { definePermissionFlag } from 'twenty-sdk/define';

export const MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER = '…';

export default definePermissionFlag({
  universalIdentifier: MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
  key: 'MANAGE_INVOICES',
  label: 'Manage Invoices',
  description: 'Create, edit, and delete invoices',
  icon: 'IconReceipt',
});
```

```typescript
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
import { MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER } from './permission-flags/manage-invoices';

export default defineApplicationRole({
  universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
  label: `${APP_DISPLAY_NAME} default function role`,
  // ...
  permissionFlagUniversalIdentifiers: [
    SystemPermissionFlag.UPLOAD_FILE,
    MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
  ],
});
```

The flag can then be referenced by UUID in a role's
permissionFlagUniversalIdentifiers. On sync, the catalog row lands in
core.permissionFlag and the link in core.rolePermissionFlag.

## Not in this PR
- Runtime permission checks.
PermissionsService.getUserWorkspacePermissions still builds its result
from Object.values(PermissionFlagType), so custom flags are stored but
not yet enforced, code asking "does this role have MANAGE_INVOICES?"
won't get a meaningful answer. Widening PermissionsService and
UserWorkspacePermissions.permissionFlags to support arbitrary flag keys
is the next PR.
- PermissionFlag from apps can only define "tool" permissions and not
"settings" as a permissionType, this parameter is not mutable. This is
because "settings" are for settings page (until we might decide to
separate both type of permissions into 2 different entities) and apps
can't declare settings page or interact with them so this parameter
would be unnecessary.
2026-05-25 16:53:37 +00:00
98d47d0dd0 i18n - docs translations (#20893)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-25 19:02:12 +02:00
f613886511 fix(localization): parse date-only ISO strings as local midnight in relative date formatter (#20630)
## Summary

Fixes #19634

### Root Cause

The ECMAScript spec treats date-only strings (`YYYY-MM-DD`) as **UTC
midnight** when passed to `new Date()`. But `date-fns` comparison
functions (`isToday`, `isYesterday`, `isTomorrow`) operate in **local
time**. For users in UTC-negative timezones, UTC midnight April 14 is
April 13 evening locally — so the label shows "Yesterday" instead of
"Today".

### Fix

In `formatDateISOStringToRelativeDate.ts`, detect date-only strings
(length === 10) and append `T00:00:00` (no `Z`) to force local-time
parsing:

```ts
// Before
const targetDate = new Date(isoDate);

// After
const targetDate =
  isoDate.length === 10 ? new Date(isoDate + 'T00:00:00') : new Date(isoDate);
```

Full datetime strings (with time component) are left unchanged — they
already carry timezone information.

### Tests

Added `formatDateISOStringToRelativeDate.test.ts` covering:
- `Today` / `Yesterday` / `Tomorrow` labels for date-only strings
- Regression case: date-only string parsed at local midnight (not UTC
midnight)
- Full datetime strings continue to work as before

## Before / After

| Scenario | Before | After |
|---|---|---|
| `"2026-04-14"` viewed at UTC-5 on April 14 | Yesterday  | Today ✓ |
| `"2026-04-14"` viewed at UTC+0 on April 14 | Today ✓ | Today ✓ |
| `"2026-04-14T12:00:00Z"` | Today ✓ | Today ✓ |

---------

Co-authored-by: Marie Stoppa <[email protected]>
2026-05-25 15:54:36 +00:00
a3e41ec267 i18n - translations (#20892)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-25 17:54:07 +02:00
Félix MalfaitandGitHub d602f35cbd feat(data-model): custom-indexes management UI and mutations (#20846)
## Summary

Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).

A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).

## What ships

### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
  - BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.

### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.

### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.

### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.

### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.

### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.

### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.

## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
2026-05-25 17:47:09 +02:00
Paul RastoinandGitHub 69d89f8cfc Early return in public assets (#20881)
# Introduction
Related https://github.com/twentyhq/twenty/issues/20879

More abstracted response error and cleaner integrity check before
performing any in database search
Nothing critical patched here

Also added integration coverage to the related endpoint

Fixed the stream on error throw that would have been bubbling up into
node process

## Next
Once this has been approved will re-apply to all the existing prone
file.getBy* methods and controllers endpoints
2026-05-25 14:17:32 +00:00
Charles BochetandGitHub be39702fd2 chore(security): bump protobufjs and esbuild to clear CVEs (#20876)
## Summary

A self-hoster reported that Trivy blocks the `twentycrm/twenty:v2.7.x`
image on three fixed-critical CVEs. The reachable risk is low (none of
the vulnerable code paths are exposed to attacker-controlled input in
our deployment), but the findings are real and easy to clear by bumping
the affected dependencies in their owning workspaces.

### CVE-2026-41242 — `protobufjs` < 7.5.5

Pulled transitively into the production image via
`@opentelemetry/sdk-node`, `@opentelemetry/auto-instrumentations-node`,
and `@grpc/grpc-js` → `@grpc/proto-loader`. Lockfile was on 7.5.3; this
matches dismissed dependabot alert #1009 (Critical 9.4).

**Fix:** add `protobufjs: ^7.5.5` as a direct dep of `twenty-server`
(the workspace that exercises it via the OpenTelemetry gRPC exporters)
and run `yarn dedupe protobufjs` to collapse the residual transitive
7.5.3 copy. Resolves to 7.6.0.

### CVE-2024-24790 and CVE-2025-68121 — Go stdlib in bundled binaries

Present in the Go-built `bin/esbuild` shipped by `@esbuild/<platform>`
packages. Two paths put esbuild into the production image:

1. `twenty-client-sdk` declares `esbuild` as a runtime dep (used by its
`./generate` entry point).
2. `twenty-server` had `@lingui/vite-plugin` in `dependencies`, which
pulls `@lingui/cli` as a runtime sub-dep, which bundles `[email protected].5`
nested under `node_modules/@lingui/cli/node_modules/esbuild/`.

**Fix:**
- Bump `twenty-client-sdk`'s `esbuild` from `^0.25.0` to `^0.27.3`
(resolves to 0.27.7, built with patched Go).
- Move `@lingui/vite-plugin` from `dependencies` to `devDependencies` in
`twenty-server`. The plugin is not imported by any source file — it was
misclassified.

### Verification

Ran `yarn workspaces focus --production twenty twenty-server
twenty-emails twenty-shared twenty-client-sdk` (the same command the
Dockerfile uses) and inventoried the resulting `node_modules`. After all
three changes:

- `node_modules/esbuild/` → **0.27.7 only** (Go-patched)
- `node_modules/protobufjs/` → **7.6.0 only** (CVE-patched)

No nested copies of either package remain in the production install.

### Follow-up worth tracking separately

`esbuild` should arguably not be in `twenty-client-sdk`'s `dependencies`
at all — only the `./generate` entry point uses it, and the server never
imports that entry. Moving it to optional `peerDependencies` would stop
shipping a Go binary into the production image entirely. Out of scope
for this PR.

## Test plan

- [x] `yarn install` succeeds; `protobufjs` and `esbuild` each resolve
to a single version in production focus
- [x] `nx build twenty-client-sdk` passes
- [x] `nx typecheck twenty-server` passes
- [x] `nx build twenty-server` passes
- [x] Production focus install confirmed clean (`node_modules/esbuild`
and `node_modules/protobufjs` both single-version, both patched)
- [ ] CI green
- [ ] Re-run Trivy against the resulting image; confirm the three CVEs
no longer appear
2026-05-25 12:36:53 +00:00
EtienneandGitHub 50c8834e21 fix(billing) - skip redundant cap writes (#20882)
fixes https://sonarly.com/issue/40207
2026-05-25 12:03:18 +00:00
d54caf10c1 i18n - translations (#20885)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-25 14:10:10 +02:00
nitinandGitHub 0b38b4ffc4 fix(page-layout): respect tab layoutMode on standalone pages (#20856)
Standalone-page tabs with `layoutMode: CANVAS` were silently rendering
as GRID (border, padding, scroll). Now they render full-bleed, matching
the CANVAS contract elsewhere.

Three layered fixes:
- `getTabLayoutMode`: respect `tab.layoutMode` for `STANDALONE_PAGE`
(was hardcoded to GRID for any non-`RECORD_PAGE`)
- `getWidgetCardVariant`: CANVAS now wins regardless of page type —
refactored to early-return + exhaustive switch on `pageLayoutType`
- `FrontComponentWidgetRenderer`: removed hardcoded `overflow: auto`
(workflow/tasks/timeline widgets don't have it either)

New `getTabLayoutMode.test.ts`. Variant tests refactored to declarative
+ parameterized.

QA:

<img width="3024" height="1654" alt="CleanShot 2026-05-22 at 20 46
50@2x"
src="https://github.com/user-attachments/assets/cc61d459-6bc6-48de-ac79-d63a2ccd8957"
/>


https://github.com/user-attachments/assets/a3374e18-ad1b-4888-ab2b-d07730edccac
2026-05-25 11:59:31 +00:00
Paul RastoinandGitHub b8b115f4e3 FileStorageService Dedicated file and folder code flow + integrity check (#20831)
# Introduction

Next handling mimetype integrity check and checksum integrity check for
s3 storage type

Always expecting a trailing end slash when deleting a folder etc

## Application
Uninstalling an application now deletes all its related files

## File storage service
Making a distincton between folder path and file path

## Validation Pipeline

Every file operation in `FileStorageService.buildOnStoragePath` runs
through `validateResourcePath`, which chains three validators in order:

**1. `validateSafeRelativePath`** -- rejects path traversal attacks

| Input | Result | Error |
|---|---|---|
| `../../../etc/passwd` | Rejected | `Resource path must not contain
path traversal (..)` |
| `/etc/passwd` | Rejected | `Resource path must be relative, not
absolute` |
| `file\0.txt` | Rejected | `Resource path contains null bytes` |
| `..\\..\\etc\\passwd` | Rejected | `Resource path must not contain
backslashes` |
| _(empty)_ | Rejected | `Resource path must not be empty` |

**2. `validateFilenameIntegrity`** -- enforces safe characters, length
limits, extension required

| Input | Result | Error |
|---|---|---|
| `my folder/file.mjs` | Rejected | `A path segment contains invalid
characters...` |
| `Makefile` | Rejected | `Filename must have an extension` |
| `aaa...(256 chars).mjs` | Rejected | `A path segment exceeds the
maximum length of 255 characters` |
| `a/b/.../file.mjs` (1025+ chars) | Rejected | `Resource path exceeds
maximum length of 1024 characters` |
| `src/handlers/index.mjs` | Accepted | -- |
| `my-app/my_file.tsx` | Accepted | -- |
| `v1.0/module.config.mjs` | Accepted | -- |

Allowed characters per segment: `a-z`, `A-Z`, `0-9`, `.`, `-`, `_`

**3. `validateResourceExtension`** -- checks extension against the
`FileFolder` allowlist

| Input | FileFolder | Result | Error |
|---|---|---|---|
| `handler.js` | `BuiltLogicFunction` | Rejected | `Invalid file
extension. Allowed extensions: .mjs` |
| `card.tsx` | `BuiltFrontComponent` | Rejected | `Invalid file
extension. Allowed extensions: .mjs` |
| `script.js` | `PublicAsset` | Rejected | `Invalid file extension.
Allowed extensions: .png, .jpg, ...` |
| `index.mjs` | `BuiltLogicFunction` | Accepted | -- |
| `app.tsx` | `Source` | Accepted | -- |
| `photo.png` | `CorePicture` | Accepted | -- (unconfigured folder,
passes through) |

## Consumers

- **`FileStorageService`** -- calls `validateResourcePath`, throws
`FileStorageException` on failure (last-resort defense)
- **Resolver (`uploadApplicationFile`)** -- calls
`validateResourcePath`, throws `ApplicationException` on failure
(user-facing)
- **Flat validators** -- call `validateResourcePath`, push the error to
`validationResult.errors` (non-throwing, collects all errors)

All error messages are translated via Lingui `t` and returned in a
discriminated union `{ isValid: true } | { isValid: false, error: string
}`, letting each consumer decide how to handle failures.
2026-05-25 11:52:54 +00:00
85d649e831 [Fix] Backfill missing command menu items conditional availability expression (#20852)
## Description

Following [report in
discord](https://discord.com/channels/1130383047699738754/1498690477044793386/1506602927412744242)

Some command menu items were showing to all users because they had no
conditional availability expression, whereas users did not actually have
access to the page or feature behind. For instance: "Go to Admin panel",
"Go to AI settings", "Send email" etc.

<img width="833" height="1245" alt="image"
src="https://github.com/user-attachments/assets/8d2a9404-9b81-4d58-9522-558e9924c457"
/>


## Fix

- Add conditional availability expressions
- Backfill expressions for existing workspaces as they are stored in db
(commandMenuItems table)

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-05-23 13:22:25 +00:00
Charles BochetandGitHub 2f4ebf8160 Move preview environment workflow to ci-privileged (#20872)
## Summary

Slims `preview-env-dispatch.yaml` to a single dispatch and deletes
`preview-env-keepalive.yaml`. The actual preview-env work moves to
**twentyhq/ci-privileged#22** (must merge as a pair).

## Why

Context: PR #20867 was a credential-exfil attempt against our workflows.
GitHub's default fork-PR-no-secrets policy + our existing gates
(`author_association` checks, `pull_request_target` checking out base,
`enableScripts: false`) neutralized the actual attack — but the audit
surfaced one workflow that *would* have given a malicious external PR
access to a real secret if a maintainer had applied the `preview-app`
label: `preview-env-keepalive.yaml`.

That workflow checked out the PR head SHA, did `docker login` with
`DOCKERHUB_PASSWORD`, then ran the PR's `docker-compose.yml`. A
malicious compose could have mounted `~/.docker/config.json` and
exfiltrated the Dockerhub credential.

After this PR, that workflow lives in `twentyhq/ci-privileged` instead,
paired with a rename of the credential to `DOCKERHUB_RO_TOKEN`
(Dockerhub PAT with `Public Repo Read-only` scope). A read-only PAT has
no exfiltration value — it's equivalent to anonymous Dockerhub access
plus rate-limit headroom — so the credential lives safely on the runner
without further hygiene tricks.

## What this PR does

- **Modifies** `.github/workflows/preview-env-dispatch.yaml`:
- Single dispatch to `twentyhq/ci-privileged` (was: self-dispatch to
twenty for the env + a separate dispatch to ci-privileged for the PR
comment).
  - `permissions: {}` (was: `contents: write`).
  - Drops `preview-env-keepalive.yaml` from the path-trigger list.
- **Deletes** `.github/workflows/preview-env-keepalive.yaml`. The
207-line workflow now lives in
`twentyhq/ci-privileged/.github/workflows/preview-env.yaml`.

Net `twenty` repo change: **-204 lines / +3 lines**.

## Companion PR

twentyhq/ci-privileged#22 — adds the new `preview-env.yaml`, deletes the
now-redundant `post-preview-comment.yaml`.

## Secrets fallout in this repo

After this PR, `DOCKERHUB_PASSWORD` in `twentyhq/twenty` secrets is only
used by `ci-test-docker-compose.yaml`, where:
- It evaluates to empty for fork PRs (GitHub default — secrets aren't
passed to fork-PR workflows).
- It's only needed for internal / merge_queue runs, for Dockerhub
rate-limit headroom on base-image pulls.

Recommend (separate change): also convert the twenty-side
`DOCKERHUB_PASSWORD` to a `Public Repo Read-only` Dockerhub PAT, and
rename it to `DOCKERHUB_RO_TOKEN` for consistency with ci-privileged.
The workflow change for `ci-test-docker-compose.yaml` would just be a
rename — login flow is identical for password vs. PAT.

## Test plan

- [ ] Merge twentyhq/ci-privileged#22 first (so the dispatched event has
a handler)
- [ ] Open an internal PR touching `packages/twenty-docker/**`, confirm
`Preview Environment Dispatch` runs and ci-privileged's `Preview
Environment` workflow runs the docker compose + posts the URL
- [ ] On an external contributor PR, apply the `preview-app` label,
confirm the same flow
- [ ] Confirm closing the PR doesn't break (no cleanup workflow was
changed)
2026-05-23 11:37:37 +00:00
77603a4102 i18n - docs translations (#20869)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-23 12:47:00 +02:00
Charles BochetandGitHub 91ce59d8e2 refactor(jwt): gate signing-key auto-rotation cron on SIGNING_KEY_ROTATION_DAYS (#20866)
Only register the JWT signing-key rotation cron when
`SIGNING_KEY_ROTATION_DAYS` is set, and move that variable to Advanced
Settings.
2026-05-23 09:51:06 +00:00
martmullandGitHub 056e3a4cd8 Add check for breaking api changes (#20848)
- update ci-breaking-changes.yaml so it check for api contrat breaks
- check fails properly when removing fix
https://github.com/twentyhq/twenty/pull/20825
- check it turns green again when adding fix back
2026-05-23 08:50:05 +00:00
9876af5587 i18n - translations (#20865)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-23 10:46:12 +02:00
Charles BochetandGitHub 563acc3f57 Allow copy to clipboard and pointer/mousemove events in front components (#20858)
Follow-up to #20525, picks up the clipboard + mouse/pointer events asks
from the "Allow to copy to clipboard in front-component" Slack thread.
`navigator.geolocation` and `getBoundingClientRect` are intentionally
out of scope until we have a permission model.

### `copyToClipboard` host API

New SDK function `copyToClipboard` (in `twenty-sdk/front-component`)
that goes through the host bridge to `useCopyToClipboard` in
`twenty-front`:

```ts
import { copyToClipboard } from 'twenty-sdk/front-component';

await copyToClipboard('hello');
```

Host-side hardening (front-component code is untrusted):
- Drops anything that isn't a non-empty string
- Caps payload at 64KB
- Throttles to 1 call/sec per front-component instance
- Snackbar shows a truncated preview so the user can spot a mismatch
between the affordance they clicked and what actually got copied

### `mousemove` and pointer events

Added to `COMMON_HTML_EVENTS` (and the React mapping) so they fire on
every HTML tag the renderer ships: `mousemove`, `pointerdown/up/move`,
`pointerover/out/enter/leave/cancel`. Generator rerun for
`remote-elements.ts` and `remote-components.ts`.

`SerializedEventData` now also forwards pointer geometry: `pointerId`,
`pointerType`, `pressure`, `tangentialPressure`, `tiltX/Y`, `twist`,
`width/height`, `isPrimary`. Existing positional fields are unchanged.

### Coverage

- New Storybook stories: `HostApi/CopyToClipboard` and
`HtmlTag/Grouping/Div/Events::PointerMove`
- `useFrontComponentExecutionContext` unit tests cover the API call,
preview truncation, type guard, length cap, and rate limit
- Renderer Storybook suite 227 → 229, prebuild bundle count 219 → 221
2026-05-23 08:28:11 +00:00
Charles BochetandGitHub 452433a9ae feat(upgrade): emit twenty_upgrade_instance_info gauge with version attribute (#20854)
Adds `twenty_upgrade_instance_info` — a new "info"-style gauge that
carries the inferred instance version (derived from the last applied
upgrade migration) as the `version` label.

This follows the standard Prometheus pattern for surfacing string-valued
metadata: value is always `1` (load-bearing for PromQL `group_left`
joins), the data lives on the label. Same shape as `node_uname_info`,
`go_info`, `kube_pod_info`, etc. — see [Prometheus naming
conventions](https://prometheus.io/docs/practices/naming/#metric-names).

On `/metrics`:

```
# HELP twenty_upgrade_instance_info Inferred instance version (semver-ish, derived from the last applied upgrade migration), carried as the `version` attribute
# TYPE twenty_upgrade_instance_info gauge
twenty_upgrade_instance_info{version="2.7.3"} 1
```

Also adds `MetricsService.createInfoGauge` as the helper for the pattern
(auto-suffixes `_info`, enforces value=1). Consumed by
twentyhq/twenty-eng#65.
2026-05-23 07:19:26 +00:00
WeikoandGitHub 3bda05ea57 [Breaking change] Prepare non-system permission flags (#20847)
# Summary

Replaces the enum-keyed `permissionFlags: PermissionFlag[]` on roles
with `permissionFlagUniversalIdentifiers: string[]`

This unlocks mixing system flags (`SystemPermissionFlag.*`) with
app-defined flags in a role config.

This is a breaking change. Existing app source must switch to the new
field.

# Breaking changes

- `RoleManifest.permissionFlags` removed. Use
`RoleManifest.permissionFlagUniversalIdentifiers: string[]`.
- `RoleConfig.permissionFlags` removed (was `PermissionFlagType[]`). Use
`RoleConfig.permissionFlagUniversalIdentifiers: string[]`.
- `PermissionFlagManifest` type removed from
`twenty-shared/application`.
- `PermissionFlag` re-export removed from `twenty-sdk/define`.
`SystemPermissionFlag` is re-exported in its place.
- Retargeting a permission flag between roles is now classified as
delete + create instead of update

 ### Not in this PR
- definePermissionFlag SDK function and top-level
Manifest.permissionFlags catalog (apps defining their own custom flags).
Until those land, permissionFlagUniversalIdentifiers only accepts
SystemPermissionFlag.* UUIDs; arbitrary UUIDs fail validation.
2026-05-22 21:22:28 +00:00
EtienneandGitHub eda41b4eba feat(ai) - add observability (#20850)
**AI Chat - Tool Executions (counters, tagged with model)**
ai-chat/tool-execution-succeeded: number of tool calls invoked by the AI
that completed without error
ai-chat/tool-execution-failed: number of tool calls invoked by the AI
that threw an error
**AI Chat - Token Usage (counters, tagged with model)**
ai-chat/input-tokens: total input tokens sent to the model across all
turns
ai-chat/output-tokens: total output tokens generated by the model
ai-chat/cache-read-tokens: input tokens served from the model's prompt
cache (cheaper)
ai-chat/cache-write-tokens: input tokens written into the prompt cache
for future reuse
**AI Chat - Latency (histograms in ms, tagged with model)**
ai-chat/turn-latency-ms: total duration of a full chat turn (from stream
start to stream end)
ai-chat/step-latency-ms: duration of a single reasoning/tool-call step
within a turn
ai-chat/ttft-ms: time-to-first-token, i.e. how long until the model
starts streaming output
**MCP - Tool Executions (counters)**
mcp/tool-execution-succeeded: number of MCP tool calls that completed
successfully
mcp/tool-execution-failed: number of MCP tool calls that threw an error
2026-05-22 15:32:51 +00:00
Félix MalfaitandGitHub de044f4b45 feat(ai-chat): add navigation menu item + webhook tool providers (#20759)
## Summary

Exposes two Twenty primitives to the AI chat that it could not
previously manage:

- **Navigation menu items** — workspace nav and personal favorites
(favorites are just nav items with `scope: 'user'`).
- **Webhooks** — full CRUD with a structured operations input (record +
metadata events).

Page layouts and workflow runs were originally in this PR but have been
split out — they touch heavier surfaces (21 widget configurations and
the workflow runner cycle, respectively) and deserve their own focused
PRs.

### Tool inventory (8 new tools across 2 providers)

| Provider | Tools |
|---|---|
| NavigationMenuItem | `list_`, `create_`, `update_`,
`delete_navigation_menu_item` |
| Webhook | `list_`, `create_`, `update_`, `delete_webhook` |

### Design notes

- Both providers follow the established **view-style pattern**: tool
workspace service lives in the entity module's `tools/` folder, is
provided + exported by the entity module, and `ToolProviderModule`
imports the entity module. No `@Global()` modules or injection tokens
introduced.
- `create_navigation_menu_item` uses a Zod `discriminatedUnion` on
`type` (`FOLDER` / `LINK` / `OBJECT` / `VIEW` / `RECORD` /
`PAGE_LAYOUT`). `scope: 'workspace' | 'user'` switches between shared
nav and personal favorites — the underlying
`NavigationMenuItemAccessService` enforces LAYOUTS for workspace writes.
- Webhook operations accept both record events (`{kind:'record', object,
event}` → `<object>.<event>`) and metadata events (`{kind:'metadata',
metadataName, operation}` → `metadata.<metadataName>.<operation>`).
- Permissions reuse existing flags (`LAYOUTS`, `API_KEYS_AND_WEBHOOKS`).
No new permission flags, no migrations.

### Category cleanup

- New: `ToolCategory.NAVIGATION_MENU_ITEM`, `ToolCategory.WEBHOOK`.
- `ToolCategory.VIEW_FIELD` → folded into `VIEW`. Same permission gate,
same domain — separate category was organizational drift.
- `navigate_app` action stays in `ToolCategory.ACTION` where it belongs.

### System prompt addition


[chat-system-prompts.const.ts](packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts)
now teaches the AI:
- Favorites are nav items with `scope: 'user'`.
- A default OBJECT nav item is auto-created with
`create_object_metadata` — don't double-create.

### One file = one export

Every new schema / type / util file has exactly one top-level export.

## Test plan

- [ ] `npx nx typecheck twenty-server` — passes
- [ ] Spin up locally and exercise via AI chat:
- [ ] "Pin the Companies view to my favorites in a folder called
Important." → `create_navigation_menu_item` (FOLDER, user) then (VIEW,
user, folderId)
- [ ] "Register a webhook to https://example.com firing when any person
is created or updated." → `create_webhook` with discriminated operations
- [ ] Verify workspace-scoped nav writes are denied for a user without
LAYOUTS permission
- [ ] Verify user-scoped nav writes work without LAYOUTS permission

## Follow-ups (separate PRs)

- Page layout tools (record-page, record-index, standalone) — needs
widget-config strategy.
- Workflow run tools (list, get, run, stop) — uses the workflow-runner
cycle path.
- Dashboard / page-layout tool unification —
`DashboardToolWorkspaceService` and a future
`PageLayoutToolWorkspaceService` both inject the same trio
(PageLayout/Tab/Widget services).
- Webhook Settings page reads from raw Apollo query — switch to the
metadata store so it refreshes when the AI mutates webhooks.
2026-05-22 17:27:06 +02:00
e3c79c803c Fix standard React form event targets in front components (#20525)
Fixes #20354

## Problem

Front component form events currently expose serialized form state
through a sandbox-specific event shape, such as `event.detail.value` and
`event.detail.checked`.

That works for examples that explicitly read `event.detail`, but it is
surprising for app authors writing standard React form handlers:

```tsx
onChange={(event) => {
  setValue(event.target.value);
}}
Internal app code already has to defend against multiple possible shapes:

// Values may live on e.detail.value, e.value, or e.target.value.
This suggests the sandbox event shape is leaking into userland.

Solution
This change keeps the existing event.detail behavior, but also syncs serialized event target properties back onto the remote element before dispatching the event.

That means both styles work:

// Existing sandbox-specific style
event.detail.value;

// Standard React style
event.target.value;
The same applies to checked, files, scroll/media target properties, and similar serialized target state.

What Changed
Added a shared helper to apply serialized event target properties onto the remote element.
Updated generated remote element event configs to dispatch serialized events through a custom event config.
Updated the remote-dom element generator so regenerated files preserve this behavior.
Updated Storybook form-event examples to use standard React event target reads.
Added/updated Storybook coverage for input, checkbox, textarea, select, submit, and caret preservation flows.
Validation
Ran git diff --check
Ran a targeted TypeScript error scan for the changed front component renderer files
Manually verified the Storybook FrontComponent/EventForwarding form event story locally:
text input updates state
checkbox updates state
submit reflects the updated JSON
Note: local Storybook verification on Windows required temporary local build/cache fixes that are not included in this PR, to keep this change focused on front component event behavior.

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-05-22 14:58:33 +00:00
nitinandGitHub 59d69e2f5c fix(sdk): link dev UI to workspace application detail page (#20849)
sdk handle auth of one workspace per session -- but server could be
configured as multi or single -- hence for multi get subdomain -- and
for single the localhost fallback!
also: link includes applicationId so it opens the app detail page
directly (not the list)

## QA
multi workspace flag on -

<img width="2996" height="1712" alt="CleanShot 2026-05-22 at 18 21
31@2x"
src="https://github.com/user-attachments/assets/8499b9f3-b22e-45e2-8b97-4b27fadc3c94"
/>

multi workspace flag off - 

<img width="3012" height="1734" alt="CleanShot 2026-05-22 at 18 14
37@2x"
src="https://github.com/user-attachments/assets/3af2f492-5e2d-4a4b-8251-c3343d79ae9e"
/>
2026-05-22 14:19:27 +00:00
neo773andGitHub 4554dbe3c9 make connectedAccount clients self contained (#20827)
Replace OAuth2ClientManagerService with per provider each loading their
own entity and resolving tokens internally

Removes the ugly spread pattern of sprinkling tokens everywhere, this
caused downtime of messaging when we migrated to encrypted tokens
2026-05-22 14:17:07 +00:00
martmullGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
efba71a938 Download tarball instead of yarn install when installing application (#20835)
as title

Tested on Vexa public application install
- before -> 5.4s
- after -> 2.55s

Tested on Exa public application install
- before -> 5.5s
- after -> 2.24s

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-22 14:08:12 +00:00
e6cc783a23 i18n - translations (#20853)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-22 15:56:56 +02:00
76e144e85a Deprecate messageChannel messageFolder calendarChannel standard objects (#20836)
# Introduction

Removing old standard objects `messageChannel` and `messageFolder` and
`calendarChannel`

---------

Co-authored-by: Cursor <[email protected]>
2026-05-22 13:40:53 +00:00
985 changed files with 39852 additions and 27712 deletions
+91 -5
View File
@@ -144,6 +144,9 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding current branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -160,7 +163,7 @@ jobs:
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=300
timeout=60
interval=5
elapsed=0
@@ -185,9 +188,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Timed out waiting for current branch server to serve a valid schema."
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -311,6 +315,9 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding main branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -352,9 +359,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Timed out waiting for main branch server to serve a valid schema."
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -450,6 +458,7 @@ jobs:
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
id: graphql-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
@@ -463,6 +472,7 @@ jobs:
echo "✅ No changes in GraphQL schema"
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "core_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
@@ -480,6 +490,7 @@ jobs:
echo "✅ No changes in GraphQL metadata schema"
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
@@ -496,6 +507,7 @@ jobs:
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
id: rest-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
@@ -518,6 +530,7 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
@@ -565,6 +578,7 @@ jobs:
fi
- name: Check REST Metadata API Breaking Changes
id: rest-metadata-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
@@ -587,6 +601,7 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
@@ -632,6 +647,79 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Fail on breaking changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
breaking=false
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
echo "❌ GraphQL core schema has breaking changes"
breaking=true
if [ -f graphql-schema-diff.md ]; then
echo ""
cat graphql-schema-diff.md
echo ""
fi
fi
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
echo "❌ GraphQL metadata schema has breaking changes"
breaking=true
if [ -f graphql-metadata-diff.md ]; then
echo ""
cat graphql-metadata-diff.md
echo ""
fi
fi
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST core API has breaking changes"
breaking=true
if [ -f rest-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST metadata API has breaking changes"
breaking=true
if [ -f rest-metadata-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-metadata-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "$breaking" = "true" ]; then
echo ""
echo "This PR introduces breaking changes to the public API."
echo "If intentional, deprecate the old endpoint and introduce a new one."
echo "See the breaking changes report artifact and PR comment for details."
exit 1
fi
echo "✅ No breaking API changes detected"
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -652,5 +740,3 @@ jobs:
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
+3 -18
View File
@@ -1,7 +1,6 @@
name: 'Preview Environment Dispatch'
permissions:
contents: write
permissions: {}
on:
pull_request_target:
@@ -11,7 +10,6 @@ on:
- packages/twenty-server/**
- packages/twenty-front/**
- .github/workflows/preview-env-dispatch.yaml
- .github/workflows/preview-env-keepalive.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -35,28 +33,15 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
- name: Dispatch preview-env to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/"$REPOSITORY"/dispatches \
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
@@ -1,186 +0,0 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
on:
repository_dispatch:
types: [preview-environment]
jobs:
preview-environment:
timeout-minutes: 310
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..."
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
sleep 5
count=$((count+1))
if [ $count -gt 60 ]; then
echo "Timeout waiting for services to be ready"
docker compose logs
exit 1
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.7.0",
"version": "2.8.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -80,8 +80,8 @@ export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
throw new Error(
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
);
}
}
@@ -1,4 +1,4 @@
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: true,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
@@ -0,0 +1,17 @@
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
@@ -1,4 +1,4 @@
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.1.0",
"version": "0.3.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -16,8 +16,13 @@
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
@@ -3,7 +3,7 @@ export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
@@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerCompany',
label: 'Customer Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerCompanyId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerPerson',
label: 'Customer Person',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerPersonId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
export default defineField({
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,15 +1,15 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconFileDollar',
icon: 'IconQuote',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +1,15 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineObject({
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerQuote',
namePlural: 'partnerQuotes',
labelSingular: 'Partner Quote',
labelPlural: 'Partner Quotes',
description: 'A quote a partner submitted for a customer deal',
icon: 'IconFileDollar',
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerContent',
namePlural: 'partnerContents',
labelSingular: 'Partner Content',
labelPlural: 'Partner Content',
description: 'Marketing content involving a partner: quotes, case studies, logos',
icon: 'IconQuote',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
fields: [
@@ -21,6 +21,20 @@ export default defineObject({
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
type: FieldType.MULTI_SELECT,
name: 'contentType',
label: 'Content Type',
icon: 'IconCategory',
isNullable: true,
options: [
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
],
},
{
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
type: FieldType.SELECT,
@@ -45,21 +59,21 @@ export default defineObject({
isNullable: true,
},
{
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
type: FieldType.CURRENCY,
name: 'amount',
label: 'Amount',
icon: 'IconCoin',
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
type: FieldType.LINKS,
name: 'interview',
label: 'Interview',
icon: 'IconMicrophone',
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
type: FieldType.FILES,
name: 'quoteFile',
label: 'Quote File',
name: 'documents',
label: 'Documents',
icon: 'IconPaperclip',
isNullable: true,
universalSettings: { maxNumberOfValues: 1 },
universalSettings: { maxNumberOfValues: 10 },
},
],
});
@@ -23,7 +23,7 @@
// tsx src/scripts/import-from-tft.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
@@ -76,6 +76,17 @@ const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
REJECTED: 'REJECTED',
};
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
// coarse timezone band, not a geography, so each maps to every region it plausibly
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
const TIMEZONE_TO_REGION: Record<string, string[]> = {
AMERICAS: ['US', 'LATAM'],
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
WEST_ASIA: ['MENA', 'APAC'],
EAST_ASIA_OCEANIA: ['APAC'],
OTHER: [],
};
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
// these would fail a real write). Keep in sync with src/objects + src/fields.
const LOCAL_OPTIONS: Record<string, Set<string>> = {
@@ -101,6 +112,30 @@ const edges = (result: any, key: string): any[] =>
const uniq = (values: (string | undefined | null)[]): string[] =>
[...new Set(values.filter((v): v is string => !!v))];
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
// form (no protocol, no www, no trailing slash, lowercased).
const normDomain = (d?: string | null): string | undefined =>
d
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
: undefined;
// Distinct non-empty values across rows, for the preflight report. Flattens array
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
// multi-select fields share one path. Derived from the already-fetched source
// rows — no per-loop bookkeeping needed.
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
[
...new Set(
rows.flatMap((row) => {
const value = pick(row);
return Array.isArray(value) ? value : value != null ? [value] : [];
}),
),
]
.map(String)
.sort();
async function main() {
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
const local = new CoreApiClient({
@@ -114,26 +149,10 @@ async function main() {
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
};
// Distinct TFT values seen, for the preflight coverage report.
const seen: Record<string, Set<string>> = {
partnerStage: new Set(),
partnerTier: new Set(),
partnerScope: new Set(),
typeOfTeam: new Set(),
oppStage: new Set(),
hostingType: new Set(),
subscriptionType: new Set(),
subscriptionFrequency: new Set(),
quoteStatus: new Set(),
typeCustom: new Set(),
};
const note = (bucket: string, value?: string | null) => {
if (value) seen[bucket].add(value);
};
// ---------------------------------------------------------------------
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
// ---------------------------------------------------------------------
console.log('[import] fetching TFT people...');
const tftPeople = edges(
await tftQuery(`query {
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
@@ -143,7 +162,7 @@ async function main() {
emails { primaryEmail }
city jobTitle
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
partnerBudgetAverage { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
@@ -152,7 +171,8 @@ async function main() {
}`),
'people',
);
const tftOpps = edges(
console.log('[import] fetching TFT opportunities...');
const tftOppsAll = edges(
await tftQuery(`query {
opportunities(first: 500) {
edges { node {
@@ -166,16 +186,32 @@ async function main() {
}`),
'opportunities',
);
// Only import opportunities linked to a partner. The rest is TFT's general sales
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
console.log('[import] fetching TFT partner content...');
const tftContent = edges(
await tftQuery(`query {
customerContents(first: 500) {
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
edges { node {
id name status approvalDate typeCustom
interview { primaryLinkUrl }
partnerPerson { id }
customerCompany { id name domainName { primaryLinkUrl } }
customerPerson { id }
} }
}
}`),
'customerContents',
);
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
// Import all content TYPES (quotes, case studies, logos) but only records that
// involve a partner. Customer-only content (no partnerPerson) is noise for the
// partners app; a partner-linked case study/quote should show on the partner.
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
const personSlug = (p: any): string =>
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
@@ -183,6 +219,7 @@ async function main() {
// ---------------------------------------------------------------------
// 2. Batched existence lookups against local (one `in` query per object).
// ---------------------------------------------------------------------
console.log('[import] checking existing records in target workspace...');
const partnerSlugs = uniq(tftPeople.map(personSlug));
const partnerIdBySlug = new Map<string, string>(
partnerSlugs.length
@@ -195,17 +232,33 @@ async function main() {
: [],
);
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
const companyIdByName = new Map<string, string>(
companyNames.length
? edges(
await local.query({
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'companies',
).map((n: any) => [n.name, n.id])
: [],
);
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
// opps and content, and creating a second one collides on the domain constraint.
// Page through ALL companies (not just the first 500): these dedupe maps must
// be complete, or upsertCompany would create domain-colliding duplicates for
// companies that live beyond the first page in a larger workspace.
const existingCompanies: any[] = [];
let companiesCursor: string | undefined;
for (;;) {
const page: any = await local.query({
companies: {
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
pageInfo: { hasNextPage: true, endCursor: true },
},
} as any);
existingCompanies.push(...edges(page, 'companies'));
if (!page?.companies?.pageInfo?.hasNextPage) break;
companiesCursor = page.companies.pageInfo.endCursor;
}
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
const companyIdByDomain = new Map<string, string>();
for (const c of existingCompanies) {
const nd = normDomain(c.domainName?.primaryLinkUrl);
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
}
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
const oppIdByTftId = new Map<string, string>(
@@ -219,14 +272,18 @@ async function main() {
: [],
);
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
const quoteIdByName = new Map<string, string>(
quoteNames.length
// Unnamed content upserts by name, so a constant fallback would make every
// unnamed record collide on one key (collapsing them on re-run). Key the
// fallback on the TFT id so each unnamed record stays distinct.
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
const contentNames = uniq(contentRecords.map(contentName));
const contentIdByName = new Map<string, string>(
contentNames.length
? edges(
await local.query({
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'partnerQuotes',
'partnerContents',
).map((n: any) => [n.name, n.id])
: [],
);
@@ -238,43 +295,102 @@ async function main() {
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
if (!name) return undefined;
if (companyIdByName.has(name)) return companyIdByName.get(name);
const nd = normDomain(domain);
// Same company under a different name but same domain — reuse it.
if (nd && companyIdByDomain.has(nd)) {
const existingId = companyIdByDomain.get(nd) as string;
companyIdByName.set(name, existingId);
return existingId;
}
let id = `dry:company:${name}`;
if (APPLY) {
await pace();
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
try {
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
} catch (err) {
// Fallback: createCompany failed, almost certainly because the domain
// already exists (Twenty enforces a unique domain) on a company we
// didn't index. Re-find it and reuse instead of failing the import.
// `ilike` is a substring match — and the stored value carries an
// https:// prefix so we can't `eq` the normalized form — so it can
// return the wrong company ("acme.com" also matches "notacme.com" or
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
if (!nd) throw err;
await pace();
const candidates = edges(
await local.query({
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
} as any),
'companies',
);
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
if (!match?.id) throw err;
id = match.id;
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
}
}
companyIdByName.set(name, id);
if (nd) companyIdByDomain.set(nd, id);
return id;
};
const budgetCurrency = (amount: any) =>
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
// Generic create/update dispatch shared by partners, opportunities and content.
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
// only builds its `data`. genql keys a mutation by the object name, so the
// create<Object>/update<Object> names are derived from one argument. Companies
// keep their own upsert (above) because of the domain-collision fallback.
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
// relation mapping in dry-run still resolves to a stable placeholder.
const upsert = async (
object: 'Partner' | 'Opportunity' | 'PartnerContent',
existingId: string | undefined,
data: Record<string, unknown>,
dryKey: string,
): Promise<string> => {
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
await pace();
if (existingId) {
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
return existingId;
}
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
return created[`create${object}`].id;
};
// -- Partners (upsert by slug) --
console.log(`[import] upserting ${tftPeople.length} partners...`);
const localPartnerIdByTftPersonId = new Map<string, string>();
let partnersCreated = 0;
let partnersUpdated = 0;
let partnersDone = 0;
for (const p of tftPeople) {
note('partnerStage', p.partnerStage);
note('partnerTier', p.partnerTier);
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
note('typeOfTeam', p.partnerTypeOfTeam);
const slug = personSlug(p);
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
// A partner scoped for hosting is, by definition, a self-host expert.
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
const data: Record<string, unknown> = {
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
slug,
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
// TFT has no language data; default everyone to English.
languagesSpoken: ['ENGLISH'],
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
...(scope.length ? { partnerScope: scope } : {}),
...(region.length ? { region } : {}),
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
@@ -284,39 +400,29 @@ async function main() {
...(companyId && APPLY ? { companyId } : {}),
};
let partnerId = partnerIdBySlug.get(slug);
if (partnerId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
}
const existingId = partnerIdBySlug.get(slug);
const partnerId = await upsert('Partner', existingId, data, slug);
if (existingId) {
partnersUpdated++;
} else {
if (APPLY) {
await pace();
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerId = created.createPartner.id;
} else {
partnerId = `dry:partner:${slug}`;
}
partnerIdBySlug.set(slug, partnerId as string);
partnerIdBySlug.set(slug, partnerId);
partnersCreated++;
}
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
localPartnerIdByTftPersonId.set(p.id, partnerId);
partnersDone++;
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
}
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
// -- Opportunities (upsert by tftOpportunityId) --
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
let oppsCreated = 0;
let oppsUpdated = 0;
let oppsPartnerLinked = 0;
let oppsDone = 0;
for (const o of tftOpps) {
if (!o.name) continue;
note('oppStage', o.stage);
note('hostingType', o.hostingType);
note('subscriptionType', o.subscriptionType);
note('subscriptionFrequency', o.subscriptionFrequence);
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
if (partnerId) oppsPartnerLinked++;
@@ -338,58 +444,44 @@ async function main() {
};
const existingId = oppIdByTftId.get(o.id);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
}
oppsUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
}
oppsCreated++;
}
await upsert('Opportunity', existingId, data, o.id);
if (existingId) oppsUpdated++;
else oppsCreated++;
oppsDone++;
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
}
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
// -- Partner quotes (upsert by name) --
let quotesCreated = 0;
let quotesUpdated = 0;
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
for (const q of quotes) {
note('quoteStatus', q.status);
const name = q.name || 'Partner quote';
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
// -- Partner content (upsert by name) --
console.log(`[import] upserting ${contentRecords.length} content records...`);
let contentCreated = 0;
let contentUpdated = 0;
for (const c of contentRecords) {
const name = contentName(c);
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
const data: Record<string, unknown> = {
name,
...(q.status ? { status: q.status } : {}),
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
...(c.status ? { status: c.status } : {}),
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
};
const existingId = quoteIdByName.get(name);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
}
quotesUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quotesCreated++;
}
const existingId = contentIdByName.get(name);
await upsert('PartnerContent', existingId, data, name);
if (existingId) contentUpdated++;
else contentCreated++;
}
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
// ---------------------------------------------------------------------
// 4. Preflight: distinct TFT values vs local option coverage.
// 4. Preflight: distinct TFT values vs local option coverage. Derived
// directly from the fetched source rows (no per-loop bookkeeping).
// ---------------------------------------------------------------------
const report = (label: string, bucket: string, optionKey?: string) => {
const values = [...seen[bucket]].sort();
const report = (label: string, values: string[], optionKey?: string) => {
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
console.log(
@@ -397,21 +489,27 @@ async function main() {
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
);
};
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
const oppStages = distinct(tftOpps, (o: any) => o.stage);
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
console.log('--- preflight: distinct TFT values seen ---');
report('partnerStage (-> validationStage map)', 'partnerStage');
report('partnerTier', 'partnerTier', 'partnerTier');
report('partnerScope', 'partnerScope', 'partnerScope');
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
report('opp stage (-> matchStatus map)', 'oppStage');
report('hostingType', 'hostingType', 'hostingType');
report('subscriptionType', 'subscriptionType', 'subscriptionType');
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
report('quote status', 'quoteStatus', 'quoteStatus');
report('customerContent typeCustom', 'typeCustom');
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
report('partnerStage (-> validationStage map)', partnerStages);
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
report('partnerTimezone (-> region map)', timezones);
report('opp stage (-> matchStatus map)', oppStages);
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
}
main().catch((err) => {
@@ -0,0 +1,59 @@
// Hard-destroy soft-deleted records that block re-imports.
//
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
// unique constraints (e.g. company domain, partner slug). But normal queries —
// including the import's existence checks — exclude soft-deleted rows. So after a
// UI "delete" or a partial import that got rolled back, re-running the import hits
// "A duplicate entry was detected" on records it cannot see. This purges those
// ghosts permanently so idempotent upserts work again.
//
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
// left untouched. One bulk destroy per object, so it is not rate-limited.
//
// yarn purge # against .env.local
// yarn purge:prod # against .env.prod
//
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
// Objects the import writes to. partners + partnerContents are app custom objects;
// companies + opportunities are standard but populated by the import.
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
const gql = async (url: string, key: string, query: string): Promise<any> => {
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const json: any = await response.json();
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
return json.data;
};
async function main() {
const url = requireEnv('TWENTY_PARTNERS_API_URL');
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
for (const obj of OBJECTS) {
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
const destroyed = data[mutationName]?.length ?? 0;
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
}
console.log('[purge] done');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -11,7 +11,7 @@
// tsx src/scripts/seed.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
@@ -89,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
];
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
const QUOTES: Quote[] = [
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
];
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
@@ -177,12 +177,12 @@ async function main() {
// -- Partner quotes (upsert by name) --
let quoteCount = 0;
for (const q of QUOTES) {
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
if (existing[0]?.id) {
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
} else {
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
}
quoteCount++;
}
@@ -1,21 +1,21 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Index view for partner quotes.
// Index view for partner content.
export default defineView({
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner quotes',
icon: 'IconFileDollar',
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner content',
icon: 'IconQuote',
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
],
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.7.0",
"version": "2.8.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -42,7 +42,7 @@
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.25.0",
"esbuild": "^0.28.0",
"graphql": "^16.8.1"
},
"devDependencies": {
@@ -247,7 +247,7 @@ type FieldPermission {
type RolePermissionFlag {
id: UUID!
roleId: UUID!
flag: PermissionFlagType!
flag: String!
}
type ApiKeyForRole {
@@ -523,6 +523,7 @@ type IndexField {
id: UUID!
fieldMetadataId: UUID!
order: Float!
subFieldName: String
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -2476,6 +2477,18 @@ type ImapSmtpCaldavConnectionSuccess {
connectedAccountId: String!
}
type Webhook {
id: UUID!
targetUrl: String!
operations: [String!]!
description: String
secret: String!
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
}
type ToolIndexEntry {
name: String!
description: String!
@@ -2902,18 +2915,6 @@ type MinimalMetadata {
collectionHashes: [CollectionHash!]!
}
type Webhook {
id: UUID!
targetUrl: String!
operations: [String!]!
description: String
secret: String!
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
}
type Query {
navigationMenuItems: [NavigationMenuItem!]!
navigationMenuItem(id: UUID!): NavigationMenuItem
@@ -2982,6 +2983,8 @@ type Query {
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
field(
"""The id of the record to find."""
id: UUID!
@@ -2999,8 +3002,6 @@ type Query {
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
@@ -3209,6 +3210,8 @@ type Mutation {
createOneObject(input: CreateOneObjectInput!): Object!
deleteOneObject(input: DeleteOneObjectInput!): Object!
updateOneObject(input: UpdateOneObjectInput!): Object!
createOneIndex(input: CreateOneIndexInput!): Index!
deleteOneIndex(input: DeleteOneIndexInput!): Index!
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
@@ -3222,6 +3225,9 @@ type Mutation {
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
removeRoleFromAgent(agentId: UUID!): Boolean!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createOneField(input: CreateOneFieldMetadataInput!): Field!
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
deleteOneField(input: DeleteOneFieldInput!): Field!
@@ -3238,9 +3244,6 @@ type Mutation {
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
@@ -3925,6 +3928,27 @@ input UpdateObjectPayload {
isSearchable: Boolean
}
input CreateOneIndexInput {
"""The custom index to create"""
index: CreateIndexInput!
}
input CreateIndexInput {
objectMetadataId: UUID!
fields: [CreateIndexFieldInput!]!
indexType: IndexType! = BTREE
}
input CreateIndexFieldInput {
fieldMetadataId: UUID!
subFieldName: String
}
input DeleteOneIndexInput {
"""The id of the custom index to delete."""
id: UUID!
}
input CreateAgentInput {
name: String
label: String!
@@ -4005,7 +4029,7 @@ input ObjectPermissionInput {
input UpsertPermissionFlagsInput {
roleId: UUID!
permissionFlagKeys: [PermissionFlagType!]!
permissionFlagKeys: [String!]!
}
input UpsertFieldPermissionsInput {
@@ -4047,6 +4071,29 @@ input RowLevelPermissionPredicateGroupInput {
positionInRowLevelPermissionPredicateGroup: Float
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
operations: [String!]!
description: String
secret: String
}
input UpdateWebhookInput {
"""The id of the webhook to update"""
id: UUID!
"""The webhook fields to update"""
update: UpdateWebhookInputUpdates!
}
input UpdateWebhookInputUpdates {
targetUrl: String
operations: [String!]
description: String
secret: String
}
input CreateOneFieldMetadataInput {
"""The record to create"""
field: CreateFieldInput!
@@ -4184,29 +4231,6 @@ input UpdateCalendarChannelInputUpdates {
isSyncEnabled: Boolean
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
operations: [String!]!
description: String
secret: String
}
input UpdateWebhookInput {
"""The id of the webhook to update"""
id: UUID!
"""The webhook fields to update"""
update: UpdateWebhookInputUpdates!
}
input UpdateWebhookInputUpdates {
targetUrl: String
operations: [String!]
description: String
secret: String
}
input FileAttachmentInput {
id: UUID!
filename: String!
@@ -193,7 +193,7 @@ export interface FieldPermission {
export interface RolePermissionFlag {
id: Scalars['UUID']
roleId: Scalars['UUID']
flag: PermissionFlagType
flag: Scalars['String']
__typename: 'RolePermissionFlag'
}
@@ -386,6 +386,7 @@ export interface IndexField {
id: Scalars['UUID']
fieldMetadataId: Scalars['UUID']
order: Scalars['Float']
subFieldName?: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'IndexField'
@@ -2160,6 +2161,19 @@ export interface ImapSmtpCaldavConnectionSuccess {
__typename: 'ImapSmtpCaldavConnectionSuccess'
}
export interface Webhook {
id: Scalars['UUID']
targetUrl: Scalars['String']
operations: Scalars['String'][]
description?: Scalars['String']
secret: Scalars['String']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
__typename: 'Webhook'
}
export interface ToolIndexEntry {
name: Scalars['String']
description: Scalars['String']
@@ -2528,19 +2542,6 @@ export interface MinimalMetadata {
__typename: 'MinimalMetadata'
}
export interface Webhook {
id: Scalars['UUID']
targetUrl: Scalars['String']
operations: Scalars['String'][]
description?: Scalars['String']
secret: Scalars['String']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
__typename: 'Webhook'
}
export interface Query {
navigationMenuItems: NavigationMenuItem[]
navigationMenuItem?: NavigationMenuItem
@@ -2591,6 +2592,8 @@ export interface Query {
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
webhooks: Webhook[]
webhook?: Webhook
field: Field
fields: FieldConnection
getViewGroups: ViewGroup[]
@@ -2599,8 +2602,6 @@ export interface Query {
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
@@ -2742,6 +2743,8 @@ export interface Mutation {
createOneObject: Object
deleteOneObject: Object
updateOneObject: Object
createOneIndex: Index
deleteOneIndex: Index
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
@@ -2755,6 +2758,9 @@ export interface Mutation {
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
removeRoleFromAgent: Scalars['Boolean']
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
createOneField: Field
updateOneField: Field
deleteOneField: Field
@@ -2771,9 +2777,6 @@ export interface Mutation {
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
@@ -3257,6 +3260,7 @@ export interface IndexFieldGenqlSelection{
id?: boolean | number
fieldMetadataId?: boolean | number
order?: boolean | number
subFieldName?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
@@ -5166,6 +5170,20 @@ export interface ImapSmtpCaldavConnectionSuccessGenqlSelection{
__scalar?: boolean | number
}
export interface WebhookGenqlSelection{
id?: boolean | number
targetUrl?: boolean | number
operations?: boolean | number
description?: boolean | number
secret?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ToolIndexEntryGenqlSelection{
name?: boolean | number
description?: boolean | number
@@ -5541,20 +5559,6 @@ export interface MinimalMetadataGenqlSelection{
__scalar?: boolean | number
}
export interface WebhookGenqlSelection{
id?: boolean | number
targetUrl?: boolean | number
operations?: boolean | number
description?: boolean | number
secret?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface QueryGenqlSelection{
navigationMenuItems?: NavigationMenuItemGenqlSelection
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5617,6 +5621,8 @@ export interface QueryGenqlSelection{
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
field?: (FieldGenqlSelection & { __args: {
/** The id of the record to find. */
id: Scalars['UUID']} })
@@ -5631,8 +5637,6 @@ export interface QueryGenqlSelection{
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
minimalMetadata?: MinimalMetadataGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5795,6 +5799,8 @@ export interface MutationGenqlSelection{
createOneObject?: (ObjectGenqlSelection & { __args: {input: CreateOneObjectInput} })
deleteOneObject?: (ObjectGenqlSelection & { __args: {input: DeleteOneObjectInput} })
updateOneObject?: (ObjectGenqlSelection & { __args: {input: UpdateOneObjectInput} })
createOneIndex?: (IndexGenqlSelection & { __args: {input: CreateOneIndexInput} })
deleteOneIndex?: (IndexGenqlSelection & { __args: {input: DeleteOneIndexInput} })
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
@@ -5808,6 +5814,9 @@ export interface MutationGenqlSelection{
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createOneField?: (FieldGenqlSelection & { __args: {input: CreateOneFieldMetadataInput} })
updateOneField?: (FieldGenqlSelection & { __args: {input: UpdateOneFieldMetadataInput} })
deleteOneField?: (FieldGenqlSelection & { __args: {input: DeleteOneFieldInput} })
@@ -5824,9 +5833,6 @@ export interface MutationGenqlSelection{
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
@@ -6126,6 +6132,18 @@ id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
export interface CreateOneIndexInput {
/** The custom index to create */
index: CreateIndexInput}
export interface CreateIndexInput {objectMetadataId: Scalars['UUID'],fields: CreateIndexFieldInput[],indexType: IndexType}
export interface CreateIndexFieldInput {fieldMetadataId: Scalars['UUID'],subFieldName?: (Scalars['String'] | null)}
export interface DeleteOneIndexInput {
/** The id of the custom index to delete. */
id: Scalars['UUID']}
export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt: Scalars['String'],modelId: Scalars['String'],roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
@@ -6142,7 +6160,7 @@ export interface UpsertObjectPermissionsInput {roleId: Scalars['UUID'],objectPer
export interface ObjectPermissionInput {objectMetadataId: Scalars['UUID'],canReadObjectRecords?: (Scalars['Boolean'] | null),canUpdateObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteObjectRecords?: (Scalars['Boolean'] | null),canDestroyObjectRecords?: (Scalars['Boolean'] | null)}
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: PermissionFlagType[]}
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: Scalars['String'][]}
export interface UpsertFieldPermissionsInput {roleId: Scalars['UUID'],fieldPermissions: FieldPermissionInput[]}
@@ -6154,6 +6172,16 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
/** The id of the webhook to update */
id: Scalars['UUID'],
/** The webhook fields to update */
update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface CreateOneFieldMetadataInput {
/** The record to create */
field: CreateFieldInput}
@@ -6206,16 +6234,6 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
/** The id of the webhook to update */
id: Scalars['UUID'],
/** The webhook fields to update */
update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
@@ -7937,6 +7955,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Webhook_possibleTypes: string[] = ['Webhook']
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
return Webhook_possibleTypes.includes(obj.__typename)
}
const ToolIndexEntry_possibleTypes: string[] = ['ToolIndexEntry']
export const isToolIndexEntry = (obj?: { __typename?: any } | null): obj is ToolIndexEntry => {
if (!obj?.__typename) throw new Error('__typename is missing in "isToolIndexEntry"')
@@ -8201,14 +8227,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Webhook_possibleTypes: string[] = ['Webhook']
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
return Webhook_possibleTypes.includes(obj.__typename)
}
const Query_possibleTypes: string[] = ['Query']
export const isQuery = (obj?: { __typename?: any } | null): obj is Query => {
if (!obj?.__typename) throw new Error('__typename is missing in "isQuery"')
File diff suppressed because it is too large Load Diff
@@ -44,8 +44,53 @@ A Twenty app's **data layer** is the data your app *adds* to a workspace — the
| **Object** | A new custom record type (e.g. PostCard, Invoice) with its own fields | `defineObject()` |
| **Field** | A column on an object. Standalone fields can extend objects you didn't create (e.g. add `loyaltyTier` to Company) | `defineField()` |
| **Relation** | A bidirectional link between two objects — both sides declared as fields | `defineField()` with `FieldType.RELATION` |
| **Index** | A database index to speed up a recurring query on one of your objects | `defineIndex()` |
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/` and `src/fields/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/`, `src/fields/`, and `src/indexes/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
## Indexes (optional)
Apps can ship indexes alongside their objects to keep recurring queries fast. The most common case is a status or foreign-key column that you read frequently.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Unique indexes
`defineIndex` accepts `isUnique: true` for both single- and multi-column uniqueness. This is the recommended primitive — `defineField({ isUnique: true })` is deprecated and will be removed in a future release.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Other constraints
- Partial `WHERE` clauses stay under admin control — apps can't declare them.
- Each object is capped at 10 custom indexes (the framework's own indexes don't count).
Order the `fields` array the way Postgres should use it — leftmost column first, like a phone book. Indexes are not free: every write to the table updates them. Add one only when you have a query that needs it.
<Note>
Looking for **Application Config** or **Roles & Permissions**? Those describe the app itself rather than the data it adds — they live under [Config](/developers/extend/apps/config/overview). Looking for **Connections** (Linear, GitHub, Slack OAuth)? Those exist to be called *from* logic functions and live under [Logic](/developers/extend/apps/logic/connections).
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
@@ -16,10 +16,9 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is
### Rotate the current key
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
<Note>Auto-rotation ships in v2.6+.</Note>
### Revoke a key (leak / emergency only)
@@ -44,8 +44,53 @@ Die **Datenebene** einer Twenty-App umfasst die Daten, die Ihre App zu einem Wor
| **Objekt** | Ein neuer benutzerdefinierter Datensatztyp (z. B. PostCard, Invoice) mit eigenen Feldern | `defineObject()` |
| **Feld** | Eine Spalte in einem Objekt. Eigenständige Felder können Objekte erweitern, die Sie nicht erstellt haben (z. B. `loyaltyTier` zu Company hinzufügen) | `defineField()` |
| **Beziehung** | Eine bidirektionale Verknüpfung zwischen zwei Objekten beide Seiten werden als Felder deklariert | `defineField()` mit `FieldType.RELATION` |
| **Indizes** | Ein Datenbankindex, um eine wiederkehrende Abfrage für eines Ihrer Objekte zu beschleunigen | `defineIndex()` |
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist die Konvention ist `src/objects/` und `src/fields/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist die Konvention ist `src/objects/`, `src/fields/` und `src/indexes/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
## Indizes (optional)
Apps können Indizes gemeinsam mit ihren Objekten ausliefern, um wiederkehrende Abfragen schnell zu halten. Der häufigste Fall ist eine Status- oder Fremdschlüsselspalte, die Sie häufig lesen.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Eindeutige Indizes
`defineIndex` akzeptiert `isUnique: true` sowohl für Einspalten- als auch Mehrspalteneindeutigkeit. Dies ist das empfohlene Primitive `defineField({ isUnique: true })` ist veraltet und wird in einer zukünftigen Version entfernt.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Andere Einschränkungen
* Partielle `WHERE`-Klauseln bleiben unter Kontrolle der Administratoren Apps können sie nicht deklarieren.
* Jedes Objekt ist auf 10 benutzerdefinierte Indizes begrenzt (die Indizes des Frameworks selbst werden nicht mitgezählt).
Ordnen Sie das `fields`-Array so an, wie Postgres es verwenden soll die ganz linke Spalte zuerst, wie in einem Telefonbuch. Indizes sind nicht kostenlos: Jeder Schreibvorgang in die Tabelle aktualisiert sie. Fügen Sie einen nur dann hinzu, wenn Sie eine Abfrage haben, die ihn benötigt.
<Note>
Suchen Sie nach **Application Config** oder **Roles & Permissions**? Diese beschreiben die App selbst und nicht die Daten, die sie hinzufügt sie befinden sich unter [Config](/l/de/developers/extend/apps/config/overview). Suchen Sie nach **Connections** (Linear, GitHub, Slack OAuth)? Diese existieren, um *von* Logikfunktionen aufgerufen zu werden, und befinden sich unter [Logic](/l/de/developers/extend/apps/logic/connections).
@@ -16,10 +16,9 @@ Jeder Schlüssel enthält einen `publicKey` (unbefristet aufbewahrt, damit er zu
### Aktuellen Schlüssel rotieren
* **Manuell** — **Settings → Admin Panel → Signing keys → Revoke** in der aktuellen Zeile. Das Widerrufen löscht das verschlüsselte private Material und stuft es herab; der nächste Signieraufruf erstellt automatisch ein neues ES256-Schlüsselpaar als neuen aktuellen Schlüssel. Tokens, die unter einem anderen (nicht widerrufenen) `kid` signiert wurden, werden weiterhin verifiziert, bis sie ablaufen.
* **Enterprise (automatisch)** — ein täglicher Cron-Job (`'15 3 * * *'` UTC) erstellt einen neuen aktuellen Schlüssel, sobald der vorhandene Schlüssel seit `SIGNING_KEY_ROTATION_DAYS` (Standard `90`) aktuell ist. Der vorherige Schlüssel wird *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Registriere ihn einmalig mit `yarn command:prod cron:register:all`.
Setzen Sie `SIGNING_KEY_ROTATION_DAYS`, um die Funktion zu aktivieren: Ein täglicher Cronjob erstellt dann einen neuen aktuellen Schlüssel, sobald der bestehende Schlüssel älter als dieser Schwellenwert ist. Frühere Schlüssel werden *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Lassen Sie die Variable ungesetzt, um die automatische Rotation zu deaktivieren.
<Note>Der Enterprise-Cron-Job und `SIGNING_KEY_ROTATION_DAYS` sind ab v2.6+ enthalten.</Note>
<Note>Die automatische Rotation ist ab Version v2.6 verfügbar.</Note>
### Einen Schlüssel widerrufen (nur bei Leak / Notfall)
@@ -101,6 +101,10 @@ Machen Sie ein Feld einzigartig, um sicherzustellen, dass sich keine verschieden
Wenn beim Einstellen der Einzigartigkeit ein Fehler auftritt, überprüfen Sie auf doppelte Werte in Ihren Daten (einschließlich gelöschter Datensätze).
## Indizes (Erweitert)
Datenbankindizes werden automatisch verwaltet eigene hinzuzufügen ist selten erforderlich und kann leicht schiefgehen. Wenn der Erweiterte Modus aktiviert ist, hat jedes Objekt unter `Einstellungen → Datenmodell → <object>` einen Abschnitt **Indizes** für die Fälle, in denen du weißt, dass du einen brauchst.
## Beste Praktiken zur Feldkonfiguration
### Benennungskonventionen und Einschränkungen
@@ -44,8 +44,53 @@ A **camada de dados** de um app Twenty é o conjunto de dados que seu app *adici
| **Objeto** | Um novo tipo de registro personalizado (por exemplo, PostCard, Invoice) com seus próprios campos | `defineObject()` |
| **Campo** | Uma coluna em um objeto. Campos independentes podem estender objetos que você não criou (por exemplo, adicionar `loyaltyTier` ao objeto Company) | `defineField()` |
| **Relação** | Um vínculo bidirecional entre dois objetos — ambos os lados declarados como campos | `defineField()` com `FieldType.RELATION` |
| **Índice** | Um índice de banco de dados para acelerar uma consulta recorrente em um dos seus objetos | `defineIndex()` |
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/` e `src/fields/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/`, `src/fields/` e `src/indexes/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
## Índices (Opcional)
Os apps podem incluir índices junto com seus objetos para manter rápidas as consultas recorrentes. O caso mais comum é uma coluna de status ou de chave estrangeira que você lê com frequência.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Índices únicos
`defineIndex` aceita `isUnique: true` tanto para unicidade de uma única coluna quanto de múltiplas colunas. Este é o recurso recomendado — `defineField({ isUnique: true })` está obsoleto e será removido em uma versão futura.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Outras restrições
* Cláusulas `WHERE` parciais permanecem sob controle do administrador — os apps não podem declará-las.
* Cada objeto é limitado a 10 índices personalizados (os índices do próprio framework não contam).
Ordene o array `fields` da forma como o Postgres deve usá-lo — coluna mais à esquerda primeiro, como em uma lista telefônica. Índices não são gratuitos: cada gravação na tabela os atualiza. Adicione um apenas quando você tiver uma consulta que precise dele.
<Note>
Procurando por **Application Config** ou **Roles & Permissions**? Esses descrevem o próprio app em vez dos dados que ele adiciona — eles ficam em [Config](/l/pt/developers/extend/apps/config/overview). Procurando por **Connections** (Linear, GitHub, Slack OAuth)? Essas existem para serem chamadas *a partir de* funções de lógica e ficam em [Logic](/l/pt/developers/extend/apps/logic/connections).
@@ -16,10 +16,9 @@ Cada chave carrega uma `publicKey` (mantida indefinidamente para que possa verif
### Rotacionar a chave atual
* **Manual** — **Settings → Admin Panel → Signing keys → Revoke** na linha atual. A revogação apaga o material privado criptografado e o rebaixa; a próxima chamada de assinatura gera automaticamente um novo par de chaves ES256 como o novo atual. Tokens assinados sob qualquer outro `kid` (não revogado) continuam sendo verificados até expirarem.
* **Enterprise (automático)** — um cron diário (`'15 3 * * *'` UTC) emite uma nova chave atual assim que a existente tiver sido atual por `SIGNING_KEY_ROTATION_DAYS` (padrão `90`). A chave anterior *não* é revogada, então tokens assinados sob ela continuam sendo verificados. Registre-o uma vez com `yarn command:prod cron:register:all`.
Defina `SIGNING_KEY_ROTATION_DAYS` para ativar: um cron diário então gera uma nova chave e a define como atual quando a existente for mais antiga do que esse limite. Chaves anteriores *não* são revogadas, então tokens assinados sob elas continuam sendo verificados. Deixe a variável não definida para desativar a rotação automática.
<Note>O cron do Enterprise e `SIGNING_KEY_ROTATION_DAYS` são disponibilizados a partir da v2.6+.</Note>
<Note>A rotação automática está disponível a partir da v2.6+.</Note>
### Revogar uma chave (apenas vazamento / emergência)
@@ -101,6 +101,10 @@ Torne um campo único para garantir que registros distintos não possam ter o me
Se você receber um erro ao definir exclusividade, verifique se há valores duplicados nos seus dados (incluindo registros excluídos).
## Índices (Avançado)
Os índices do banco de dados são gerenciados automaticamente — adicionar os seus próprios raramente é necessário e é fácil cometer erros. Com o modo Avançado ativado, cada objeto tem uma seção **Índices** em `Settings → Data Model → <object>` para os casos em que você sabe que precisa de um índice.
## Melhores Práticas de Configuração de Campos
### Convenções de Nomeação e Limitações
@@ -44,8 +44,53 @@ Stratul de **date** al unei aplicații Twenty reprezintă datele pe care aplica
| **Obiect** | Un nou tip de înregistrare personalizat (de ex. PostCard, Invoice) cu propriile sale câmpuri | `defineObject()` |
| **Câmp** | O coloană pe un obiect. Câmpurile independente pot extinde obiecte pe care nu le-ați creat (de ex. adăugați `loyaltyTier` la Company) | `defineField()` |
| **Relație** | O legătură bidirecțională între două obiecte — ambele părți declarate ca câmpuri | `defineField()` cu `FieldType.RELATION` |
| **Indice** | Un indice de bază de date pentru a accelera o interogare recurentă asupra unuia dintre obiectele tale | `defineIndex()` |
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de dumneavoastră — convenția este `src/objects/` și `src/fields/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de tine — convenția este `src/objects/`, `src/fields/` și `src/indexes/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
## Indici (opțional)
Aplicațiile pot livra indici împreună cu obiectele lor pentru a menține rapide interogările recurente. Cel mai comun caz este o coloană de status sau o coloană cu cheie străină pe care o citești frecvent.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Indici unici
`defineIndex` acceptă `isUnique: true` atât pentru unicitatea pe o singură coloană, cât și pe mai multe coloane. Aceasta este primitiva recomandată — `defineField({ isUnique: true })` este învechită (deprecated) și va fi eliminată într-o versiune viitoare.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Alte constrângeri
* Clauzele `WHERE` parțiale rămân sub controlul administratorului — aplicațiile nu le pot declara.
* Fiecare obiect este limitat la 10 indici personalizați (indicii proprii ai framework-ului nu se pun la socoteală).
Ordonează array-ul `fields` în modul în care Postgres ar trebui să îl folosească — coloana din stânga prima, ca într-o agendă telefonică. Indicii nu sunt gratuiți: fiecare scriere în tabel îi actualizează. Adaugă unul doar atunci când ai o interogare care are nevoie de el.
<Note>
Căutați **Application Config** sau **Roles & Permissions**? Acestea descriu aplicația în sine, mai degrabă decât datele pe care le adaugă — se află la [Config](/l/ro/developers/extend/apps/config/overview). Căutați **Connections** (Linear, GitHub, Slack OAuth)? Acestea există pentru a fi apelate *din* funcții de logică și se află la [Logic](/l/ro/developers/extend/apps/logic/connections).
@@ -16,10 +16,9 @@ Fiecare cheie are un `publicKey` (păstrat pe termen nelimitat pentru a putea ve
### Rotește cheia curentă
* **Manual** — **Settings → Admin Panel → Signing keys → Revoke** pe rândul curent. Revocarea șterge materialul privat criptat și o retrogradează; următorul apel de semnare generează automat o nouă pereche de chei ES256 ca noua cheie curentă. Token-urile semnate sub orice alt `kid` (nerevocat) continuă să fie verificate până la expirare.
* **Enterprise (automat)** — un cron zilnic (`'15 3 * * *'` UTC) emite o nouă cheie curentă după ce cea existentă a fost curentă timp de `SIGNING_KEY_ROTATION_DAYS` (implicit `90`). Cheia anterioară *nu* este revocată, astfel încât token-urile semnate cu ea continuă să fie verificate. Înregistrează-l o singură dată cu `yarn command:prod cron:register:all`.
Setați `SIGNING_KEY_ROTATION_DAYS` pentru a activa rotația automată: un job cron zilnic va emite o nouă cheie curentă odată ce cea existentă este mai veche decât acest prag. Cheile anterioare *nu* sunt revocate, astfel încât token-urile semnate cu ele continuă să fie verificate. Lăsați variabila nesetată pentru a dezactiva rotația automată.
<Note>Cron-ul Enterprise și `SIGNING_KEY_ROTATION_DAYS` sunt disponibile în v2.6+.</Note>
<Note>Rotația automată este disponibilă începând cu versiunea v2.6+.</Note>
### Revocă o cheie (numai în caz de scurgere / urgență)
@@ -101,6 +101,10 @@ Faceți un câmp unic pentru a vă asigura că înregistrările distincte nu pot
Dacă primiți o eroare când setați unicitatea, verificați prezența valorilor duplicate în datele dumneavoastră (inclusiv în cele șterse).
## Indexuri (Avansat)
Indexurile bazei de date sunt gestionate automat — adăugarea unor indexuri proprii este rareori necesară și se greșește ușor. Cu modul Avansat activat, fiecare obiect are o secțiune **Indexuri** sub `Settings → Data Model → <object>` pentru cazurile în care știi că ai nevoie de unul.
## Cele mai bune practici pentru configurarea câmpurilor
### Convenții de denumire și limitări
@@ -44,8 +44,53 @@ icon: database
| **Объект** | Новый пользовательский тип записей (например, PostCard, Invoice) с собственными полями | `defineObject()` |
| **Поле** | Столбец в объекте. Отдельные поля могут расширять объекты, которые вы не создавали (например, добавьте `loyaltyTier` к объекту Company) | `defineField()` |
| **Связь** | Двусторонняя связь между двумя объектами — обе стороны объявлены как поля | `defineField()` с `FieldType.RELATION` |
| **Индекс** | Индекс базы данных для ускорения повторяющегося запроса к одному из ваших объектов | `defineIndex()` |
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/` и `src/fields/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/`, `src/fields/` и `src/indexes/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
## Индексы (необязательно)
Приложения могут поставлять индексы вместе со своими объектами, чтобы повторяющиеся запросы выполнялись быстро. Наиболее распространенный случай — столбец статуса или внешнего ключа, к которому вы часто обращаетесь при чтении.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Уникальные индексы
`defineIndex` принимает `isUnique: true` как для уникальности по одному столбцу, так и по нескольким столбцам. Это рекомендуемый примитив — `defineField({ isUnique: true })` устарел и будет удален в одном из будущих релизов.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Другие ограничения
* Частичные предложения `WHERE` остаются под контролем администратора — приложения не могут объявлять их.
* Для каждого объекта допускается не более 10 пользовательских индексов (индексы самого фреймворка не учитываются).
Упорядочьте массив `fields` в том порядке, в котором Postgres должен его использовать — сначала самый левый столбец, как в телефонной книге. Индексы не бесплатны: при каждой записи в таблицу они обновляются. Добавляйте индекс только тогда, когда у вас есть запрос, которому он действительно нужен.
<Note>
Ищете **Application Config** или **Roles & Permissions**? Они описывают само приложение, а не данные, которые оно добавляет, — их можно найти в разделе [Config](/l/ru/developers/extend/apps/config/overview). Ищете **Connections** (Linear, GitHub, Slack OAuth)? Они существуют для вызова *из* логических функций и находятся в разделе [Logic](/l/ru/developers/extend/apps/logic/connections).
@@ -16,10 +16,9 @@ icon: rotate
### Выполнить ротацию текущего ключа
* **Вручную** — **Settings → Admin Panel → Signing keys → Revoke** в текущей строке. Отзыв стирает его зашифрованный закрытый материал и понижает его; следующий вызов подписи автоматически создает новую пару ключей ES256 как новый текущий ключ. Токены, подписанные любым другим (не отозванным) `kid`, продолжают успешно проходить проверку до истечения срока действия.
* **Enterprise (автоматически)** — ежедневный cron (`'15 3 * * *'` UTC) выпускает новый текущий ключ, как только существующий ключ находится в статусе текущего в течение `SIGNING_KEY_ROTATION_DAYS` (по умолчанию `90`). Предыдущий ключ *не* отзывается, поэтому токены, подписанные им, продолжают успешно проходить проверку. Зарегистрируйте его один раз с помощью `yarn command:prod cron:register:all`.
Установите `SIGNING_KEY_ROTATION_DAYS`, чтобы включить эту опцию: ежедневный cron затем будет выпускать новый текущий ключ, как только существующий ключ станет старше этого порогового значения. Предыдущие ключи *не* отзываются, поэтому токены, подписанные ими, продолжают успешно проходить проверку. Оставьте переменную не заданной, чтобы отключить автоматическую ротацию.
<Note>Enterprise-cron и `SIGNING_KEY_ROTATION_DAYS` поставляются начиная с v2.6+.</Note>
<Note>Автоматическая ротация доступна, начиная с версии v2.6+.</Note>
### Отозвать ключ (только при утечке / в экстренных случаях)
@@ -101,6 +101,10 @@ Twenty поддерживает различные типы полей:
Если вы получаете ошибку при установке уникальности, проверьте дублирующиеся значения в ваших данных (включая удаленные записи).
## Индексы (расширенный режим)
Индексы базы данных управляются автоматически — добавлять собственные почти никогда не требуется и при этом легко допустить ошибку. При включенном расширенном режиме у каждого объекта есть раздел **Индексы** в `Settings → Data Model → <object>` для случаев, когда вы знаете, что вам нужен индекс.
## Лучшие практики в настройке полей
### Именование и ограничения
@@ -44,8 +44,53 @@ Bir Twenty uygulamasının **veri katmanı**, uygulamanızın bir çalışma ala
| **Nesne** | Kendi alanlarına sahip yeni bir özel kayıt türü (ör. PostCard, Invoice) | `defineObject()` |
| **Alan** | Bir nesne üzerindeki sütun. Bağımsız alanlar, oluşturmadığınız nesneleri genişletebilir (ör. Company nesnesine `loyaltyTier` ekleyin) | `defineField()` |
| **İlişki** | İki nesne arasında, her iki tarafı da alan olarak bildirilmiş çift yönlü bir bağlantı | `defineField()` ile `FieldType.RELATION` |
| **Dizin** | Nesnelerinizden biri üzerinde yinelenen bir sorguyu hızlandırmak için bir veritabanı dizini | `defineIndex()` |
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/` ve `src/fields/` dizinleridir. Kararlı `universalIdentifier` UUIDleri, dağıtımlar arasında her şeyi birbirine bağlar.
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/`, `src/fields/` ve `src/indexes/` dizinleridir. Kararlı `universalIdentifier` UUIDleri, dağıtımlar arasında her şeyi birbirine bağlar.
## Dizinler (İsteğe bağlı)
Uygulamalar, yinelenen sorguları hızlı tutmak için nesneleriyle birlikte dizinler sunabilir. En yaygın durum, sık okuduğunuz bir durum ya da yabancı anahtar sütunudur.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Benzersiz dizinler
`defineIndex`, hem tek sütunlu hem çok sütunlu benzersizlik için `isUnique: true` kabul eder. Önerilen yöntem budur — `defineField({ isUnique: true })` kullanımdan kaldırılmıştır ve gelecekteki bir sürümde kaldırılacaktır.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Diğer kısıtlamalar
* Kısmi `WHERE` koşulları yönetici kontrolü altında kalır — uygulamalar bunları tanımlayamaz.
* Her nesne, 10 özel dizin ile sınırlandırılmıştır (framework'ün kendi dizinleri buna dahil değildir).
`fields` dizisini, Postgres'in kullanması gereken şekilde sıralayın — en soldaki sütun ilk, bir telefon rehberinde olduğu gibi. Dizinler bedava değildir: tabloya yapılan her yazma işlemi bunları günceller. Bir dizini yalnızca ona ihtiyaç duyan bir sorgunuz olduğunda ekleyin.
<Note>
**Application Config** veya **Roles & Permissions** mı arıyorsunuz? Bunlar, ekledikleri verilerden çok uygulamanın kendisini tanımlar — [Config](/l/tr/developers/extend/apps/config/overview) altında bulunurlar. **Connections** (Linear, GitHub, Slack OAuth) mı arıyorsunuz? Bunlar, mantık fonksiyonları *içinden* çağrılmak için vardır ve [Logic](/l/tr/developers/extend/apps/logic/connections) altında bulunurlar.
@@ -16,10 +16,9 @@ Her anahtar bir `publicKey` (önceden verilmiş belirteçleri doğrulayabilmesi
### Geçerli anahtarı döndür
* **El ile** — geçerli satırda **Settings → Admin Panel → Signing keys → Revoke**. İptal etmek, şifrelenmiş özel materyalini siler ve onu geçerli olmaktan çıkarır; bir sonraki imzalama çağrısı, yeni geçerli olarak otomatik olarak yeni bir ES256 anahtar çifti oluşturur. Herhangi başka bir (iptal edilmemiş) `kid` ile imzalanan belirteçler, süreleri dolana kadar doğrulanmaya devam eder.
* **Kurumsal (otomatik)** — günlük bir cron (`'15 3 * * *'` UTC), mevcut anahtar `SIGNING_KEY_ROTATION_DAYS` (varsayılan `90`) kadar süredir geçerli olduğunda yeni bir geçerli anahtar verir. Önceki anahtar *iptal edilmez*, bu nedenle onunla imzalanan belirteçler doğrulanmaya devam eder. `yarn command:prod cron:register:all` ile bir kez kaydedin.
Etkinleştirmek için `SIGNING_KEY_ROTATION_DAYS` değerini ayarlayın: günlük bir cron görevi, mevcut anahtar bu eşikten daha eski olduğunda yeni bir güncel anahtar oluşturur. Önceki anahtarlar *iptal edilmez*, bu nedenle onlarla imzalanmış belirteçler doğrulanmaya devam eder. Otomatik döndürmeyi devre dışı bırakmak için değişkeni ayarlamadan bırakın.
<Note>Kurumsal cron ve `SIGNING_KEY_ROTATION_DAYS` v2.6+ ile birlikte gelir.</Note>
<Note>Otomatik döndürme v2.6+ ile birlikte gelir.</Note>
### Bir anahtarı iptal et (yalnızca sızıntı / acil durum için)
@@ -101,6 +101,10 @@ Farklı kayıtların aynı değere sahip olamaması için bir alanı benzersiz y
Benzersizliği ayarlarken bir hata alırsanız, verilerinizde (silinen kayıtlar dahil) yinelenen değerleri kontrol edin.
## Dizinler (Gelişmiş)
Veritabanı dizinleri otomatik olarak yönetilir — kendi dizinlerinizi eklemeniz nadiren gereklidir ve yanlış yapmak da kolaydır. Gelişmiş mod açıkken, bir dizine ihtiyacınız olduğunu bildiğiniz durumlar için her nesnenin `Ayarlar → Veri Modeli → <object>` altında bir **Dizinler** bölümü bulunur.
## Alan Yapılandırma En İyi Uygulamaları
### Adlandırma Kuralları ve Kısıtlamaları
@@ -44,8 +44,53 @@ Twenty 应用的 **数据层(data layer)** 是你的应用*添加*到工作
| **对象** | 具有自有字段的新自定义记录类型(例如 PostCard、Invoice | `defineObject()` |
| **字段** | 对象上的一列。 独立字段可以扩展你未创建的对象(例如向 Company 添加 `loyaltyTier` | `defineField()` |
| **关系** | 两个对象之间的双向链接——双方都声明为字段 | 使用 `defineField()` 并指定 `FieldType.RELATION` |
| **索引** | 用于加速在某个对象上经常执行的查询的数据库索引 | `defineIndex()` |
SDK 会在构建时通过 AST 分析检测这些内容,因此文件组织方式由你决定——约定是使用 `src/objects/` 和 `src/fields/`。 稳定的 `universalIdentifier` UUID 在不同部署之间将一切关联在一起。
SDK 会在构建时通过 AST 分析检测这些内容,因此文件组织方式由你决定——约定是使用 `src/objects/`、`src/fields/` 和 `src/indexes/`。 稳定的 `universalIdentifier` UUID 在不同部署之间将一切关联在一起。
## 索引(可选)
应用可以随对象一同提供索引,以确保经常执行的查询保持快速。 最常见的情况是某个你经常读取的状态列或外键列。
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### 唯一索引
`defineIndex` 接受 `isUnique: true`,可同时用于单列和多列表达唯一性约束。 这是推荐的基础方式——`defineField({ isUnique: true })` 已被弃用,并将在未来的版本中移除。
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### 其他约束
* 部分 `WHERE` 子句由管理员控制——应用无法声明它们。
* 每个对象最多只能有 10 个自定义索引(框架自身的索引不计入其中)。
按照 Postgres 使用索引的方式来排列 `fields` 数组——最左边的列放在最前面,就像电话簿一样。 索引不是免费的:对表的每一次写入都会更新索引。 只有当你确实有查询需要某个索引时才添加它。
<Note>
在找 **Application Config** 或 **Roles & Permissions** 吗? 这些描述的是应用本身而不是它添加的数据——相关内容位于 [Config](/l/zh/developers/extend/apps/config/overview) 下。 在找 **Connections**Linear、GitHub、Slack OAuth)吗? 这些用于*从*逻辑函数中调用,并位于 [Logic](/l/zh/developers/extend/apps/logic/connections) 下。
@@ -16,10 +16,9 @@ Twenty 具有两个相互独立的密钥族:
### 轮换当前密钥
* **手动** — 在当前行上执行 **Settings → Admin Panel → Signing keys → Revoke**。 吊销操作会清除其加密的私有材料并将其降级;下一次签名调用会自动生成一个新的 ES256 密钥对,作为新的当前密钥。 在任何其他(未被吊销的)`kid` 下签名的令牌会一直验证,直到过期
* **Enterprise(自动)** — 每日 cron`'15 3 * * *'` UTC)会在现有密钥已作为当前密钥使用 `SIGNING_KEY_ROTATION_DAYS`(默认 `90`)后签发一个新的当前密钥。 先前的密钥*不会*被吊销,因此在其下签名的令牌仍然可以通过验证。 使用 `yarn command:prod cron:register:all` 注册一次。
将 `SIGNING_KEY_ROTATION_DAYS` 设为启用该功能:然后每日的 cron 任务会在现有密钥超过该阈值后签发新的当前密钥。 先前的密钥*不会*被吊销,因此在其下签名的令牌仍然可以通过验证。 将变量保持未设置以禁用自动轮换
<Note>Enterprise 的 cron 和 `SIGNING_KEY_ROTATION_DAYS` 从 v2.6+ 版本开始提供。</Note>
<Note>自动轮换在 v2.6+ 提供。</Note>
### 吊销密钥(仅限泄露 / 紧急情况)
@@ -101,6 +101,10 @@ Twenty 支持多种字段类型:
如果在设置唯一性时出现错误,请检查数据中是否有重复值(包括删除的记录)。
## 索引(高级)
数据库索引由系统自动管理——自己添加索引通常没必要,而且也很容易出错。 在开启高级模式后,每个对象在 `Settings → Data Model → <object>` 下都会有一个 **Indexes** 部分,供你在确实需要索引时使用。
## 字段配置最佳实践
### 命名约定和限制
@@ -101,6 +101,10 @@ Make a field unique to ensure distinct records cannot have the same value. For e
If you get an error when setting uniqueness, check for duplicate values in your data (including deleted records).
## Indexes (Advanced)
Database indexes are managed automatically — adding your own is rarely necessary and easy to get wrong. With Advanced mode on, each object has an **Indexes** section under `Settings → Data Model → <object>` for the cases where you know you need one.
## Field Configuration Best Practices
### Naming Conventions and Limitations
@@ -66,10 +66,16 @@
],
"inputs": [
"{projectRoot}/scripts/front-component-stories/**/*",
"{projectRoot}/src/__stories__/example-sources/*",
"{projectRoot}/src/__stories__/html-tag/**/*",
"{projectRoot}/src/__stories__/host-api/**/*",
"{projectRoot}/src/__stories__/showcase/**/*",
"{projectRoot}/src/__stories__/shared/front-components/**/*",
"{workspaceRoot}/packages/twenty-sdk/src/cli/utilities/build/**/*"
],
"outputs": ["{projectRoot}/src/__stories__/example-sources-built/*"],
"outputs": [
"{projectRoot}/src/__stories__/example-sources-built/*",
"{projectRoot}/src/__stories__/example-sources-built-preact/*"
],
"options": {
"command": "tsx {projectRoot}/scripts/front-component-stories/build-source-examples.ts"
}
@@ -6,10 +6,7 @@ import { fileURLToPath } from 'node:url';
import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const exampleSourcesDir = path.resolve(
dirname,
'../../src/__stories__/example-sources',
);
const storiesDir = path.resolve(dirname, '../../src/__stories__');
const exampleSourcesBuiltDir = path.resolve(
dirname,
'../../src/__stories__/example-sources-built',
@@ -19,6 +16,8 @@ const exampleSourcesBuiltPreactDir = path.resolve(
'../../src/__stories__/example-sources-built-preact',
);
const SOURCE_SCAN_ROOTS = ['html-tag', 'host-api', 'showcase'];
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
const twentyUiIndividualIndex = path.resolve(
@@ -71,44 +70,69 @@ const storyAlias = {
...twentySharedAliases,
};
const STORY_COMPONENTS = [
'static.front-component',
'interactive.front-component',
'lifecycle.front-component',
'chakra-example.front-component',
'tailwind-example.front-component',
'emotion-example.front-component',
'styled-components-example.front-component',
'shadcn-example.front-component',
'mui-example.front-component',
'twenty-ui-example.front-component',
'sdk-context-example.front-component',
'form-events.front-component',
'keyboard-events.front-component',
'host-api-calls.front-component',
'caret-preservation.front-component',
'file-input.front-component',
];
const ENTRY_POINT_PATTERN = /\.front-component\.tsx$/;
const findEntryPointFiles = (directory: string): string[] => {
const result: string[] = [];
if (!fs.existsSync(directory)) {
return result;
}
for (const dirent of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, dirent.name);
if (dirent.isDirectory()) {
if (dirent.name === 'shared') {
continue;
}
result.push(...findEntryPointFiles(absolutePath));
continue;
}
if (!dirent.isFile()) {
continue;
}
if (ENTRY_POINT_PATTERN.test(dirent.name)) {
result.push(absolutePath);
}
}
return result;
};
const resolveEntryPoints = (): Record<string, string> => {
const files = SOURCE_SCAN_ROOTS.flatMap((root) =>
findEntryPointFiles(path.join(storiesDir, root)),
);
const entryPoints: Record<string, string> = {};
for (const name of STORY_COMPONENTS) {
const filePath = path.join(exampleSourcesDir, `${name}.tsx`);
for (const filePath of files) {
const basename = path.basename(filePath).replace(/\.tsx$/, '');
if (!fs.existsSync(filePath)) {
if (entryPoints[basename] !== undefined) {
throw new Error(
`Story component source file not found: ${filePath}\n` +
`Ensure the file exists in ${exampleSourcesDir} and the name in STORY_COMPONENTS is correct.`,
`Duplicate front-component basename "${basename}" found at ${filePath} and ${entryPoints[basename]}`,
);
}
entryPoints[name] = filePath;
entryPoints[basename] = filePath;
}
if (Object.keys(entryPoints).length === 0) {
throw new Error(
`No front-component source files found under ${storiesDir} (scanned: ${SOURCE_SCAN_ROOTS.join(', ')})`,
);
}
return entryPoints;
};
const STORY_COMPONENTS = Object.keys(resolveEntryPoints());
type BundleSizeEntry = {
name: string;
reactBytes: number;
@@ -107,6 +107,66 @@ const generateCommonEventsType = (
},
],
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: 'createSerializedEventConfig',
initializer: (writer) => {
writer.write(
'(eventType: string): RemoteElementEventListenerDefinition => (',
);
writer.block(() => {
writer.writeLine(
'dispatchEvent(this: Element, eventData: SerializedEventData) {',
);
writer.indent(() => {
writer.writeLine('applySerializedEventTargetProperties(');
writer.indent(() => {
writer.writeLine('this as unknown as Record<string, unknown>,');
writer.writeLine('eventData,');
});
writer.writeLine(');');
writer.blankLine();
writer.writeLine('return new CustomEvent(eventType, {');
writer.indent(() => {
writer.writeLine('detail: eventData,');
});
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
});
writer.writeLine('},');
});
writer.write(')');
},
},
],
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: 'HTML_COMMON_EVENTS_CONFIG',
initializer: (writer) => {
writer.writeLine('Object.fromEntries(');
writer.indent(() => {
writer.writeLine(
`${TYPE_NAMES.COMMON_EVENTS_ARRAY}.map((eventType) => [`,
);
writer.indent(() => {
writer.writeLine('eventType,');
writer.writeLine('createSerializedEventConfig(eventType),');
});
writer.writeLine(']),');
});
writer.write(
`) as RemoteElementEventListenersDefinition<${TYPE_NAMES.COMMON_EVENTS}>`,
);
},
},
],
});
};
const generateCommonPropertiesConfig = (
@@ -246,17 +306,19 @@ const generateElementDefinition = (
}
}
if (hasEvents) {
const formattedCustomEvents = customEvents
.map((event) => `'${event}'`)
.join(', ');
writer.write('events: ');
writer.block(() => {
if (hasCommonHtmlEvents) {
writer.writeLine('...HTML_COMMON_EVENTS_CONFIG,');
}
writer.write(
hasCommonHtmlEvents && customEvents.length > 0
? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}, ${formattedCustomEvents}],`
: hasCommonHtmlEvents
? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}],`
: `events: [${formattedCustomEvents}],`,
);
for (const event of customEvents) {
writer.writeLine(
`'${event}': createSerializedEventConfig('${event}'),`,
);
}
});
writer.write(',');
writer.newLine();
}
});
@@ -332,12 +394,17 @@ export const generateRemoteElements = (
INTERNAL_ELEMENT_CLASSES.ROOT,
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
{ name: 'RemoteEvent', isTypeOnly: true },
{ name: 'RemoteElementEventListenerDefinition', isTypeOnly: true },
{ name: 'RemoteElementEventListenersDefinition', isTypeOnly: true },
],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/constants/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
namedImports: [
'applySerializedEventTargetProperties',
{ name: 'SerializedEventData', isTypeOnly: true },
],
});
const commonPropertyNames = new Set(Object.keys(commonProperties));
@@ -1,6 +1,6 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import bundleSizes from './example-sources-built/bundle-sizes.json';
import bundleSizes from '@/__stories__/example-sources-built/bundle-sizes.json';
type BundleSizeEntry = {
name: string;
@@ -1,517 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
const createHostApiMocks = () => ({
navigate: fn().mockResolvedValue(undefined),
enqueueSnackbar: fn().mockResolvedValue(undefined),
openSidePanelPage: fn().mockResolvedValue(undefined),
closeSidePanel: fn().mockResolvedValue(undefined),
unmountFrontComponent: fn().mockResolvedValue(undefined),
updateProgress: fn().mockResolvedValue(undefined),
requestAccessTokenRefresh: fn().mockResolvedValue('refreshed-token'),
openCommandConfirmationModal: fn().mockResolvedValue(undefined),
});
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/EventForwarding',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
selectedRecordIds: [],
},
colorScheme: 'light',
frontComponentHostCommunicationApi: createHostApiMocks(),
},
beforeEach: () => {
errorHandler.mockClear();
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const MOUNT_TIMEOUT = 30000;
const INTERACTION_TIMEOUT = 5000;
const HOST_API_TIMEOUT = 10000;
const createComponentStory = (
name: string,
options?: { play?: Story['play'] },
): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
),
},
...(options?.play ? { play: options.play } : {}),
});
const createHostApiStory = (play: Story['play']): Story => ({
...createComponentStory('host-api-calls'),
args: {
...createComponentStory('host-api-calls').args,
frontComponentHostCommunicationApi: createHostApiMocks(),
},
play,
});
export const FormTextInput: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.type(textInput, 'hello');
expect(
await canvas.findByText('hello', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormCheckbox: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const checkbox = await canvas.findByTestId('checkbox-input');
await userEvent.click(checkbox);
expect(
await canvas.findByText('true', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormFocusAndBlur: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.click(textInput);
expect(
await canvas.findByText('focused', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
await userEvent.click(await canvas.findByTestId('form-events-component'));
expect(
await canvas.findByText('blurred', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormSubmission: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.type(textInput, 'hello');
const checkbox = await canvas.findByTestId('checkbox-input');
await userEvent.click(checkbox);
const submitButton = await canvas.findByTestId('submit-button');
await userEvent.click(submitButton);
expect(
await canvas.findByText(
'{"text":"hello","checkbox":true}',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
export const KeyboardBasicInput: Story = createComponentStory(
'keyboard-events',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'keyboard-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = await canvas.findByTestId('keyboard-input');
await userEvent.click(input);
await userEvent.keyboard('a');
expect(
await canvas.findByText('a', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
expect(
await canvas.findByText('KeyA', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
expect(
await canvas.findByText(
/^[1-9]\d*$/,
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
},
);
export const KeyboardModifiers: Story = createComponentStory(
'keyboard-events',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'keyboard-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = await canvas.findByTestId('keyboard-input');
await userEvent.click(input);
await userEvent.keyboard('{Shift>}b{/Shift}');
expect(
await canvas.findByText('shift', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
},
);
export const HostApiNavigate: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const navigateBtn = await canvas.findByTestId('btn-navigate');
await userEvent.click(navigateBtn);
await waitFor(
() => {
expect(api.navigate).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'navigate:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
export const HostApiSnackbar: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const snackbarBtn = await canvas.findByTestId('btn-snackbar');
await userEvent.click(snackbarBtn);
await waitFor(
() => {
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
message: 'Test notification',
variant: 'success',
});
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'snackbar:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
export const HostApiProgress: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const progressBtn = await canvas.findByTestId('btn-progress');
await userEvent.click(progressBtn);
await waitFor(
() => {
expect(api.updateProgress).toHaveBeenCalledWith(50);
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'progress:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
const TYPING_TIMEOUT = 10000;
const expectCaretAt = async (
element: HTMLInputElement | HTMLTextAreaElement,
position: number,
): Promise<void> => {
await waitFor(
() => {
expect(element.selectionStart).toBe(position);
expect(element.selectionEnd).toBe(position);
},
{ timeout: TYPING_TIMEOUT },
);
};
export const InputCaretPreservedMidString: Story = createComponentStory(
'caret-preservation',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'caret-preservation-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = (await canvas.findByTestId(
'caret-text-input',
)) as HTMLInputElement;
await waitFor(
() => {
expect(input.value).toBe('Hello world');
},
{ timeout: INTERACTION_TIMEOUT },
);
input.focus();
input.setSelectionRange(4, 4);
await userEvent.keyboard('X');
await waitFor(
() => {
expect(input.value).toBe('HellXo world');
expect(canvas.getByTestId('caret-text-value').textContent).toBe(
'HellXo world',
);
},
{ timeout: TYPING_TIMEOUT },
);
await expectCaretAt(input, 5);
},
},
);
export const TextareaCaretPreservedMidString: Story = createComponentStory(
'caret-preservation',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'caret-preservation-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textarea = (await canvas.findByTestId(
'caret-textarea-input',
)) as HTMLTextAreaElement;
await waitFor(
() => {
expect(textarea.value).toBe('Hello world');
},
{ timeout: INTERACTION_TIMEOUT },
);
textarea.focus();
textarea.setSelectionRange(4, 4);
await userEvent.keyboard('X');
await waitFor(
() => {
expect(textarea.value).toBe('HellXo world');
expect(canvas.getByTestId('caret-textarea-value').textContent).toBe(
'HellXo world',
);
},
{ timeout: TYPING_TIMEOUT },
);
await expectCaretAt(textarea, 5);
},
},
);
export const FileInputSingle: Story = createComponentStory('file-input', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'file-input-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = (await canvas.findByTestId(
'single-file-input',
)) as HTMLInputElement;
const file = new File(['hello world'], 'hello.txt', {
type: 'text/plain',
lastModified: 1700000000000,
});
await userEvent.upload(input, file);
await waitFor(
() => {
expect(canvas.getByTestId('single-file-count').textContent).toBe('1');
expect(canvas.getByTestId('single-file-name').textContent).toContain(
'hello.txt',
);
expect(canvas.getByTestId('single-file-name').textContent).toContain(
'text/plain',
);
},
{ timeout: INTERACTION_TIMEOUT },
);
},
});
export const FileInputMultiple: Story = createComponentStory('file-input', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'file-input-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = (await canvas.findByTestId(
'multi-file-input',
)) as HTMLInputElement;
const first = new File(['a'], 'one.png', { type: 'image/png' });
const second = new File(['bb'], 'two.png', { type: 'image/png' });
await userEvent.upload(input, [first, second]);
await waitFor(
() => {
expect(canvas.getByTestId('multi-file-count').textContent).toBe('2');
const list = canvas.getByTestId('multi-file-list');
expect(list.textContent).toContain('one.png');
expect(list.textContent).toContain('two.png');
},
{ timeout: INTERACTION_TIMEOUT },
);
},
});
export const HostApiClosePanel: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const closePanelBtn = await canvas.findByTestId('btn-close-panel');
await userEvent.click(closePanelBtn);
await waitFor(
() => {
expect(api.closeSidePanel).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'closePanel:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
@@ -1,9 +1,8 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
@@ -1,9 +1,8 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
@@ -1,98 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { type ChangeEvent, useState } from 'react';
const CARD_STYLE = {
padding: 24,
backgroundColor: '#eff6ff',
border: '2px solid #3b82f6',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
maxWidth: 400,
};
const HEADING_STYLE = {
color: '#1e3a8a',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const LABEL_STYLE = {
fontSize: 13,
fontWeight: 600,
color: '#374151',
};
const HINT_STYLE = {
fontSize: 13,
color: '#6b7280',
fontFamily: 'monospace',
};
const INPUT_STYLE = {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: 6,
fontSize: 14,
fontFamily: 'monospace',
};
const INITIAL_VALUE = 'Hello world';
const CaretPreservationComponent = () => {
const [text, setText] = useState(INITIAL_VALUE);
const [textareaText, setTextareaText] = useState(INITIAL_VALUE);
return (
<div data-testid="caret-preservation-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>Caret Preservation</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Text input (pre-filled)</label>
<input
data-testid="caret-text-input"
type="text"
value={text}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (event as unknown as { detail: { value?: string } })
.detail;
setText(detail?.value ?? '');
}}
style={INPUT_STYLE}
/>
<span data-testid="caret-text-value" style={HINT_STYLE}>
{text}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Textarea (pre-filled)</label>
<textarea
data-testid="caret-textarea-input"
value={textareaText}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
const detail = (event as unknown as { detail: { value?: string } })
.detail;
setTextareaText(detail?.value ?? '');
}}
style={INPUT_STYLE}
rows={3}
/>
<span data-testid="caret-textarea-value" style={HINT_STYLE}>
{textareaText}
</span>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-caret-00000000-0000-0000-0000-000000000021',
name: 'caret-preservation-component',
description:
'Component verifying caret position is preserved during mid-string editing of <input>/<textarea>',
component: CaretPreservationComponent,
});
@@ -1,120 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { type ChangeEvent, useState } from 'react';
type SelectedFile = {
name: string;
size: number;
type: string;
lastModified: number;
};
const CARD_STYLE = {
padding: 24,
backgroundColor: '#fef3c7',
border: '2px solid #f59e0b',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
maxWidth: 480,
};
const HEADING_STYLE = {
color: '#92400e',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const LABEL_STYLE = {
fontSize: 13,
fontWeight: 600,
color: '#374151',
};
const HINT_STYLE = {
fontSize: 13,
color: '#6b7280',
fontFamily: 'monospace',
};
const LIST_STYLE = {
margin: 0,
paddingLeft: 16,
fontSize: 13,
fontFamily: 'monospace',
color: '#374151',
display: 'flex',
flexDirection: 'column' as const,
gap: 4,
};
const FileInputComponent = () => {
const [singleFile, setSingleFile] = useState<SelectedFile | null>(null);
const [multiFiles, setMultiFiles] = useState<SelectedFile[]>([]);
return (
<div data-testid="file-input-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>File Input</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Single file</label>
<input
data-testid="single-file-input"
type="file"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (
event as unknown as { detail: { files?: SelectedFile[] } }
).detail;
setSingleFile(detail?.files?.[0] ?? null);
}}
/>
<span data-testid="single-file-count" style={HINT_STYLE}>
{singleFile === null ? 'none' : '1'}
</span>
{singleFile !== null && (
<span data-testid="single-file-name" style={HINT_STYLE}>
{singleFile.name} ({singleFile.size}B, {singleFile.type})
</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Multiple files (image/*)</label>
<input
data-testid="multi-file-input"
type="file"
multiple
accept="image/*"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (
event as unknown as { detail: { files?: SelectedFile[] } }
).detail;
setMultiFiles(detail?.files ?? []);
}}
/>
<span data-testid="multi-file-count" style={HINT_STYLE}>
{multiFiles.length}
</span>
{multiFiles.length > 0 && (
<ul data-testid="multi-file-list" style={LIST_STYLE}>
{multiFiles.map((file) => (
<li key={`${file.name}-${file.lastModified}`}>
{file.name} ({file.size}B)
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-file-00000000-0000-0000-0000-000000000022',
name: 'file-input-component',
description:
'Component verifying file input metadata is forwarded from host to worker',
component: FileInputComponent,
});
@@ -1,141 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { type ChangeEvent, useState } from 'react';
const CARD_STYLE = {
padding: 24,
backgroundColor: '#f0fdf4',
border: '2px solid #22c55e',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
maxWidth: 400,
};
const HEADING_STYLE = {
color: '#166534',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const LABEL_STYLE = {
fontSize: 13,
fontWeight: 600,
color: '#374151',
};
const HINT_STYLE = {
fontSize: 13,
color: '#6b7280',
};
const INPUT_STYLE = {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: 6,
fontSize: 14,
};
const SUBMIT_BUTTON_STYLE = {
padding: '10px 20px',
backgroundColor: '#16a34a',
color: 'white',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
};
const FormEventsComponent = () => {
const [textValue, setTextValue] = useState('');
const [checkboxValue, setCheckboxValue] = useState(false);
const [focusState, setFocusState] = useState('none');
const [submittedData, setSubmittedData] = useState<string | null>(null);
return (
<div data-testid="form-events-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>Form Events</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Text Input</label>
<input
data-testid="text-input"
type="text"
placeholder="Type here..."
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (event as unknown as { detail: { value?: string } })
.detail;
setTextValue(detail?.value ?? '');
}}
onFocus={() => setFocusState('focused')}
onBlur={() => setFocusState('blurred')}
style={INPUT_STYLE}
/>
<span data-testid="text-value" style={HINT_STYLE}>
{textValue}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
data-testid="checkbox-input"
type="checkbox"
checked={checkboxValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (
event as unknown as { detail: { checked?: boolean } }
).detail;
setCheckboxValue(detail?.checked ?? false);
}}
/>
<label style={LABEL_STYLE}>Check me</label>
<span data-testid="checkbox-value" style={HINT_STYLE}>
{String(checkboxValue)}
</span>
</div>
<span data-testid="focus-state" style={HINT_STYLE}>
{focusState}
</span>
<button
data-testid="submit-button"
type="button"
onClick={() =>
setSubmittedData(
JSON.stringify({ text: textValue, checkbox: checkboxValue }),
)
}
style={SUBMIT_BUTTON_STYLE}
>
Submit
</button>
{submittedData !== null && (
<pre
data-testid="submitted-data"
style={{
fontSize: 13,
background: '#dcfce7',
padding: 12,
borderRadius: 8,
margin: 0,
overflow: 'auto',
}}
>
{submittedData}
</pre>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-form-00000000-0000-0000-0000-000000000020',
name: 'form-events-component',
description:
'Component testing form input events (onChange, onFocus, onBlur, submit)',
component: FormEventsComponent,
});
@@ -1,155 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppPath,
closeSidePanel,
enqueueSnackbar,
navigate,
openSidePanelPage,
SidePanelPages,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { useState } from 'react';
const CARD_STYLE = {
padding: 24,
backgroundColor: '#faf5ff',
border: '2px solid #a78bfa',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 10,
maxWidth: 400,
};
const HEADING_STYLE = {
color: '#5b21b6',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const BUTTON_STYLE = {
padding: '8px 16px',
backgroundColor: '#7c3aed',
color: 'white',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
fontSize: 13,
};
const STATUS_STYLE = {
fontSize: 13,
color: '#6b7280',
fontFamily: 'monospace',
};
const HostApiCallsComponent = () => {
const [apiStatus, setApiStatus] = useState('idle');
const callApi = async (name: string, apiFunction: () => Promise<void>) => {
try {
await apiFunction();
setApiStatus(`${name}:success`);
} catch (error) {
setApiStatus(
`${name}:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<div data-testid="host-api-calls-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>Host API Calls</h2>
<button
data-testid="btn-navigate"
type="button"
onClick={() =>
callApi('navigate', () =>
navigate(AppPath.RecordIndexPage, {
objectNamePlural: 'companies',
}),
)
}
style={BUTTON_STYLE}
>
Navigate
</button>
<button
data-testid="btn-snackbar"
type="button"
onClick={() =>
callApi('snackbar', () =>
enqueueSnackbar({
message: 'Test notification',
variant: 'success',
}),
)
}
style={BUTTON_STYLE}
>
Snackbar
</button>
<button
data-testid="btn-side-panel"
type="button"
onClick={() =>
callApi('sidePanel', () =>
openSidePanelPage({
page: SidePanelPages.ViewRecord,
pageTitle: 'Test Record',
}),
)
}
style={BUTTON_STYLE}
>
Open Side Panel
</button>
<button
data-testid="btn-close-panel"
type="button"
onClick={() => callApi('closePanel', () => closeSidePanel())}
style={BUTTON_STYLE}
>
Close Side Panel
</button>
<button
data-testid="btn-unmount"
type="button"
onClick={() => callApi('unmount', () => unmountFrontComponent())}
style={BUTTON_STYLE}
>
Unmount
</button>
<button
data-testid="btn-progress"
type="button"
onClick={() => callApi('progress', () => updateProgress(50))}
style={BUTTON_STYLE}
>
Update Progress (50)
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{apiStatus}
</span>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-hapi-00000000-0000-0000-0000-000000000022',
name: 'host-api-calls-component',
description:
'Component testing host communication API calls (navigate, snackbar, side panel, etc.)',
component: HostApiCallsComponent,
});
@@ -1,125 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { type KeyboardEvent, useState } from 'react';
type RemoteKeyboardEventDetail = {
key?: string;
code?: string;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
};
const KeyboardEventsComponent = () => {
const [lastKey, setLastKey] = useState('');
const [lastCode, setLastCode] = useState('');
const [modifiers, setModifiers] = useState('');
const [keyCount, setKeyCount] = useState(0);
// remote-dom serializes keyboard events into CustomEvent.detail
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
const data = (event as unknown as { detail: RemoteKeyboardEventDetail })
.detail;
setLastKey(data.key ?? '');
setLastCode(data.code ?? '');
setKeyCount((previousCount) => previousCount + 1);
const activeModifiers: string[] = [];
if (data.shiftKey) activeModifiers.push('shift');
if (data.ctrlKey) activeModifiers.push('ctrl');
if (data.metaKey) activeModifiers.push('meta');
if (data.altKey) activeModifiers.push('alt');
setModifiers(activeModifiers.join(','));
};
return (
<div
data-testid="keyboard-events-component"
style={{
padding: 24,
backgroundColor: '#fefce8',
border: '2px solid #eab308',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column',
gap: 12,
maxWidth: 400,
}}
>
<h2
style={{
color: '#854d0e',
fontWeight: 700,
fontSize: 18,
margin: 0,
}}
>
Keyboard Events
</h2>
<input
data-testid="keyboard-input"
type="text"
placeholder="Press keys here..."
onKeyDown={handleKeyDown}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: 6,
fontSize: 14,
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 16 }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Key:{' '}
<span
data-testid="last-key"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{lastKey}
</span>
</span>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Code:{' '}
<span
data-testid="last-code"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{lastCode}
</span>
</span>
</div>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Modifiers:{' '}
<span
data-testid="modifiers"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{modifiers}
</span>
</span>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Key count:{' '}
<span
data-testid="key-count"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{keyCount}
</span>
</span>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-kbd0-00000000-0000-0000-0000-000000000021',
name: 'keyboard-events-component',
description:
'Component testing keyboard event serialization (key, code, modifiers)',
component: KeyboardEventsComponent,
});
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/ClosePanel',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const ClosePanel: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-side-panel-close',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.closeSidePanel).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'closePanel:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/CopyToClipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CopyToClipboard: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-copy-to-clipboard',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.copyToClipboard).toHaveBeenCalledWith('Hello clipboard');
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'clipboard:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { copyToClipboard } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiCopyToClipboardFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await copyToClipboard('Hello clipboard');
setStatus('clipboard:success');
} catch (error) {
setStatus(
`clipboard:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:copy-to-clipboard">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Copy
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-clipboard-00000000-0000-0000-0000-000000000020',
name: 'host-api-copy-to-clipboard-front-component',
description: 'Front component covering copyToClipboard host API',
component: HostApiCopyToClipboardFrontComponent,
});
@@ -0,0 +1,52 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { AppPath, navigate } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiNavigateFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await navigate(AppPath.RecordIndexPage, {
objectNamePlural: 'companies',
});
setStatus('navigate:success');
} catch (error) {
setStatus(
`navigate:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:navigate">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Navigate
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-navigate-00000000-0000-0000-0000-000000000020',
name: 'host-api-navigate-front-component',
description: 'Front component covering navigate host API',
component: HostApiNavigateFrontComponent,
});
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { updateProgress } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiProgressFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await updateProgress(50);
setStatus('progress:success');
} catch (error) {
setStatus(
`progress:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:progress">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Update Progress
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-progress-00000000-0000-0000-0000-000000000020',
name: 'host-api-progress-front-component',
description: 'Front component covering updateProgress host API',
component: HostApiProgressFrontComponent,
});
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { closeSidePanel } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiSidePanelCloseFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await closeSidePanel();
setStatus('closePanel:success');
} catch (error) {
setStatus(
`closePanel:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:side-panel:close">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Close Side Panel
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-spc-00000000-0000-0000-0000-000000000020',
name: 'host-api-side-panel-close-front-component',
description: 'Front component covering closeSidePanel host API',
component: HostApiSidePanelCloseFrontComponent,
});
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { openSidePanelPage, SidePanelPages } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiSidePanelOpenFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await openSidePanelPage({
page: SidePanelPages.ViewRecord,
pageTitle: 'Test Record',
});
setStatus('sidePanel:success');
} catch (error) {
setStatus(
`sidePanel:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:side-panel:open">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Open Side Panel
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-spo-00000000-0000-0000-0000-000000000020',
name: 'host-api-side-panel-open-front-component',
description: 'Front component covering openSidePanelPage host API',
component: HostApiSidePanelOpenFrontComponent,
});
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiSnackbarFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await enqueueSnackbar({
message: 'Test notification',
variant: 'success',
});
setStatus('snackbar:success');
} catch (error) {
setStatus(
`snackbar:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:snackbar">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Snackbar
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-snackbar-00000000-0000-0000-0000-000000000020',
name: 'host-api-snackbar-front-component',
description: 'Front component covering enqueueSnackbar host API',
component: HostApiSnackbarFrontComponent,
});
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { unmountFrontComponent } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiUnmountFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await unmountFrontComponent();
setStatus('unmount:success');
} catch (error) {
setStatus(
`unmount:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:unmount">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Unmount
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-unmount-00000000-0000-0000-0000-000000000020',
name: 'host-api-unmount-front-component',
description: 'Front component covering unmountFrontComponent host API',
component: HostApiUnmountFrontComponent,
});
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Navigate',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Navigate: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-navigate',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.navigate).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'navigate:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Progress',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Progress: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-progress',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.updateProgress).toHaveBeenCalledWith(50);
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'progress:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,63 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Snackbar',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Snackbar: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-snackbar',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
message: 'Test notification',
variant: 'success',
});
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'snackbar:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,37 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="iframe:click">
<iframe
data-testid="subject"
title="probe"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-c-click-00000000-0000-0000-0000-000000000020',
name: 'iframe-click-front-component',
description: 'Front component covering click on <iframe>',
component: IframeClickFrontComponent,
});
@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Iframe/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'iframe-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'iframe-focus-blur',
});
@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="iframe:focus-blur">
<iframe
data-testid="subject"
title="probe"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'iframe-focus-blur-front-component',
description: 'Front component covering focus-blur on <iframe>',
component: IframeFocusBlurFrontComponent,
});
@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const ImgClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="img:click">
<img
data-testid="subject"
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 40 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-img-c-click-00000000-0000-0000-0000-000000000020',
name: 'img-click-front-component',
description: 'Front component covering click on <img>',
component: ImgClickFrontComponent,
});
@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Img/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'img-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'img-focus-blur',
});
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const ImgFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="img:focus-blur">
<img
data-testid="subject"
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 40 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-img-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'img-focus-blur-front-component',
description: 'Front component covering focus-blur on <img>',
component: ImgFocusBlurFrontComponent,
});
@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const ImgPropertiesFrontComponent = () => (
<FrontComponentCard title="img:properties">
<img
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
src={PROPERTY_FIXTURE.src}
alt={PROPERTY_FIXTURE.alt}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-img-props-00000000-0000-0000-0000-000000000020',
name: 'img-properties-front-component',
description: 'Front component covering property reflection on <img>',
component: ImgPropertiesFrontComponent,
});
@@ -0,0 +1,26 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Img/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'img-properties',
extraAttributes: {
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40"/>',
alt: 'subject-alt',
},
});
@@ -0,0 +1,42 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const PictureClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="picture:click">
<picture
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_RECT_STYLE}
>
<img
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
/>
</picture>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-picture-c-click-00000000-0000-0000-0000-000000000020',
name: 'picture-click-front-component',
description: 'Front component covering click on <picture>',
component: PictureClickFrontComponent,
});
@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Picture/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'picture-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'picture-focus-blur',
});
@@ -0,0 +1,42 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const PictureFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="picture:focus-blur">
<picture
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_RECT_STYLE}
>
<img
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
/>
</picture>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-picture-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'picture-focus-blur-front-component',
description: 'Front component covering focus-blur on <picture>',
component: PictureFocusBlurFrontComponent,
});
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const ButtonClickFrontComponent = () => {
const [clickCount, setClickCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="button:click">
<button
data-testid="subject"
type="button"
onClick={(event) => {
setClickCount((previous) => previous + 1);
pushEvent(event);
}}
style={BUTTON_STYLE}
>
Click me
</button>
<span data-testid="front-component-value">{clickCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-btn-click-00000000-0000-0000-0000-000000000020',
name: 'button-click-front-component',
description: 'Front component covering click event on <button>',
component: ButtonClickFrontComponent,
});
@@ -0,0 +1,43 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Button/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const ClickEvent: Story = runFrontComponentStory({
frontComponentBundleName: 'button-click',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: '1' });
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: '2' });
await expectEventLogged({ canvas, matcher: { type: 'click' } });
},
});
@@ -0,0 +1,28 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const ButtonPropertiesFrontComponent = () => (
<FrontComponentCard title="button:properties">
<button
data-testid="subject"
type="button"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
button
</button>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-btn-props-00000000-0000-0000-0000-000000000020',
name: 'button-properties-front-component',
description: 'Front component covering property reflection on <button>',
component: ButtonPropertiesFrontComponent,
});
@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Button/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'button-properties',
});
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
const DatalistClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="datalist:click">
<>
<input list="probe-list" />
<datalist
id="probe-list"
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
>
<option value="alpha" />
<option value="beta" />
</datalist>
</>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-datalist-c-click-00000000-0000-0000-0000-000000000020',
name: 'datalist-click-front-component',
description: 'Front component covering click on <datalist>',
component: DatalistClickFrontComponent,
});
@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createHtmlTagClickStory } from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Datalist/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'datalist-click',
});
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const FieldsetClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="fieldset:click">
<fieldset
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_RECT_STYLE}
>
fieldset
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-fieldset-c-click-00000000-0000-0000-0000-000000000020',
name: 'fieldset-click-front-component',
description: 'Front component covering click on <fieldset>',
component: FieldsetClickFrontComponent,
});
@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Fieldset/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'fieldset-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'fieldset-focus-blur',
});
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const FieldsetFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="fieldset:focus-blur">
<fieldset
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_RECT_STYLE}
>
fieldset
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-fieldset-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'fieldset-focus-blur-front-component',
description: 'Front component covering focus-blur on <fieldset>',
component: FieldsetFocusBlurFrontComponent,
});
@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const FieldsetPropertiesFrontComponent = () => (
<FrontComponentCard title="fieldset:properties">
<fieldset
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
field
</fieldset>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-fieldset-props-00000000-0000-0000-0000-000000000020',
name: 'fieldset-properties-front-component',
description: 'Front component covering property reflection on <fieldset>',
component: FieldsetPropertiesFrontComponent,
});
@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Fieldset/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'fieldset-properties',
});
@@ -0,0 +1,40 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Form/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const SubmitEvent: Story = runFrontComponentStory({
frontComponentBundleName: 'form-submit',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const submitButton = await canvas.findByTestId('submit-button');
await userEvent.click(submitButton);
await expectFrontComponentValue({ canvas, expected: 'value-from-form' });
await expectEventLogged({ canvas, matcher: { type: 'submit' } });
},
});

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