Compare commits

...
Author SHA1 Message Date
prastoin 6e27b5ec62 remove vite 2026-05-23 12:37:46 +02:00
prastoin 5751cacb4d nit 2026-05-23 12:36:24 +02:00
prastoin 64f9e07d5e enrich blob patterns 2026-05-23 12:32:09 +02:00
prastoin a6aeb10484 chore(server): include package.json and lock file 2026-05-23 12:30:03 +02:00
prastoin 2327c5dd30 chore(server): downscope code owners 2026-05-23 12:29:25 +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 <github-actions@twenty.com>
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 <charles@twenty.com>
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 <github-actions@twenty.com>
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 <cursoragent@cursor.com>
2026-05-22 13:40:53 +00:00
WeikoandGitHub 8f24cda586 Fix permission flag removed flag property from diffing (#20845)
## Why it fixes the bug

RolePermissionFlagEntity.flag (role-permission-flag.entity.ts:54) is
marked @WasRemovedInUpgrade for
2.7.0_FinalizeRolePermissionFlagCutoverFastInstanceCommand.
After 2.7.0, the column is gone from real DBs and the metadata layer no
longer accepts writes to it — but the diffing config still listed flag
with toCompare: true. So when an SDK-generated manifest carried a flag
value, computeUniversalFlatEntityPropertiesToCompareAndStringify
(all-universal-flat-entity-properties-to-compare-and-stringify.constant.ts:55-69)
included it in the comparison, the diff emitted { update: { flag:
"UPLOAD_FILE" } }, and the metadata update failed with Property "flag"
was not found in "RolePermissionFlagEntity".

Switching toCompare: false makes the diff skip flag; the only properties
compared are now permissionFlagUniversalIdentifier and
roleUniversalIdentifier, which is what the post-cutover
entity actually supports.

Fixes https://github.com/twentyhq/twenty/issues/20843
2026-05-22 13:20:27 +00:00
c2a48a1cc2 i18n - docs translations (#20839)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-22 13:22:12 +02:00
37a1d3980f i18n - translations (#20838)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-22 12:26:58 +02:00
nitinandGitHub 05f58b2dba Fix onboarding modals spacings (#20682)
closes
https://discord.com/channels/1130383047699738754/1496415056085389422
2026-05-22 10:06:18 +00:00
Abdullah.andGitHub 4e0b69eb8d [Website] force-static releases page & move workspace bullet to pro self-host (#20834)
Add `export const dynamic = 'force-static'` to the releases page to
prevent runtime re-renders on Cloudflare Workers where fs is
unavailable, which caused stale "Releases were not found" errors after
ISR cache eviction.

Also moves the "Up to 5 workspaces" bullet from the Organization plan to
the Pro plan on the self-hosting pricing view.
2026-05-22 09:58:22 +00:00
99799073e9 i18n - translations (#20837)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-22 12:04:44 +02:00
Félix MalfaitandGitHub 084fa8eaba fix(server): auto-index target<X>Id join columns on polymorphic standard objects (#20820)
Closes #20726

## The bug

`timelineActivity` (and the three other polymorphic standard objects —
`attachment`, `noteTarget`, `taskTarget`) store relations as N nullable
`target<X>Id` columns, one per related object. Each one is a join key
queried as `WHERE target<X>Id IN (...) AND deletedAt IS NULL`.

For **built-in** related objects (Person, Company, Opportunity, …), each
`target<X>Id` column gets a BTREE index, declared statically in
`compute-{timelineActivity,attachment,noteTarget,taskTarget}-standard-flat-index-metadata.util.ts`.

For **custom** related objects, the same `target<CustomObject>Id` column
was added — **without an index**. On a `timelineActivity` table at
issue-reporter scale (~21.9M rows, 7.1 GB), this turned record loads
into 20–40s sequential scans and produced `QueryFailedError: Query read
timeout` for end users.

## Diagnosis

The morph/relation field generator
(`generateMorphOrRelationFlatFieldMetadataPair`) already creates a BTREE
index for the field that owns the join column and returns it alongside
the field metadata pair. The two user-driven entry points
(`fromRelationCreateFieldInput…`, `fromMorphRelationCreateFieldInput…`)
correctly destructure and propagate that index.

But the **custom-object creation path** —
`buildDefaultRelationFlatFieldMetadatasForCustomObject`, called when a
user creates a new custom object — destructured only `{
flatFieldMetadatas }` and threw away `indexMetadatas`. So every
`target<CustomObject>Id` column added to the four polymorphic standard
objects has been shipping unindexed since custom morph relations went
in.

## The fix

Three commits.

### 1. `fix(server): index target<CustomObject>Id columns on standard
polymorphic objects`

13 lines across 2 files.

-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
— also destructure `indexMetadatas` from the pair generator and
accumulate them into the returned record (new field
`standardTargetFlatIndexMetadatas`).
-
`from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts`
— append the accumulated indexes to `flatIndexMetadataToCreate`. The
migration pipeline at `object-metadata.service.ts:559–562` already
passes `flatIndexMetadataToCreate` to the migration runner, so no
further wiring is needed.

From now on, creating a custom object also creates the four BTREE
indexes — one per polymorphic standard object's new
`target<CustomObject>Id` column — atomically with the rest of the
migration.

### 2. `feat(server): backfill workspace command for relation join
column indexes`

For existing workspaces whose custom objects were created before the
forward-fix.

`upgrade:2-8:backfill-relation-join-column-indexes` is a
`@RegisteredWorkspaceCommand('2.8.0', 1798100000000)` matching the
pattern from
`2-7-workspace-command-…-drop-connected-account-standard-object.command.ts`.

Per workspace:
1. Load `flatObjectMetadataMaps`, `flatFieldMetadataMaps`,
`flatIndexMaps` from the workspace cache.
2. Resolve the four polymorphic standard object IDs by `nameSingular`
against `DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS`.
3. Collect every field ID that's already covered by any existing index.
4. Filter `flatFieldMetadataMaps` to MORPH_RELATION fields on those four
objects whose `settings.relationType === MANY_TO_ONE` (i.e. owns a join
column) and whose ID isn't in the indexed set.
5. Generate a BTREE `UniversalFlatIndexMetadata` for each via
`generateIndexForFlatFieldMetadata` (same helper the forward-fix uses).
6. Create the indexes in the workspace schema with **CONCURRENTLY** (see
commit 3).
7. Submit the metadata through
`WorkspaceMigrationValidateBuildAndRunService` so it lands in
`indexMetadata` and the cache — same pipeline as a normal metadata
change. The pipeline's own `CREATE INDEX IF NOT EXISTS` no-ops because
the index already exists.

Properties:
- **Idempotent.** Re-running is a no-op once indexes exist.
- **Scoped.** Only the four polymorphic standard objects, only their
MANY_TO_ONE morph relation fields, only those with no covering index.
- **Same code path as the forward-fix.** The backfill produces exactly
the indexes the forward-fix would have created at custom-object creation
time.
- **`--dry-run` supported** via the base
`ActiveOrSuspendedWorkspaceCommandRunner`.

### 3. `feat(server): create index CONCURRENTLY in relation join column
backfill`

Adds an opt-in `concurrently` flag to
`WorkspaceSchemaIndexManagerService.createIndex` (threaded through
`createIndexInWorkspaceSchema`). When `true`, emits `CREATE INDEX
CONCURRENTLY IF NOT EXISTS …`. Defaults to `false` — every existing
caller keeps the current transactional `CREATE INDEX` behavior.

The backfill command opts in. It creates a QueryRunner **without**
`startTransaction()`, issues the CONCURRENTLY indexes one-by-one (each
waits for the previous to finish), then submits the metadata through the
normal migration pipeline whose own `CREATE INDEX IF NOT EXISTS` is now
a no-op.

Why not flip the default for the helper:
- `CREATE INDEX CONCURRENTLY` cannot run inside a transaction — Postgres
errors out. The migration pipeline calls `createIndex` from inside a
transactional schema migration.
- CONCURRENTLY doesn't roll back with the transaction. If the
surrounding migration fails, the index remains and you end up with
metadata/schema drift.
- Failed CONCURRENTLY builds leave an INVALID index behind that needs
manual `DROP`.
- UNIQUE indexes have different failure semantics under CONCURRENTLY
(deferred, not immediate).

So CONCURRENTLY is opt-in, used only where it's the right tool (post-hoc
backfills on populated tables).

## Decisions / tradeoffs

- **Single-column BTREE vs partial `WHERE deletedAt IS NULL` vs
composite.** Twenty's queries always include `deletedAt IS NULL`. A
partial index would be slightly better than a plain BTREE (smaller, no
wasted seeks on soft-deleted rows). This PR ships single-column to match
the existing built-in target index pattern, which already covers >95% of
the available speedup (the 20s→4ms drop the reporter saw comes from
having any index — composite/partial is a second-order effect).
Switching all relation indexes to partial is a separate, broader change.
- **CONCURRENTLY operator caveat.** If a CONCURRENTLY build is
interrupted (kill, connection drop, OOM), Postgres leaves the index as
INVALID. We deliberately don't probe `pg_index` for invalid leftovers on
every create — catalog-table queries can be slow at multi-tenant scale
and the failure mode is rare. Recovery is manual: `DROP INDEX <name>`
and re-run the backfill.
- **Forward-fix is not gated** behind a feature flag. The change is
metadata-pipeline-internal; before, custom-object creation silently
produced a degraded state. After, it produces the correct state. No new
public API, no behavioural change for end users besides the indexes
existing.

## Risk

- Forward-fix: changes only the metadata produced during custom-object
creation. New objects get four extra `FlatIndexMetadata` rows and four
extra `CREATE INDEX` statements during their creation migration. Tables
are empty at that point so the index builds in microseconds.
- Helper change: API-compatible, default behavior unchanged. The new
`concurrently` parameter is optional.
- Backfill: read-only state probe → CONCURRENTLY index creation (no
write blocking) → metadata insert via the normal migration pipeline.
Idempotent. Reverting is `DROP INDEX`.

## Test plan

- [ ] Verify forward-fix: create a custom object, confirm four new BTREE
indexes appear on `timelineActivity`, `attachment`, `noteTarget`,
`taskTarget` for the new `target<CustomObject>Id` columns, and that
`flatIndexMaps` has matching entries.
- [ ] Verify backfill on a workspace that had custom objects created
before the fix: run `--dry-run` first, confirm the expected indexes are
listed; then run for real, confirm the indexes appear in pg (and as
`indisvalid = true` in `pg_index`) and in `flatIndexMaps`. Re-run;
confirm no-op.
- [ ] Verify backfill on a clean workspace: should log "no missing
indexes" and exit.
- [ ] Verify CONCURRENTLY behavior under load: run backfill against a
workspace with active writes on `timelineActivity`; confirm
inserts/updates keep working during index build (no `ShareLock` waits in
`pg_stat_activity`).
- [ ] On the affected reporter-scale workspace, confirm `EXPLAIN
ANALYZE` switches from sequential scan to index scan and timeline
activity timeouts go away.
2026-05-22 11:56:33 +02:00
788d120b71 i18n - docs translations (#20833)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-22 11:30:11 +02:00
3128157988 i18n - translations (#20832)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-22 11:19:10 +02:00
nitinandGitHub 068d365731 feat(sdk): error on incompatible view filter operand at sync time (#20763)
view filters with mismatched operand + field type now error at sync --
was silently failing before
2026-05-22 09:01:40 +00:00
9b05e7fff4 chore: sync AI model catalog from models.dev (#20830)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-05-22 09:06:07 +02:00
774 changed files with 22831 additions and 24546 deletions
+38 -6
View File
@@ -1,6 +1,38 @@
/.github/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/CODEOWNERS @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/workflows/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/actions/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/dependabot.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.yarnrc.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
# ============================================================
# CI/CD & GitHub infrastructure
# ============================================================
/.github/ @Weiko @charlesBochet @prastoin
/.github/CODEOWNERS @Weiko @charlesBochet @prastoin
/.github/workflows/ @Weiko @charlesBochet @prastoin
/.github/actions/ @Weiko @charlesBochet @prastoin
/.github/dependabot.yml @Weiko @charlesBochet @prastoin
# ============================================================
# Package management & dependency lockfiles
# ============================================================
/.yarnrc.yml @Weiko @charlesBochet @prastoin
/.npmrc @Weiko @charlesBochet @prastoin
**/package.json @Weiko @charlesBochet @prastoin
**/yarn.lock @Weiko @charlesBochet @prastoin
# ============================================================
# twenty-apps: exempt from strict ownership (last match wins)
# ============================================================
/packages/twenty-apps/
# ============================================================
# Build & tooling configs
# ============================================================
/nx.json @Weiko @charlesBochet @prastoin
# ============================================================
# Docker (container-level compromise)
# ============================================================
**/Dockerfile* @Weiko @charlesBochet @prastoin
**/docker-compose*.yml @Weiko @charlesBochet @prastoin
**/docker-compose*.yaml @Weiko @charlesBochet @prastoin
# ============================================================
# Git hooks (execute on checkout/commit/push)
# ============================================================
/.husky/** @Weiko @charlesBochet @prastoin
+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
@@ -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],
});
@@ -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],
});
@@ -2476,6 +2476,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 +2914,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 +2982,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 +3001,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!
@@ -3222,6 +3222,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 +3241,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!
@@ -3392,7 +3392,7 @@ input UpdateViewFilterGroupInput {
input CreateViewFilterInput {
id: UUID
fieldMetadataId: UUID!
operand: ViewFilterOperand = CONTAINS
operand: ViewFilterOperand
value: JSON!
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
@@ -3500,7 +3500,7 @@ input UpsertViewWidgetViewFieldInput {
input UpsertViewWidgetViewFilterInput {
id: UUID
fieldMetadataId: UUID!
operand: ViewFilterOperand = CONTAINS
operand: ViewFilterOperand
value: JSON!
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
@@ -4047,6 +4047,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 +4207,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!
@@ -2160,6 +2160,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 +2541,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 +2591,8 @@ export interface Query {
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
webhooks: Webhook[]
webhook?: Webhook
field: Field
fields: FieldConnection
getViewGroups: ViewGroup[]
@@ -2599,8 +2601,6 @@ export interface Query {
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
@@ -2755,6 +2755,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 +2774,6 @@ export interface Mutation {
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
@@ -5166,6 +5166,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 +5555,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 +5617,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 +5633,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']} })
@@ -5808,6 +5808,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 +5827,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']} }
@@ -6154,6 +6154,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 +6216,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 +7937,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 +8209,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"')
@@ -60,20 +60,20 @@ export default {
226,
262,
263,
292,
301,
293,
302,
303,
304,
306,
305,
307,
308,
309,
310,
311,
312,
315,
317,
313,
316,
318,
327,
334,
341,
@@ -4912,6 +4912,38 @@ export default {
1
]
},
"Webhook": {
"id": [
3
],
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"applicationId": [
3
],
"createdAt": [
4
],
"updatedAt": [
4
],
"deletedAt": [
4
],
"__typename": [
1
]
},
"ToolIndexEntry": {
"name": [
1
@@ -5051,7 +5083,7 @@ export default {
1
],
"series": [
277
278
],
"xAxisLabel": [
1
@@ -5100,7 +5132,7 @@ export default {
1
],
"data": [
279
280
],
"__typename": [
1
@@ -5108,7 +5140,7 @@ export default {
},
"LineChartData": {
"series": [
280
281
],
"xAxisLabel": [
1
@@ -5145,7 +5177,7 @@ export default {
},
"PieChartData": {
"data": [
282
283
],
"showLegend": [
6
@@ -5239,13 +5271,13 @@ export default {
},
"EventLogQueryResult": {
"records": [
286
287
],
"totalCount": [
21
],
"pageInfo": [
287
288
],
"__typename": [
1
@@ -5309,7 +5341,7 @@ export default {
1
],
"parts": [
275
276
],
"processedAt": [
4
@@ -5323,7 +5355,7 @@ export default {
},
"AgentChatThread": {
"id": [
292
293
],
"title": [
1
@@ -5379,7 +5411,7 @@ export default {
},
"AiSystemPromptPreview": {
"sections": [
293
294
],
"estimatedTokenCount": [
21
@@ -5455,10 +5487,10 @@ export default {
3
],
"evaluations": [
298
299
],
"messages": [
290
291
],
"createdAt": [
4
@@ -5475,19 +5507,19 @@ export default {
1
],
"syncStatus": [
301
],
"syncStage": [
302
],
"visibility": [
"syncStage": [
303
],
"visibility": [
304
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
304
305
],
"isSyncEnabled": [
6
@@ -5523,22 +5555,22 @@ export default {
3
],
"visibility": [
306
307
],
"handle": [
1
],
"type": [
307
308
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
308
309
],
"messageFolderImportPolicy": [
309
310
],
"excludeNonProfessionalEmails": [
6
@@ -5547,7 +5579,7 @@ export default {
6
],
"pendingGroupEmailsAction": [
310
311
],
"isSyncEnabled": [
6
@@ -5556,10 +5588,10 @@ export default {
4
],
"syncStatus": [
311
312
],
"syncStage": [
312
313
],
"syncStageStartedAt": [
4
@@ -5595,7 +5627,7 @@ export default {
"MessageChannelSyncStage": {},
"CreateEmailGroupChannelOutput": {
"messageChannel": [
305
306
],
"forwardingAddress": [
1
@@ -5624,7 +5656,7 @@ export default {
1
],
"pendingSyncAction": [
315
316
],
"messageChannelId": [
3
@@ -5642,7 +5674,7 @@ export default {
"MessageFolderPendingSyncAction": {},
"CollectionHash": {
"collectionName": [
317
318
],
"hash": [
1
@@ -5709,45 +5741,13 @@ export default {
},
"MinimalMetadata": {
"objectMetadataItems": [
318
],
"views": [
319
],
"views": [
320
],
"collectionHashes": [
316
],
"__typename": [
1
]
},
"Webhook": {
"id": [
3
],
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"applicationId": [
3
],
"createdAt": [
4
],
"updatedAt": [
4
],
"deletedAt": [
4
317
],
"__typename": [
1
@@ -6107,7 +6107,7 @@ export default {
29
],
"getToolIndex": [
274
275
],
"getToolInputSchema": [
15,
@@ -6118,6 +6118,18 @@ export default {
]
}
],
"webhooks": [
274
],
"webhook": [
274,
{
"id": [
3,
"UUID!"
]
}
],
"field": [
43,
{
@@ -6158,7 +6170,7 @@ export default {
}
],
"myMessageFolders": [
314,
315,
{
"messageChannelId": [
3
@@ -6166,7 +6178,7 @@ export default {
}
],
"myMessageChannels": [
305,
306,
{
"connectedAccountId": [
3
@@ -6177,33 +6189,21 @@ export default {
269
],
"myCalendarChannels": [
300,
301,
{
"connectedAccountId": [
3
]
}
],
"webhooks": [
"minimalMetadata": [
321
],
"webhook": [
321,
{
"id": [
3,
"UUID!"
]
}
],
"minimalMetadata": [
320
],
"chatThreads": [
291
292
],
"chatThread": [
291,
292,
{
"id": [
3,
@@ -6212,7 +6212,7 @@ export default {
}
],
"chatMessages": [
290,
291,
{
"threadId": [
3,
@@ -6221,7 +6221,7 @@ export default {
}
],
"chatStreamCatchupChunks": [
295,
296,
{
"threadId": [
3,
@@ -6230,13 +6230,13 @@ export default {
}
],
"getAiSystemPromptPreview": [
294
295
],
"skills": [
289
290
],
"skill": [
289,
290,
{
"id": [
3,
@@ -6245,7 +6245,7 @@ export default {
}
],
"agentTurns": [
299,
300,
{
"agentId": [
3,
@@ -6390,7 +6390,7 @@ export default {
219
],
"eventLogs": [
288,
289,
{
"input": [
326,
@@ -6399,7 +6399,7 @@ export default {
}
],
"pieChartData": [
283,
284,
{
"input": [
330,
@@ -6408,7 +6408,7 @@ export default {
}
],
"lineChartData": [
281,
282,
{
"input": [
331,
@@ -6417,7 +6417,7 @@ export default {
}
],
"barChartData": [
278,
279,
{
"input": [
332,
@@ -6506,7 +6506,7 @@ export default {
},
"LogicFunctionIdInput": {
"id": [
292
293
],
"__typename": [
1
@@ -7616,11 +7616,38 @@ export default {
]
}
],
"createWebhook": [
274,
{
"input": [
418,
"CreateWebhookInput!"
]
}
],
"updateWebhook": [
274,
{
"input": [
419,
"UpdateWebhookInput!"
]
}
],
"deleteWebhook": [
274,
{
"id": [
3,
"UUID!"
]
}
],
"createOneField": [
43,
{
"input": [
418,
421,
"CreateOneFieldMetadataInput!"
]
}
@@ -7629,7 +7656,7 @@ export default {
43,
{
"input": [
420,
423,
"UpdateOneFieldMetadataInput!"
]
}
@@ -7638,7 +7665,7 @@ export default {
43,
{
"input": [
422,
425,
"DeleteOneFieldInput!"
]
}
@@ -7647,7 +7674,7 @@ export default {
65,
{
"input": [
423,
426,
"CreateViewGroupInput!"
]
}
@@ -7656,7 +7683,7 @@ export default {
65,
{
"inputs": [
423,
426,
"[CreateViewGroupInput!]!"
]
}
@@ -7665,7 +7692,7 @@ export default {
65,
{
"input": [
424,
427,
"UpdateViewGroupInput!"
]
}
@@ -7674,7 +7701,7 @@ export default {
65,
{
"inputs": [
424,
427,
"[UpdateViewGroupInput!]!"
]
}
@@ -7683,7 +7710,7 @@ export default {
65,
{
"input": [
426,
429,
"DeleteViewGroupInput!"
]
}
@@ -7692,49 +7719,49 @@ export default {
65,
{
"input": [
427,
430,
"DestroyViewGroupInput!"
]
}
],
"updateMessageFolder": [
314,
315,
{
"input": [
428,
431,
"UpdateMessageFolderInput!"
]
}
],
"updateMessageFolders": [
314,
315,
{
"input": [
430,
433,
"UpdateMessageFoldersInput!"
]
}
],
"updateMessageChannel": [
305,
306,
{
"input": [
431,
434,
"UpdateMessageChannelInput!"
]
}
],
"createEmailGroupChannel": [
313,
314,
{
"input": [
433,
436,
"CreateEmailGroupChannelInput!"
]
}
],
"deleteEmailGroupChannel": [
305,
306,
{
"id": [
3,
@@ -7752,46 +7779,19 @@ export default {
}
],
"updateCalendarChannel": [
300,
301,
{
"input": [
434,
437,
"UpdateCalendarChannelInput!"
]
}
],
"createWebhook": [
321,
{
"input": [
436,
"CreateWebhookInput!"
]
}
],
"updateWebhook": [
321,
{
"input": [
437,
"UpdateWebhookInput!"
]
}
],
"deleteWebhook": [
321,
{
"id": [
3,
"UUID!"
]
}
],
"createChatThread": [
291
292
],
"sendChatMessage": [
296,
297,
{
"threadId": [
3,
@@ -7827,7 +7827,7 @@ export default {
}
],
"renameChatThread": [
291,
292,
{
"id": [
3,
@@ -7840,7 +7840,7 @@ export default {
}
],
"archiveChatThread": [
291,
292,
{
"id": [
3,
@@ -7849,7 +7849,7 @@ export default {
}
],
"unarchiveChatThread": [
291,
292,
{
"id": [
3,
@@ -7876,7 +7876,7 @@ export default {
}
],
"createSkill": [
289,
290,
{
"input": [
440,
@@ -7885,7 +7885,7 @@ export default {
}
],
"updateSkill": [
289,
290,
{
"input": [
441,
@@ -7894,7 +7894,7 @@ export default {
}
],
"deleteSkill": [
289,
290,
{
"id": [
3,
@@ -7903,7 +7903,7 @@ export default {
}
],
"activateSkill": [
289,
290,
{
"id": [
3,
@@ -7912,7 +7912,7 @@ export default {
}
],
"deactivateSkill": [
289,
290,
{
"id": [
3,
@@ -7921,7 +7921,7 @@ export default {
}
],
"evaluateAgentTurn": [
298,
299,
{
"turnId": [
3,
@@ -7930,7 +7930,7 @@ export default {
}
],
"runEvaluationInput": [
299,
300,
{
"agentId": [
3,
@@ -8443,7 +8443,7 @@ export default {
}
],
"duplicateDashboard": [
284,
285,
{
"id": [
3,
@@ -8465,7 +8465,7 @@ export default {
}
],
"sendEmail": [
285,
286,
{
"input": [
459,
@@ -8474,7 +8474,7 @@ export default {
}
],
"startChannelSync": [
276,
277,
{
"connectedAccountId": [
3,
@@ -10320,9 +10320,57 @@ export default {
1
]
},
"CreateWebhookInput": {
"id": [
3
],
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"__typename": [
1
]
},
"UpdateWebhookInput": {
"id": [
3
],
"update": [
420
],
"__typename": [
1
]
},
"UpdateWebhookInputUpdates": {
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"__typename": [
1
]
},
"CreateOneFieldMetadataInput": {
"field": [
419
422
],
"__typename": [
1
@@ -10395,7 +10443,7 @@ export default {
3
],
"update": [
421
424
],
"__typename": [
1
@@ -10487,7 +10535,7 @@ export default {
3
],
"update": [
425
428
],
"__typename": [
1
@@ -10531,7 +10579,7 @@ export default {
3
],
"update": [
429
432
],
"__typename": [
1
@@ -10550,7 +10598,7 @@ export default {
3
],
"update": [
429
432
],
"__typename": [
1
@@ -10561,7 +10609,7 @@ export default {
3
],
"update": [
432
435
],
"__typename": [
1
@@ -10569,16 +10617,16 @@ export default {
},
"UpdateMessageChannelInputUpdates": {
"visibility": [
306
307
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
308
309
],
"messageFolderImportPolicy": [
309
310
],
"isSyncEnabled": [
6
@@ -10606,7 +10654,7 @@ export default {
3
],
"update": [
435
438
],
"__typename": [
1
@@ -10614,13 +10662,13 @@ export default {
},
"UpdateCalendarChannelInputUpdates": {
"visibility": [
303
304
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
304
305
],
"isSyncEnabled": [
6
@@ -10629,54 +10677,6 @@ export default {
1
]
},
"CreateWebhookInput": {
"id": [
3
],
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"__typename": [
1
]
},
"UpdateWebhookInput": {
"id": [
3
],
"update": [
438
],
"__typename": [
1
]
},
"UpdateWebhookInputUpdates": {
"targetUrl": [
1
],
"operations": [
1
],
"description": [
1
],
"secret": [
1
],
"__typename": [
1
]
},
"FileAttachmentInput": {
"id": [
3
@@ -10947,7 +10947,7 @@ export default {
454
],
"metadataName": [
317
318
],
"universalIdentifier": [
1
@@ -11138,7 +11138,7 @@ export default {
}
],
"onAgentChatEvent": [
297,
298,
{
"threadId": [
3,
@@ -1,7 +1,7 @@
---
title: Views
description: Ship pre-configured saved views — column order, filters, groups — for objects in your app.
icon: "list"
icon: 'list'
---
A **view** is a saved configuration for how records of an object are displayed: which fields appear, their order, whether they're visible, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app — typically a default index view for each custom object you create.
@@ -38,6 +38,60 @@ export default defineView({
- You can also declare `filters`, `filterGroups`, `groups`, and `fieldGroups` for advanced configurations.
- `position` controls ordering when multiple views exist for the same object.
## Filters
A view can ship with pre-applied filters. Each filter has three coordinates: the **field** being filtered, the **operand** (how to compare), and the **value** (what to compare against). All three must line up — using an operand that doesn't apply to a field type will be rejected at sync time.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Supported operands per field type
| Field type | Supported operands |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
### Value shape per operand
The `value` field is always a JSON-serializable value, but its expected shape depends on the operand:
| Operand family | Value shape | Example |
| ----------------------------------------------------- | ------------------------------ | ------------------------ |
| `IS`, `IS_NOT` on `SELECT` | array of option keys (strings) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` on `MULTI_SELECT` | array of option keys (strings) | `['TAG_A']` |
| `IS`, `IS_NOT` on `RELATION` | array of record IDs (uuids) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` on text-like fields | string | `'acme'` |
| `IS`, `IS_NOT` on `NUMBER` | string (the value) | `'5'` |
| `IS` on `RATING` / `UUID` | string (the value) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | string (the bound) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` on `DATE` / `DATE_TIME` | ISO 8601 string | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | empty string | `''` |
| `IS` on `BOOLEAN` | `'true'` or `'false'` | `'true'` |
## How views show up in the UI
A view by itself isn't reachable from the sidebar. To make it appear there, pair it with a [navigation menu item](/developers/extend/apps/layout/navigation-menu-items) of type `VIEW` that points at the view's `universalIdentifier`. That's the canonical pattern: every custom object typically ships a default view + a sidebar entry that opens it.
@@ -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)
@@ -38,6 +38,60 @@ export default defineView({
* Für erweiterte Konfigurationen können Sie außerdem `filters`, `filterGroups`, `groups` und `fieldGroups` deklarieren.
* `position` steuert die Reihenfolge, wenn mehrere Ansichten für dasselbe Objekt existieren.
## Filter
Eine Ansicht kann mit vorab angewendeten Filtern ausgeliefert werden. Jeder Filter hat drei Koordinaten: das **Feld**, das gefiltert wird, der **Operand** (wie verglichen wird) und der **Wert** (womit verglichen wird). Alle drei müssen übereinstimmen — die Verwendung eines Operanden, der nicht auf einen Feldtyp anwendbar ist, wird bei der Synchronisierung zurückgewiesen.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Unterstützte Operanden pro Feldtyp
| Feldtyp | Unterstützte Operanden |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
### Wertstruktur je Operand
Das Feld `value` ist immer ein JSON-serialisierbarer Wert, aber die erwartete Struktur hängt vom Operanden ab:
| Operandenfamilie | Wertstruktur | Beispiel |
| -------------------------------------------------------- | ------------------------------------- | ------------------------ |
| `IS`, `IS_NOT` bei `SELECT` | Array von Optionsschlüsseln (Strings) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` bei `MULTI_SELECT` | Array von Optionsschlüsseln (Strings) | `['TAG_A']` |
| `IS`, `IS_NOT` bei `RELATION` | Array von Datensatz-IDs (UUIDs) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` bei textähnlichen Feldern | Zeichenkette | `'acme'` |
| `IS`, `IS_NOT` bei `NUMBER` | String (der Wert) | `'5'` |
| `IS` bei `RATING` / `UUID` | String (der Wert) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | String (der Grenzwert) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` bei `DATE` / `DATE_TIME` | ISO-8601-String | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | leerer String | `''` |
| `IS` bei `BOOLEAN` | `'true'` oder `'false'` | `'true'` |
## Wie Ansichten in der UI angezeigt werden
Eine Ansicht für sich ist aus der Seitenleiste nicht erreichbar. Damit sie dort erscheint, verknüpfen Sie sie mit einem [Navigationsmenüeintrag](/l/de/developers/extend/apps/layout/navigation-menu-items) des Typs `VIEW`, der auf den `universalIdentifier` der Ansicht zeigt. Das ist das kanonische Muster: Jedes benutzerdefinierte Objekt liefert typischerweise eine Standardansicht plus einen Eintrag in der Seitenleiste, der sie öffnet.
@@ -38,6 +38,60 @@ export default defineView({
* Você também pode declarar `filters`, `filterGroups`, `groups` e `fieldGroups` para configurações avançadas.
* `position` controla a ordenação quando existem várias visualizações para o mesmo objeto.
## Filtros
Uma visualização pode vir com filtros pré-aplicados. Cada filtro tem três coordenadas: o **campo** a ser filtrado, o **operador** (como comparar) e o **valor** (com o que comparar). As três precisam estar alinhadas — usar um operador que não se aplica a um tipo de campo será rejeitado no momento da sincronização.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Operadores compatíveis por tipo de campo
| Tipo de campo | Operandos suportados |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Tipos de campos com nomes semelhantes podem usar operandos completamente diferentes — `SELECT` e `MULTI_SELECT` sendo um caso comum.
### Formato do valor por operando
O campo `value` é sempre um valor serializável em JSON, mas o seu formato esperado depende do operando:
| Família do operando | Formato do valor | Exemplo |
| ----------------------------------------------------- | ----------------------------------- | ------------------------ |
| `IS`, `IS_NOT` em `SELECT` | array de chaves de opções (strings) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` em `MULTI_SELECT` | array de chaves de opções (strings) | `['TAG_A']` |
| `IS`, `IS_NOT` em `RELATION` | array de IDs de registros (uuids) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` em campos de texto | string | `'acme'` |
| `IS`, `IS_NOT` em `NUMBER` | string (o valor) | `'5'` |
| `IS` em `RATING` / `UUID` | string (o valor) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | string (o limite) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` em `DATE` / `DATE_TIME` | string ISO 8601 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | string vazia | `''` |
| `IS` em `BOOLEAN` | `'true'` ou `'false'` | `'true'` |
## Como as visualizações aparecem na UI
Uma visualização por si só não é acessível a partir da barra lateral. Para fazê-la aparecer lá, associe-a a um [item de menu de navegação](/l/pt/developers/extend/apps/layout/navigation-menu-items) do tipo `VIEW` que aponte para o `universalIdentifier` da visualização. Esse é o padrão canônico: cada objeto personalizado normalmente inclui uma visualização padrão + uma entrada na barra lateral que a abre.
@@ -38,6 +38,60 @@ export default defineView({
* Puteți declara, de asemenea, `filters`, `filterGroups`, `groups` și `fieldGroups` pentru configurații avansate.
* `position` controlează ordonarea atunci când există mai multe vizualizări pentru același obiect.
## Filtre
O vizualizare poate include filtre aplicate în prealabil. Fiecare filtru are trei coordonate: **câmpul** care este filtrat, **operandul** (cum se compară) și **valoarea** (față de ce se compară). Toate cele trei trebuie să se potrivească — folosirea unui operand care nu se aplică unui tip de câmp va fi respinsă în timpul sincronizării.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Operanzi acceptați pentru fiecare tip de câmp
| Tipul câmpului | Operanzi acceptați |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Tipurile de câmp cu nume similare pot folosi operanzi complet diferiți — `SELECT` și `MULTI_SELECT` fiind un caz comun.
### Formă de valoare per operand
Câmpul `value` este întotdeauna o valoare serializabilă JSON, dar forma sa așteptată depinde de operand:
| Familie de operanzi | Formă de valoare | Exemplu |
| ----------------------------------------------------- | ---------------------------------------------- | ------------------------ |
| `IS`, `IS_NOT` pe `SELECT` | array de chei de opțiune (șiruri de caractere) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` pe `MULTI_SELECT` | array de chei de opțiune (șiruri de caractere) | `['TAG_A']` |
| `IS`, `IS_NOT` pe `RELATION` | array de ID-uri de înregistrare (uuid-uri) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` pe câmpuri de tip text | șir | `'acme'` |
| `IS`, `IS_NOT` pe `NUMBER` | șir de caractere (valoarea) | `'5'` |
| `IS` pe `RATING` / `UUID` | șir de caractere (valoarea) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | șir de caractere (limita) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` pe `DATE` / `DATE_TIME` | șir în format ISO 8601 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | șir gol | `''` |
| `IS` pe `BOOLEAN` | `'true'` sau `'false'` | `'true'` |
## Cum apar vizualizările în interfața utilizatorului
O vizualizare, de una singură, nu este accesibilă din bara laterală. Pentru a o face să apară acolo, asociați-o cu un [element de meniu de navigare](/l/ro/developers/extend/apps/layout/navigation-menu-items) de tip `VIEW` care indică către `universalIdentifier` al vizualizării. Acesta este modelul canonic: fiecare obiect personalizat livrează, de obicei, o vizualizare implicită + o intrare în bara laterală care o deschide.
@@ -38,6 +38,60 @@ export default defineView({
* Также вы можете определить `filters`, `filterGroups`, `groups` и `fieldGroups` для более продвинутых конфигураций.
* `position` управляет порядком, когда для одного и того же объекта существует несколько представлений.
## Фильтры
Представление может поставляться с заранее примененными фильтрами. У каждого фильтра есть три координаты: **поле**, по которому выполняется фильтрация, **операнд** (как сравнивать) и **значение** (с чем сравнивать). Все три должны совпадать — использование операнда, который не подходит для типа поля, будет отклонено во время синхронизации.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Поддерживаемые операнды по типу поля
| Тип поля | Поддерживаемые операнды |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Типы полей с похожими названиями могут использовать совершенно разные операнды — типичный пример: `SELECT` и `MULTI_SELECT`.
### Форма значения для каждого операнда
Поле `value` всегда содержит значение, сериализуемое в JSON, но ожидаемая форма зависит от операнда:
| Семейство операндов | Форма значения | Пример |
| ------------------------------------------------------ | ------------------------------------- | ------------------------ |
| `IS`, `IS_NOT` для `SELECT` | массив ключей опций (строки) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` для `MULTI_SELECT` | массив ключей опций (строки) | `['TAG_A']` |
| `IS`, `IS_NOT` для `RELATION` | массив идентификаторов записей (uuid) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` для текстовых полей | строка | `'acme'` |
| `IS`, `IS_NOT` для `NUMBER` | строка (значение) | `'5'` |
| `IS` для `RATING` / `UUID` | строка (значение) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | строка (граница) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` для `DATE` / `DATE_TIME` | строка в формате ISO 8601 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | пустая строка | `''` |
| `IS` для `BOOLEAN` | `'true'` или `'false'` | `'true'` |
## Как представления отображаются в интерфейсе
Само по себе представление недоступно из боковой панели. Чтобы оно появилось там, свяжите его с [пунктом навигационного меню](/l/ru/developers/extend/apps/layout/navigation-menu-items) типа `VIEW`, который указывает на `universalIdentifier` представления. Это канонический шаблон: каждый пользовательский объект обычно поставляется с представлением по умолчанию и пунктом боковой панели, который его открывает.
@@ -38,6 +38,60 @@ export default defineView({
* Daha gelişmiş yapılandırmalar için `filters`, `filterGroups`, `groups` ve `fieldGroups` da tanımlayabilirsiniz.
* `position`, aynı nesne için birden fazla görünüm olduğunda sıralamayı kontrol eder.
## Filtreler
Bir görünüm, önceden uygulanmış filtrelerle gelebilir. Her filtrenin üç koordinatı vardır: filtrelenen **alan**, **işleç** (nasıl karşılaştırılacağı) ve **değer** (neyle karşılaştırılacağı). Üçünün de hizalı olması gerekir — bir alan türüne uygulanmayan bir işlecin kullanılması, senkronizasyon sırasında reddedilir.
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Alan türüne göre desteklenen işleçler
| Alan tipi | Desteklenen işleçler |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Benzer adlara sahip alan türleri tamamen farklı işleçler kullanabilir — buna `SELECT` ve `MULTI_SELECT` yaygın bir örnektir.
### Her işleç için değer biçimi
`value` alanı her zaman JSON olarak serileştirilebilir bir değerdir, ancak beklenen biçim işlece bağlıdır:
| İşleç ailesi | Değer biçimi | Örnek |
| ------------------------------------------------------------- | ------------------------------------- | ------------------------ |
| `SELECT` üzerinde `IS`, `IS_NOT` | seçenek anahtarları dizisi (metinler) | `['ACTIVE', 'PENDING']` |
| `MULTI_SELECT` üzerinde `CONTAINS`, `DOES_NOT_CONTAIN` | seçenek anahtarları dizisi (metinler) | `['TAG_A']` |
| `RELATION` üzerinde `IS`, `IS_NOT` | kayıt kimlikleri dizisi (uuid'ler) | `['c5a1...']` |
| metin benzeri alanlar üzerinde `CONTAINS`, `DOES_NOT_CONTAIN` | metin | `'acme'` |
| `NUMBER` üzerinde `IS`, `IS_NOT` | metin (değer) | `'5'` |
| `RATING` / `UUID` üzerinde `IS` | metin (değer) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | metin (sınır) | `'10'` |
| `DATE` / `DATE_TIME` üzerinde `IS`, `IS_BEFORE`, `IS_AFTER` | ISO 8601 dizesi | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | boş dize | `''` |
| `BOOLEAN` üzerinde `IS` | `'true'` veya `'false'` | `'true'` |
## Görünümlerin kullanıcı arayüzünde görüntülenme şekli
Tek başına bir görünüm, kenar çubuğundan erişilebilir değildir. Orada görünmesini sağlamak için, türü `VIEW` olan ve görünümün `universalIdentifier` değerini işaret eden bir [navigasyon menüsü öğesi](/l/tr/developers/extend/apps/layout/navigation-menu-items) ile eşleştirin. Bu, standart örüntüdür: her özel nesne genellikle varsayılan bir görünüm ve bunu açan bir kenar çubuğu girdisiyle birlikte sunulur.
@@ -38,6 +38,60 @@ export default defineView({
* 你还可以声明 `filters`、`filterGroups`、`groups` 和 `fieldGroups` 以进行更高级的配置。
* 当同一对象存在多个视图时,`position` 控制其排序。
## 过滤器
视图可以附带预先应用的过滤器。 每个过滤器有三个坐标:被筛选的**字段**、**运算符**(如何比较)以及**值**(与之比较的内容)。 这三者必须全部对齐——在同步时,使用不适用于字段类型的运算符将会被拒绝。
```ts
import { ViewFilterOperand } from 'twenty-shared/types';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### 各字段类型支持的运算符
| 字段类型 | 受支持的运算符 |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `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` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
### 每个运算符对应的值结构
`value` 字段始终是可序列化为 JSON 的值,但其期望的结构取决于所使用的运算符:
| 运算符类别 | 值结构 | 示例 |
| ----------------------------------------------------- | -------------------- | ------------------------ |
| `SELECT` 上的 `IS`, `IS_NOT` | 选项键(字符串)数组 | `['ACTIVE', 'PENDING']` |
| `MULTI_SELECT` 上的 `CONTAINS`, `DOES_NOT_CONTAIN` | 选项键(字符串)数组 | `['TAG_A']` |
| `RELATION` 上的 `IS`, `IS_NOT` | 记录 IDuuid)数组 | `['c5a1...']` |
| 文本类字段上的 `CONTAINS`, `DOES_NOT_CONTAIN` | 字符串 | `'acme'` |
| `NUMBER` 上的 `IS`, `IS_NOT` | 字符串(该值) | `'5'` |
| `RATING` / `UUID` 上的 `IS` | 字符串(该值) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | 字符串(边界值) | `'10'` |
| `DATE` / `DATE_TIME` 上的 `IS`, `IS_BEFORE`, `IS_AFTER` | ISO 8601 字符串 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | 空字符串 | `''` |
| `BOOLEAN` 上的 `IS` | `'true'` 或 `'false'` | `'true'` |
## 视图在 UI 中如何显示
单独一个视图无法从侧边栏访问。 要让它显示在侧边栏中,请将其与类型为 `VIEW`、指向该视图 `universalIdentifier` 的[导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items)配对。 这是规范用法:每个自定义对象通常都会提供一个默认视图,以及一个在侧边栏中打开该视图的条目。
@@ -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' } });
},
});
@@ -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 FormPropertiesFrontComponent = () => (
<FrontComponentCard title="form:properties">
<form
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}
>
form
</form>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-form-props-00000000-0000-0000-0000-000000000020',
name: 'form-properties-front-component',
description: 'Front component covering property reflection on <form>',
component: FormPropertiesFrontComponent,
});
@@ -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/Form/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'form-properties',
});
@@ -0,0 +1,57 @@
import { type FormEvent, 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,
INPUT_STYLE,
} from '@/__stories__/shared/front-components/styles';
const FormSubmitFrontComponent = () => {
const [fieldValue, setFieldValue] = useState('value-from-form');
const [submittedValue, setSubmittedValue] = useState<string | null>(null);
const { entries, pushEvent } = useEventLog();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmittedValue(fieldValue);
pushEvent(event);
};
return (
<FrontComponentCard title="form:submit">
<form
data-testid="subject"
action="javascript:void(0);"
onSubmit={handleSubmit}
>
<input
data-testid="form-field"
type="text"
name="field"
value={fieldValue}
onChange={(event) => setFieldValue(event.target.value)}
style={INPUT_STYLE}
/>
<button data-testid="submit-button" type="submit" style={BUTTON_STYLE}>
Submit
</button>
</form>
<span data-testid="front-component-value">
{submittedValue ?? 'none'}
</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-form-submit-00000000-0000-0000-0000-000000000020',
name: 'form-submit-front-component',
description: 'Front component covering submit event on <form>',
component: FormSubmitFrontComponent,
});
@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const CARET_INITIAL_VALUE = 'Hello world';
const InputCaretFrontComponent = () => {
const [value, setValue] = useState(CARET_INITIAL_VALUE);
return (
<FrontComponentCard title="input:text:caret">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Text input (pre-filled)</label>
<input
data-testid="subject"
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{value}</span>
</div>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-caret-00000000-0000-0000-0000-000000000020',
name: 'input-caret-front-component',
description: 'Front component covering caret behavior on <input type="text">',
component: InputCaretFrontComponent,
});
@@ -0,0 +1,61 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
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 {
INTERACTION_TIMEOUT,
TYPING_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Caret',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CaretPreservedMidString: Story = runFrontComponentStory({
frontComponentBundleName: 'input-caret',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) as HTMLInputElement;
await waitFor(
() => {
expect(subject.value).toBe('Hello world');
},
{ timeout: INTERACTION_TIMEOUT },
);
subject.focus();
subject.setSelectionRange(4, 4);
await userEvent.keyboard('X');
await waitFor(
() => {
expect(subject.value).toBe('HellXo world');
expect(canvas.getByTestId('front-component-value').textContent).toBe(
'HellXo world',
);
expect(subject.selectionStart).toBe(5);
expect(subject.selectionEnd).toBe(5);
},
{ timeout: TYPING_TIMEOUT },
);
},
});
@@ -0,0 +1,43 @@
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 {
LABEL_STYLE,
ROW_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputCheckboxFrontComponent = () => {
const [checked, setChecked] = useState(false);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:checkbox:checked">
<div style={ROW_STYLE}>
<input
data-testid="subject"
type="checkbox"
checked={checked}
onChange={(event) => {
setChecked(event.target.checked);
pushEvent(event);
}}
/>
<label style={LABEL_STYLE}>Check me</label>
<span data-testid="front-component-value">{String(checked)}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-checkbox-00000000-0000-0000-0000-000000000020',
name: 'input-checkbox-front-component',
description: 'Front component covering <input type="checkbox">',
component: InputCheckboxFrontComponent,
});
@@ -0,0 +1,51 @@
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/Input/Checkbox',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CheckedRoundTrip: Story = runFrontComponentStory({
frontComponentBundleName: 'input-checkbox',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: 'true' });
await expectEventLogged({
canvas,
matcher: { type: 'change', checked: true },
});
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: 'false' });
await expectEventLogged({
canvas,
matcher: { type: 'change', checked: false },
});
},
});
@@ -0,0 +1,63 @@
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';
import { TYPING_DELAY } from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const TextValueRoundTrip: Story = runFrontComponentStory({
frontComponentBundleName: 'input-text-value',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'hello', { delay: TYPING_DELAY });
await expectFrontComponentValue({ canvas, expected: 'hello' });
await expectEventLogged({
canvas,
matcher: { type: 'change', value: 'hello' },
});
},
});
export const FocusBlurEvents: Story = runFrontComponentStory({
frontComponentBundleName: 'input-text-focus-blur',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectEventLogged({ canvas, matcher: { type: 'focus' } });
const blurTarget = await canvas.findByTestId('blur-target');
await userEvent.click(blurTarget);
await expectEventLogged({ canvas, matcher: { type: 'blur' } });
},
});
@@ -0,0 +1,39 @@
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 {
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputFileMultipleFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:file:multiple">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Multiple files</label>
<input
data-testid="subject"
type="file"
multiple
accept="image/*"
onChange={(event) => pushEvent(event)}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-file-multiple-00000000-0000-0000-0000-000000000020',
name: 'input-file-multiple-front-component',
description: 'Front component covering multi-file <input type="file">',
component: InputFileMultipleFrontComponent,
});
@@ -0,0 +1,37 @@
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 {
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputFileSingleFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:file:single">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Single file</label>
<input
data-testid="subject"
type="file"
onChange={(event) => pushEvent(event)}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-file-single-00000000-0000-0000-0000-000000000020',
name: 'input-file-single-front-component',
description: 'Front component covering single-file <input type="file">',
component: InputFileSingleFrontComponent,
});
@@ -0,0 +1,76 @@
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 { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/File',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const SingleFile: Story = runFrontComponentStory({
frontComponentBundleName: 'input-file-single',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) as HTMLInputElement;
const file = new File(['hello world'], 'hello.txt', {
type: 'text/plain',
lastModified: 1700000000000,
});
await userEvent.upload(subject, file);
await expectEventLogged({
canvas,
matcher: {
type: 'change',
files: [{ name: 'hello.txt', type: 'text/plain' }],
},
});
},
});
export const MultipleFiles: Story = runFrontComponentStory({
frontComponentBundleName: 'input-file-multiple',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) 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(subject, [first, second]);
await expectEventLogged({
canvas,
matcher: {
type: 'change',
files: [
{ name: 'one.png', type: 'image/png' },
{ name: 'two.png', type: 'image/png' },
],
},
});
},
});
@@ -0,0 +1,45 @@
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 {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputKeyboardFrontComponent = () => {
const [lastKey, setLastKey] = useState('');
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:keyboard">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Keyboard input</label>
<input
data-testid="subject"
type="text"
onKeyDown={(event) => {
setLastKey(event.key);
pushEvent(event);
}}
onKeyUp={(event) => pushEvent(event)}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{lastKey}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-keyboard-00000000-0000-0000-0000-000000000020',
name: 'input-keyboard-front-component',
description: 'Front component covering keyboard events on <input>',
component: InputKeyboardFrontComponent,
});
@@ -0,0 +1,65 @@
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 { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Keyboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const BasicKey: Story = runFrontComponentStory({
frontComponentBundleName: 'input-keyboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.keyboard('a');
await expectEventLogged({
canvas,
matcher: { type: 'keydown', key: 'a', code: 'KeyA' },
});
await expectEventLogged({
canvas,
matcher: { type: 'keyup', key: 'a', code: 'KeyA' },
});
},
});
export const ShiftModifier: Story = runFrontComponentStory({
frontComponentBundleName: 'input-keyboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.keyboard('{Shift>}b{/Shift}');
await expectEventLogged({
canvas,
matcher: { type: 'keydown', shiftKey: true },
});
},
});
@@ -0,0 +1,33 @@
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 noop = () => undefined;
const InputNumberPropertiesFrontComponent = () => (
<FrontComponentCard title="input:number:properties">
<input
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}
type="number"
name={PROPERTY_FIXTURE.name}
value={String(PROPERTY_FIXTURE.numberValue)}
onChange={noop}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier:
'fc-input-num-props-00000000-0000-0000-0000-000000000020',
name: 'input-number-properties-front-component',
description:
'Front component covering property reflection on <input type="number">',
component: InputNumberPropertiesFrontComponent,
});
@@ -0,0 +1,38 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
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/Input/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Text = createPropertyReflectionStory({
frontComponentBundleName: 'input-text-properties',
extraAttributes: {
type: PROPERTY_FIXTURE.type,
name: PROPERTY_FIXTURE.name,
placeholder: PROPERTY_FIXTURE.placeholder,
},
extraProperties: { value: PROPERTY_FIXTURE.textValue },
});
export const Number = createPropertyReflectionStory({
frontComponentBundleName: 'input-number-properties',
extraAttributes: {
type: 'number',
name: PROPERTY_FIXTURE.name,
},
extraProperties: { value: String(PROPERTY_FIXTURE.numberValue) },
});
@@ -0,0 +1,40 @@
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 {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputTextFocusBlurFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:focus-blur">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Focus / Blur</label>
<input
data-testid="subject"
type="text"
onFocus={(event) => pushEvent(event)}
onBlur={(event) => pushEvent(event)}
style={INPUT_STYLE}
/>
</div>
<input data-testid="blur-target" type="text" style={INPUT_STYLE} />
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-text-fb-00000000-0000-0000-0000-000000000020',
name: 'input-text-focus-blur-front-component',
description: 'Front component covering focus/blur on text <input>',
component: InputTextFocusBlurFrontComponent,
});
@@ -0,0 +1,34 @@
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 noop = () => undefined;
const InputTextPropertiesFrontComponent = () => (
<FrontComponentCard title="input:text:properties">
<input
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}
type={PROPERTY_FIXTURE.type}
name={PROPERTY_FIXTURE.name}
placeholder={PROPERTY_FIXTURE.placeholder}
value={PROPERTY_FIXTURE.textValue}
onChange={noop}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier:
'fc-input-text-props-00000000-0000-0000-0000-000000000020',
name: 'input-text-properties-front-component',
description:
'Front component covering property reflection on <input type="text">',
component: InputTextPropertiesFrontComponent,
});
@@ -0,0 +1,47 @@
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 {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputTextValueFrontComponent = () => {
const [value, setValue] = useState('');
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:value">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Text input</label>
<input
data-testid="subject"
type="text"
placeholder="Type here..."
value={value}
onChange={(event) => {
setValue(event.target.value);
pushEvent(event);
}}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{value}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-text-value-00000000-0000-0000-0000-000000000020',
name: 'input-text-value-front-component',
description: 'Front component covering <input type="text"> value round-trip',
component: InputTextValueFrontComponent,
});
@@ -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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LabelClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="label:click">
<label
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
label
</label>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-label-c-click-00000000-0000-0000-0000-000000000020',
name: 'label-click-front-component',
description: 'Front component covering click on <label>',
component: LabelClickFrontComponent,
});
@@ -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/Label/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'label-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'label-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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LabelFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="label:focus-blur">
<label
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
label
</label>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-label-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'label-focus-blur-front-component',
description: 'Front component covering focus-blur on <label>',
component: LabelFocusBlurFrontComponent,
});
@@ -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 LabelPropertiesFrontComponent = () => (
<FrontComponentCard title="label:properties">
<label
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}
>
content
</label>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-label-props-00000000-0000-0000-0000-000000000020',
name: 'label-properties-front-component',
description: 'Front component covering property reflection on <label>',
component: LabelPropertiesFrontComponent,
});
@@ -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/Label/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'label-properties',
});
@@ -0,0 +1,40 @@
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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LegendClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="legend:click">
<fieldset>
<legend
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
legend
</legend>
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-legend-c-click-00000000-0000-0000-0000-000000000020',
name: 'legend-click-front-component',
description: 'Front component covering click on <legend>',
component: LegendClickFrontComponent,
});
@@ -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/Legend/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'legend-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'legend-focus-blur',
});
@@ -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';
import { FILL_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LegendFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="legend:focus-blur">
<fieldset>
<legend
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
legend
</legend>
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-legend-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'legend-focus-blur-front-component',
description: 'Front component covering focus-blur on <legend>',
component: LegendFocusBlurFrontComponent,
});
@@ -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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const MeterClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="meter:click">
<meter
data-testid="subject"
value={0.5}
min={0}
max={1}
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_INLINE_STYLE}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-meter-c-click-00000000-0000-0000-0000-000000000020',
name: 'meter-click-front-component',
description: 'Front component covering click on <meter>',
component: MeterClickFrontComponent,
});
@@ -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/Meter/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'meter-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'meter-focus-blur',
});
@@ -0,0 +1,40 @@
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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const MeterFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="meter:focus-blur">
<meter
data-testid="subject"
value={0.5}
min={0}
max={1}
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_INLINE_STYLE}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-meter-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'meter-focus-blur-front-component',
description: 'Front component covering focus-blur on <meter>',
component: MeterFocusBlurFrontComponent,
});
@@ -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 MeterPropertiesFrontComponent = () => (
<FrontComponentCard title="meter:properties">
<meter
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}
value={0.5}
min={0}
max={1}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-meter-props-00000000-0000-0000-0000-000000000020',
name: 'meter-properties-front-component',
description: 'Front component covering property reflection on <meter>',
component: MeterPropertiesFrontComponent,
});
@@ -0,0 +1,27 @@
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/Meter/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'meter-properties',
extraAttributes: {
value: '0.5',
min: '0',
max: '1',
},
});
@@ -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';
const OptgroupClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="optgroup:click">
<select defaultValue="x">
<optgroup
data-testid="subject"
label="group"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
>
<option value="x">inside</option>
</optgroup>
</select>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-optgroup-c-click-00000000-0000-0000-0000-000000000020',
name: 'optgroup-click-front-component',
description: 'Front component covering click on <optgroup>',
component: OptgroupClickFrontComponent,
});
@@ -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/Optgroup/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'optgroup-click',
});
@@ -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 OptionClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="option:click">
<select defaultValue="alpha">
<>
<option
data-testid="subject"
value="alpha"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
>
alpha
</option>
<option value="beta">beta</option>
</>
</select>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-option-c-click-00000000-0000-0000-0000-000000000020',
name: 'option-click-front-component',
description: 'Front component covering click on <option>',
component: OptionClickFrontComponent,
});
@@ -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/Option/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'option-click',
});
@@ -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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const OutputClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="output:click">
<output
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
output
</output>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-output-c-click-00000000-0000-0000-0000-000000000020',
name: 'output-click-front-component',
description: 'Front component covering click on <output>',
component: OutputClickFrontComponent,
});
@@ -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/Output/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'output-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'output-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_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const OutputFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="output:focus-blur">
<output
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
output
</output>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-output-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'output-focus-blur-front-component',
description: 'Front component covering focus-blur on <output>',
component: OutputFocusBlurFrontComponent,
});

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