Compare commits

..

31 Commits

Author SHA1 Message Date
ishan-karmakar 12bd695c59 Change host port in docker-compose.yml
CI Create App / changed-files-check (push) Has been cancelled
CD deploy main / deploy-main (push) Has been cancelled
CI Codex Plugin / changed-files-check (push) Has been cancelled
CI Codex Plugin / codex-plugin-validate (push) Has been cancelled
CI Create App E2E minimal / changed-files-check (push) Has been cancelled
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI Create App / create-app-test (lint) (push) Has been cancelled
CI Create App / create-app-test (test) (push) Has been cancelled
CI Create App / create-app-test (typecheck) (push) Has been cancelled
CI Create App / ci-create-app-status-check (push) Has been cancelled
CI Docs / changed-files-check (push) Has been cancelled
CI Docs / docs-lint (push) Has been cancelled
CI Emails / changed-files-check (push) Has been cancelled
CI Emails / emails-test (push) Has been cancelled
CI Emails / ci-emails-status-check (push) Has been cancelled
CI Example App Hello World / changed-files-check (push) Has been cancelled
CI Example App Hello World / example-app-hello-world (push) Has been cancelled
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Has been cancelled
CI Example App Postcard / changed-files-check (push) Has been cancelled
CI Example App Postcard / example-app-postcard (push) Has been cancelled
CI Example App Postcard / ci-example-app-postcard-status-check (push) Has been cancelled
CI UI / changed-files-check (push) Has been cancelled
CI UI / ui-task (lint) (push) Has been cancelled
CI UI / ui-task (test) (push) Has been cancelled
CI UI / ui-task (typecheck) (push) Has been cancelled
CI UI / ui-sb-build (push) Has been cancelled
CI UI / ui-sb-test (push) Has been cancelled
CI UI / ci-ui-status-check (push) Has been cancelled
Push translations to Crowdin / Extract and upload translations (push) Has been cancelled
2026-06-07 17:38:49 +00:00
ishan-karmakar 17347736be Update docker-compose for our purposes
CI Codex Plugin / changed-files-check (push) Has been cancelled
CI Codex Plugin / codex-plugin-validate (push) Has been cancelled
CI Create App E2E minimal / changed-files-check (push) Has been cancelled
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI Create App / changed-files-check (push) Has been cancelled
CI Create App / create-app-test (lint) (push) Has been cancelled
CI Create App / create-app-test (test) (push) Has been cancelled
CI Create App / create-app-test (typecheck) (push) Has been cancelled
CI Create App / ci-create-app-status-check (push) Has been cancelled
CI Docs / changed-files-check (push) Has been cancelled
CI Docs / docs-lint (push) Has been cancelled
CI Emails / changed-files-check (push) Has been cancelled
CI Emails / emails-test (push) Has been cancelled
CI Emails / ci-emails-status-check (push) Has been cancelled
CI Example App Hello World / changed-files-check (push) Has been cancelled
CI Example App Hello World / example-app-hello-world (push) Has been cancelled
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Has been cancelled
CI Example App Postcard / changed-files-check (push) Has been cancelled
CI Example App Postcard / example-app-postcard (push) Has been cancelled
CI Example App Postcard / ci-example-app-postcard-status-check (push) Has been cancelled
CI UI / changed-files-check (push) Has been cancelled
CI UI / ui-task (lint) (push) Has been cancelled
CI UI / ui-task (test) (push) Has been cancelled
CI UI / ui-task (typecheck) (push) Has been cancelled
CI UI / ui-sb-build (push) Has been cancelled
CI UI / ui-sb-test (push) Has been cancelled
CI UI / ci-ui-status-check (push) Has been cancelled
CD deploy main / deploy-main (push) Has been cancelled
Push translations to Crowdin / Extract and upload translations (push) Has been cancelled
2026-06-07 17:34:40 +00:00
claude[bot] dfe0b5bfd4 chore(server): rename TypeORM migrations dir to legacy-typeorm-migrations-do-not-add (#21295)
## What

Renames the historical TypeORM migrations directory so it's obvious at a
glance the path is frozen.

- `packages/twenty-server/src/database/typeorm/core/migrations/common/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/common/`
- `packages/twenty-server/src/database/typeorm/core/migrations/billing/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/`
- `packages/twenty-server/src/database/typeorm/core/migrations/utils/` —
**left in place** (those SQL helpers are still imported by current
instance/workspace commands, see e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)
- Updates `core.datasource.ts` globs to the new path and adds an inline
comment explaining the dir is frozen
- Adds a `README.md` at the new dir pointing readers at
`UPGRADE_COMMANDS.md` and the active `upgrade-version-command/` tree

## Why

The TypeORM migration system was replaced by fast/slow instance commands
+ workspace commands (PR #19356), but `typeorm/core/migrations/common/`
still looked structurally identical to an active migrations folder, with
new files merging in as recently as last week. New contributors — and AI
agents — kept inferring it was the active path and adding TypeORM
`MigrationInterface` files. See #21286 for the most recent instance.

This is recommendation **1b** from #21286
(`https://github.com/twentyhq/twenty/pull/21286#discussion_r3369356792`).
A renamed folder is the single strongest signal — no one instinctively
adds to a `do-not-add` directory.

## Notes

- Imports from `src/database/typeorm/core/migrations/utils/…` keep
working because `utils/` did not move.
- No behavior change at runtime: TypeORM loads the same files via
`_typeorm_migrations`, just from the new path.

## Test plan

- [ ] CI green
- [ ] `nx build twenty-server` succeeds
- [ ] Fresh `nx database:reset twenty-server` from a clean DB still
replays the legacy TypeORM migrations (i.e. `_typeorm_migrations` rows
get inserted at boot/init)
- [ ] Spot-check that an instance command that imports from
`migrations/utils/` still resolves at build time (e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-06-07 14:54:36 +02:00
neo773 186d5b8faa revert #21177 (#21284) 2026-06-06 14:49:55 +02:00
Valeriy Proklov 011afa6011 Allow kanban cross-column drag when sorting is enabled (#21025)
## Summary

This PR allows kanban cards to be dragged across columns while sorting
is enabled.

Previously, any board drag while a sort was active opened the “Remove
sorting?” modal. That makes sense for same-column reordering, because
manual reorder conflicts with the active sort. But for cross-column
moves, the user is changing the grouped field, not trying to manually
reorder the destination column.

With this change:

- Same-column drag with sorting enabled still opens the existing
remove-sorting modal.
- Cross-column drag with sorting enabled updates only the group field.
- The destination column keeps using the active sort to determine where
the card appears.
- Unsorted board drag behavior continues to update `position` as before.

## Why

On sorted kanban boards, moving a card to another column is a valid
workflow even though manual reordering is not. The previous guard
blocked both cases because it only checked whether sorting was active,
not whether the card stayed inside the same column.

## Implementation

The drop behavior now distinguishes between:

- sorted same-column drops, which remain blocked
- sorted cross-column drops, which are allowed without a position update
- unsorted drops, which keep the existing position-update behavior

A small helper captures that decision and has focused unit coverage.

## Validation

- Manually verified sorted cross-column drag persists after refresh.
- Manually verified sorted same-column drag still opens the
remove-sorting modal.
- Manually verified unsorted same-column drag still reorders cards.
- Manually verified unsorted cross-column drag still moves cards.
- Ran focused Jest coverage for the sorted board drop decision.
- Ran formatting and oxlint checks on touched frontend files.
- Ran `twenty-front` typecheck.
- Ran `twenty-front` production build.

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-06 14:22:57 +02:00
Félix Malfait a2fa941ce3 chore(twenty-front): remove dead SettingsAiMCP component and covers (#21281)
## What

`SettingsAiMCP` is dead code — nothing imports or renders it. The
redesign moved MCP setup to the **APIs & Webhooks** page
(`SettingsMcpSetup`, on the MCP tab), and the AI settings page now
**deep-links** there (`ApiWebhooks#mcp`) instead of rendering this
component.

Removes the orphaned component and its two cover SVGs:
- `src/pages/settings/ai/components/SettingsAiMCP.tsx`
- `public/images/ai/ai-mcp-cover-{light,dark}.svg`

## Notes

- Verified nothing references the component or the SVGs in source (only
stale `.po` source-reference comments remain, which `lingui` extraction
reconciles separately — not hand-edited here).
- The hero on the APIs & Webhooks page (incl. its MCP tab) is
unaffected; that's the `playground/cover` image.
2026-06-06 12:23:42 +02:00
Félix Malfait 898713bd49 fix(server): finalize dangling tool calls when persisting agent chat messages (#21276)
## Problem

Interrupting an AI chat turn mid tool-call batch permanently bricks the
thread. Every subsequent message fails with:

> Tool results are missing for tool calls toolu_…, toolu_…

## Root cause

When the model fires a parallel batch of tool calls, it streams all the
calls first, then results come back one by one. If the stream is aborted
(user hits stop, credit cutoff, etc.) after only some have resolved, the
AI SDK's `onFinish` still fires with the partial assistant message —
including tool parts left in `input-available` state (a tool call with
no result).

`addMessage` persists that message verbatim. On the next turn the
history is rebuilt and `streamText` validates it: every `tool-call` must
be cleared by a `tool-result` before the next user message, or it throws
`MissingToolResultsError` (`ai/dist`, the `MissingToolResultsError`
check). The orphaned calls are now in the DB, so the thread fails on
every turn from then on.

## Fix

Enforce the invariant at the single write chokepoint. Every chat message
is persisted through `AgentChatService.addMessage`, so
`finalizeDanglingToolParts` runs there once: any tool part still in
`input-available` is rewritten to `output-error` ("Tool execution was
interrupted.") before mapping to DB rows.

`output-error` converts to a real `tool-result`, so the persisted turn
is always self-consistent and the next request is valid. Interrupted
calls are kept (not dropped) and surfaced as errored rather than
perpetually "running" — honest, since a partially-executed call may have
committed side effects the model should be able to reconcile.

One guard at one point covers every abort source — no read-side
patching, no migration, no schema change.

## Caching impact

None on the happy path. A completed turn has no `input-available` parts,
so the helper is a no-op and the persisted bytes (and therefore the
cached prefix) are identical to before. For an interrupted turn, the
finalized content is deterministic and written once, so it caches
cleanly on the following request and stays stable across later turns —
there is no scenario where this invalidates an existing cache entry. Net
effect: turns a hard failure into a normally-cached continuation.

## Testing

- New unit test covering finalize / no-op cases (7 cases, passing)
- `oxlint --type-aware` + `oxfmt` clean on changed files
2026-06-06 11:22:45 +02:00
Félix Malfait 4658d44d8b fix(settings): ship borderless hero cover images (#21277)
## What

The settings discovery hero images (AI, Applications, Page Layouts,
Members, Data Model, APIs & Webhooks) baked the rounded border into the
pixels — transparent rounded corners plus a 1px edge stroke. Rendered
inside `Card rounded` — which already draws a 1px border + border-radius
and clips children with `overflow: hidden` — this produced a doubled,
slightly misaligned border.

This replaces all 12 files (light + dark per section) with clean
full-bleed exports (opaque square corners), so the border and rounding
come entirely from CSS.

## Notes

- Pure asset swap, no component changes.
- The MCP section's `.svg` cover is untouched (no new export provided).
Billing's unused cover is left as-is.

## Verification

- Each new image confirmed 1388×300, opaque square corners (no baked
border), correct light/dark variant.
- `Card` (twenty-ui) provides `border` + `border-radius` + `overflow:
hidden`, so the square images are clipped to the rounded card.
2026-06-06 11:21:06 +02:00
Summy Kumari a596e7d904 fix: add missing space in README heading (#21271)
The h2 heading in README.md was missing a space between the `>` closing
angle bracket and "The", causing minor formatting inconsistency.

Changed:
`<h2 align="center" >The #1 Open-Source CRM</h2>`

To:
`<h2 align="center"> The #1 Open-Source CRM</h2>`

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-06 10:41:16 +02:00
Félix Malfait 91f2f08995 feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why

The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`,
`usageEvent`, `applicationLog`) each wrote to ClickHouse through their
own fire-and-forget writer (`AuditService`, `UsageEventWriterService`,
and the `application-logs` driver), with the per-type knowledge (table
names, normalization, access rules) spread across several modules. Three
of them reimplemented the same ClickHouse insert, and the read side, the
live stream, and the producers lived in different modules under two
different names.

This consolidates them into one `core-modules/event-logs/` subsystem
(emit, write, live, read), with the per-type config in a single registry
so adding an event type is roughly one file.

The base Logs settings tab and free application logs shipped separately
in #21180 (merged). This PR adds the unified backend, the registry, and
the viewer's live mode and entitlement gating.

## Pipeline

```mermaid
flowchart TB
    subgraph PROD["Producers"]
      A["auth, billing, impersonation,<br/>webhook, custom-domain"]
      U["usage listener"]
      F["logic-function executor (app logs)"]
      R["record CRUD (entity events)"]
    end
    EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"]
    EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"])
    CIE["CreateEventLogFromInternalEvent"]
    SINK["WorkspaceEventSinkService.ingest()"]
    C1["ClickHouseEventSink"]
    C2["ConsoleEventSink"]
    LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"]
    CH[("ClickHouse, 5 tables, async_insert")]
    CHAN(["WORKSPACE_EVENTS_CHANNEL"])
    RS["EventLogsService (registry-driven read)"]
    LR["EventLogsLiveResolver"]
    UI["Settings > Logs"]

    A --> EM
    U --> EM
    F --> EM
    EM -->|direct| SINK
    R --> EQ --> CIE -->|ingest| SINK
    SINK --> C1 --> CH
    SINK --> C2
    SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI
    CH --> RS --> UI
```

## What it does

- Producers call `EventLogEmitterService.createContext().insert*()`,
which builds a typed `WorkspaceEventEnvelope` and writes it through
`WorkspaceEventSinkService` to the configured sinks (ClickHouse,
Console) plus a presence-gated live fan-out. Record/CRUD events reach
the same sink through the existing `entityEventsToDbQueue`. There is no
dedicated queue; ClickHouse `async_insert` batches server-side. Writes
are best-effort, as on main today.
- `EVENT_LOG_TYPES[table]` is the per-type source of truth: the
ClickHouse table, the required entitlement, the free-text filter column,
and the row-to-GraphQL mapping. Read row shapes derive from the write
rows.
- Four modules along their dependency boundaries:
`EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink
layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the
entitlement-gated GraphQL read, which is where
billing/enterprise/permissions stay so producers stay light).
- Logs viewer: per-table columns, filters (text, date, record), live
mode, and an upgrade card that points to Billing on Cloud or the Admin
Panel on self-hosted. Application logs are free on every plan; the other
four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT`
fallback to the upgrade card).
- Renames `AuditService` to `EventLogEmitterService`, and the generic
`Monitoring` event to a typed `Impersonation` event (`level` +
`action`).
- Removes `UsageEventWriterService`, the `application-logs`
driver/module, and `AuditService`'s direct inserts.

## Durability

Writes are best-effort, the same as main today (the old writers were
fire-and-forget). A dedicated queue was tried mid-PR and removed:
`async_insert` already batches server-side, so the queue only added
durability, which isn't a requirement right now. The `EventSink` seam
keeps a durable transport (e.g. a Redis-Streams buffer) easy to add
later without touching producers.

## Out of scope

S3 peer sink (seam only), Postgres or any second read path,
`ReplicatedMergeTree`, ClickHouse table-schema changes, and the
record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern).

## Testing

Unit tests cover the registry definitions and row normalization, the
entitlement gating, the envelope builders, and the producers.
Integration tests cover the write paths (record create produces an
`objectEvent`; the track mutation produces a `workspaceEvent`) and the
read/query path across all five tables. Verified with typecheck, lint, a
server boot, and GraphQL/SDK codegen.
2026-06-06 10:32:56 +02:00
martmull 6c65d26ced feat(app-dev): add dry-run preview to dev sync (#21251)
Split out of #21240. Stacked on #21250 (review/merge that first).

`yarn twenty dev --once --dry-run` computes the migration plan and
prints the diff **without applying anything** (no migration, no
app-record update, no SDK generation). Also renders the diff on a normal
`dev --once` sync.

<img width="646" height="179" alt="image"
src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0"
/>
2026-06-05 17:49:02 +00:00
neo773 bfb83e93b2 fix(metadata): resolve junction targets order-independently during mgration (#21193)
A junction relation points at a target field on the join object that
another action may create later (two junctions into the same join
reference each other). The builder validator now also looks up the
target in the to be created set, and the runner mints every field id up
front so the target resolves regardless of action order, the same way
relation pairs are already handled.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-05 17:32:25 +00:00
Charles Bochet 20d9244639 fix(server): gate viewFilter.relationTargetFieldMetadataId behind its 2.6 upgrade command (#21267)
## Problem

Self-hosted upgrades crossing 2.6 (e.g. `2.4 → 2.6/2.9`) can abort with:

```
column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
[UpgradeSequenceRunnerService] Workspace steps ended with 1 failure(s). Aborting
```

This is **Failure #1** from #20841 — the counterpart to the
role-permission cache crash fixed in #21257 (Failure #2). Same shape: a
workspace **cache recompute runs mid-upgrade and reads schema that the
target version's migration hasn't applied yet**.

## Root cause

`ViewFilterEntity.relationTargetFieldMetadataId` is added to
`core.viewFilter` only at the **2.6.0** cursor
(`AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand`, ts
`1798000005000`). But the workspace cache recompute SELECTs every column
of the entity, and it runs during *earlier* (2.5) workspace steps.
Unlike `RolePermissionFlagEntity.permissionFlag`, this column has **no
`@WasIntroducedInUpgrade` gate**, so the proxy can't hide it — and the
SELECT fails when the column isn't there yet.

There are three `IF NOT EXISTS` backport commands (2.3/2.4/2.5) meant to
add the column sooner, but they use **low timestamps** that sort to the
front of their version bundles. An instance whose cursor has already
advanced past those positions (e.g. it reached 2.4, or a prior failed
attempt advanced it through 2.5 instance commands) treats them as
already-applied and **skips them** — so the column is never created, yet
the entity keeps selecting it.

## Fix

Gate the column with `@WasIntroducedInUpgrade` pointing at the **2.6.0**
command that adds it:

```ts
@WasIntroducedInUpgrade({
  upgradeCommandName:
    '2.6.0_AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand_1798000005000',
})
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
```

`UpgradeAwareRepositoryProxy` then hides the column from reads while the
cursor is < 2.6, so the cache recompute simply omits it — no crash — and
it becomes visible once the 2.6.0 command has run (where it's guaranteed
to exist). Gating to **2.6.0** specifically (not the earlier backports)
is what fixes the cursor-skip case: 2.6.0 is the first point where the
column is reliably present regardless of whether the backports ran.

Validator-safe: the referenced command resolves to a real step
(`computeCommandName` = `${version}_${className}_${timestamp}`), so
`validate-upgrade-aware-entity-decorators` accepts it. The existing
backport commands are left untouched (committed instance commands).

## Recovery for already-stuck instances

This prevents *new* failures. An instance already aborted mid-upgrade
needs the column added manually before retrying:

```sql
ALTER TABLE core."viewFilter" ADD COLUMN IF NOT EXISTS "relationTargetFieldMetadataId" uuid;
DELETE FROM core."upgradeMigration" WHERE status='failed';
```
then re-run the upgrade on a build that includes this fix.

Refs #20841

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:56:02 +02:00
Thomas Trompette 51e3eddb29 Add job queue latency histogram metric (#21260)
## Summary
- Records the time each job spends waiting in the queue (enqueue →
processing start) as an OpenTelemetry histogram
- Metric is broken down by `queue` and `job_name` attributes, enabling
per-queue p50/p95/p99 latency analysis
- Uses BullMQ's native `job.timestamp` for accurate measurement

## Test plan
- [x] Deploy to staging and verify `job/latency-ms` metric appears in
ClickHouse `otel_metrics_histogram` table
- [x] Confirm Grafana dashboard can query the histogram data
2026-06-05 16:04:54 +00:00
Etienne 27db8bbae4 fixes - remove billing cancellation at trial end + disable ai chat if suspended (#21261)
- Remove billing cancelation if trial is ended (subscription activated)
- Disable AI Chat use if workspace suspended
2026-06-05 15:34:12 +00:00
martmull 84ba7eb8dc fix(app-dev): serialize dev sync per workspace with a cache lock (#21250)
Split out of #21240. Stacked on #21249 (review/merge that first).

Concurrent `syncApplication` calls on the same workspace could
interleave their metadata migrations and leave metadata partially
applied. Wrap the manifest sync in a per-workspace cache lock
(`app-sync:<workspaceId>`), mirroring the install path. The rate-limit
throttle stays outside the lock.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 17:30:55 +02:00
Charles Bochet a3d73740a1 fix(server): guard role-permission cache against stripped permissionFlag relation during upgrade (#21257)
## Problem

Self-hosted upgrades that jump versions (e.g. `2.4 → 2.7/2.9`) abort
with:

```
TypeError: Cannot read properties of undefined (reading 'universalIdentifier')
  at WorkspaceRolesPermissionsCacheService.hasSettingsGatedObjectPermissions
  at WorkspaceRolesPermissionsCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
```

Reported in #20841 (Failure #2). The sequence aborts mid-upgrade and
leaves the DB in a half-migrated state.

## Root cause

The per-workspace **cache recompute runs at a `2.5.0` workspace step —
before the `2.6` schema migrations apply**. At that cursor:

- `RolePermissionFlagEntity.permissionFlag` is
`@WasIntroducedInUpgrade('2.6.0_LinkRolePermissionFlagToPermissionFlag…')`,
so `UpgradeAwareRepositoryProxy` **strips the relation**
(`[upgrade-proxy] strip relation
RolePermissionFlagEntity.permissionFlag` in the logs) → `permissionFlag`
is `undefined`.
- `hasSettingsGatedObjectPermissions()` then does an **unguarded**
`rolePermissionFlag.permissionFlag.universalIdentifier` → throws.

The crash only manifests when a workspace has **≥1 `rolePermissionFlag`
row** (custom roles with gated settings perms / SDK `defineRole`). A
vanilla seed has an empty table, so `.find()` over `[]` never
dereferences anything — which is why it didn't reproduce on a clean
instance.

A null-safe fallback to the legacy `flag` column used to exist here; it
was dropped in #20730.

## Fix

Resolve the flag's universal identifier through a small helper that
falls back to the legacy `flag` column (only removed in `2.7.0`) when
the relation is unavailable:

```ts
private getRolePermissionFlagUniversalIdentifier(
  rolePermissionFlag: RolePermissionFlagEntity,
): string {
  // The `permissionFlag` relation is stripped during upgrades until the 2.6.0
  // cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
  return (
    rolePermissionFlag.permissionFlag?.universalIdentifier ??
    SystemPermissionFlag[rolePermissionFlag.flag]
  );
}
```

`SystemPermissionFlag[flag]` yields the same UUID the relation would, so
the comparison stays in a single space and the computed permission is
exact (not an over-grant). Correct at every transitional cursor:
pre-`2.6` (relation stripped → use `flag`), `2.6` (both present →
relation wins), post-`2.7` (`flag` removed → relation wins).

## Reproduction & validation

Locally jumped a real `2.4.0` DB → `v2.9.0` build via `yarn command:prod
upgrade`:

| Scenario | Result |
| --- | --- |
| Empty `permissionFlag` (vanilla seed) | passes (no crash) |
| **+1 flag row**, current code | `TypeError … universalIdentifier` →
**3 succeeded, 1 failed** |
| Same fixture, **this fix** | **16 succeeded, 0 failed**, DB fully
migrated to 2.9.0 |

`nx typecheck twenty-server` clean; existing cache-service unit tests
pass; app boots on the upgraded DB.

## Scope / follow-up

This fixes **Failure #2**. **Failure #1** in the same issue
(`viewFilter.relationTargetFieldMetadataId` selected before its column
exists) is a separate instance of the same theme — cache recompute
reading "future" schema before migrations run — and is worth a
follow-up. A more durable systemic fix would defer the workspace cache
recompute until after all schema-adding migrations; this PR is the
low-risk, backport-friendly fix for the immediate breakage.

> Note: an earlier bot branch
(`sonarly-39738-fixupgrade-guard-role-permission-flag-relation`)
proposed the same fallback inline. This PR supersedes it with a named
helper + a focused comment.

Fixes #20841

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:45:17 +00:00
Thomas Trompette 0d3c7a47af fix: guard against undefined logicFunctionId when destroying workflow CODE steps (#21256)
## Summary
- Adds `isDefined` guard in `handleLogicFunctionSubEntities` to skip
CODE steps with undefined `logicFunctionId` instead of crashing
- Adds same guard in `runWorkflowVersionStepDeletionSideEffects` for
consistency
- Rejects CODE steps in `create_complete_workflow` AI tool at runtime to
prevent creating workflows with missing logic functions in the first
place

Fixes `"Logic function with id undefined not found"`
INTERNAL_SERVER_ERROR when destroying workflows whose CODE steps were
created via `create_complete_workflow` without a proper logic function.

## Test plan
- [x] Destroy a workflow that has a CODE step with undefined
logicFunctionId → should succeed silently
- [x] Try creating a workflow with a CODE step via
`create_complete_workflow` tool → should return error message
- [x] Normal workflow destroy with valid CODE steps still deletes the
logic function
2026-06-05 14:30:19 +00:00
twenty-pr[bot] 2dbdd72e65 chore: bump version to 2.11.0 (#21259)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-06-05 16:47:40 +02:00
martmull f20d04eb6e feat(app-dev): surface metadata diff in dev sync and name failing migration actions (#21249)
Split out of #21240.

- Render the applied metadata changes (created/updated/deleted +
identifiers) in the dev sync output instead of a bare `✓ Synced`.
- Include the failing entity's `universalIdentifier` in
`WorkspaceMigrationRunnerException` messages so conflicts are
diagnosable.

<img width="637" height="114" alt="image"
src="https://github.com/user-attachments/assets/61422a16-370c-4e9b-a2f6-c29ce17f3b1b"
/>

<img width="497" height="104" alt="image"
src="https://github.com/user-attachments/assets/d493c398-da29-49c9-ac5e-aa0f26cd7389"
/>

<img width="593" height="127" alt="image"
src="https://github.com/user-attachments/assets/15e26edc-c0e4-4427-bd34-909040e970c9"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 16:35:50 +02:00
nitin f72898063a upgrade command patch - warn instead of throw when no calendarEvent object found in the workspace (#21258) 2026-06-05 16:32:16 +02:00
nitin e485b679ea [Call Recording] Add standard object (#21158)
Adds **Call Recording** as a first-class standard object (Twenty's
flat-metadata
standard-object system), with a hidden junction to calendar events and a
backfill
command for existing workspaces. Everything is gated behind the
`IS_CALL_RECORDING_ENABLED` feature flag.

### What's included
- **`CallRecording`**: audio/video files, transcript, status, recording
policy,
timing, external bot/recording ids. Label identifier is
`meetingOccurrenceKey`.
- **`CallRecordingCalendarEventAssociation`**: hidden junction linking a
recording
to a calendar event (dedupes one bot to many subscribers of the same
meeting).
- Full metadata graph via the flat-metadata builders: fields, indexes,
views,
  view fields/groups, record page layout, and navigation items.
- **Metadata-only reverse relation** on `CalendarEvent`: present in
standard
metadata, omitted from the TS entity class to avoid expanding recursive
  nested-insert types.
- **Upgrade command (2.9.0)** backfilling active/suspended workspaces:
  - Creates the full graph; idempotent (skips when it already exists).
- Moves a colliding custom `callRecording` object aside to
`callRecordingOld`
    (numeric suffix if that name is also taken).
- Navigation items (commands) are flag-gated by `universalIdentifier`,
so a custom object
    reusing the name is never gated.

### QA
Run locally against existing workspaces (with and without a name
collision) and a
freshly created workspace:
- [x] Backfill, collision: custom `callRecording` renamed to
`callRecordingOld`;
  standard graph created.
- [x] Backfill, no collision: standard graph created; unrelated custom
object untouched.
- [x] Idempotent: re-run is a no-op, with no duplicate metadata and
counts unchanged.
- [x] New workspace via `init()` produces an identical graph to the
backfill
  (`universalIdentifier` set-diff = 0).
- [x] Label identifier (`meetingOccurrenceKey`) holds position 0 in
non-widget views.
- [x] Nav items gated behind the feature flag; collision-renamed
object's nav
  expression re-pointed to its new name.
- [x] Unit tests cover collision name resolution and nav-gating logic.
2026-06-05 13:02:50 +00:00
Paul Rastoin a78b319877 sdk/v* twenty/v* namespaces (#21247)
# Introduction
Removed never used release dispatch workflow
Now assuming that anyone releasing will create both twenty and npm
family tags
Will create a workflow to ease this later

We will now start to have several github releases, one per namespace
2026-06-05 12:40:50 +00:00
Félix Malfait c3dd6b25a6 fix: use canonical oxlint rule id in lint-disable directives (#21253)
## What

Many `oxlint-disable` / `eslint-disable` directives across the repo
carry a corrupted rule id — `@typescripttypescript/<rule>` — most likely
a find-and-replace accident that mangled the eslint-era
`@typescript-eslint/` prefix.

oxlint matches disable directives **loosely by rule name**, so these
still suppress in practice (not a silent no-op), but the id is malformed
and misleading.

## Change

Replace them with the **canonical oxlint id** `typescript/<rule>` —
matching the plugin name and rule keys declared in `.oxlintrc.json` —
**127 files, 262 directives**:

| rule | count |
| --- | ----- |
| `typescript/no-explicit-any` | 250 |
| `typescript/ban-ts-comment` | 6 |
| `typescript/no-misused-promises` | 4 |
| `typescript/no-empty-object-type` | 2 |

- `twenty-server`: 122 files
- `twenty-front`: 5 files

Comment-only — no code or runtime changes.

## Verification

`oxlint --type-aware -c .oxlintrc.json` reports **0 warnings / 0
errors** for both `twenty-server` and `twenty-front`. Every changed line
is exactly the id correction inside a disable directive (262 insertions
/ 262 deletions, no collateral edits).

> Addresses the cubic review, which flagged that the canonical oxlint id
is `typescript/...` (no `@`). Worth noting the original
`@typescripttypescript/` was not actually a silent no-op — oxlint
matches these directives loosely by rule name — but `typescript/` is the
correct, config-aligned id.
2026-06-05 13:52:32 +02:00
Raphaël Bosi 6f9b59b224 Scaffold twenty-new-ui (#21236)
Scaffolds `twenty-new-ui`, the next-gen replacement for `twenty-ui`, on
**SCSS** Modules + **Base UI** (no Linaria).

- **Tooling**: Vite lib build, subpaths mirror twenty-ui, typed SCSS
Modules, Storybook + axe a11y, size-limit, Nx targets.
- **Theme**: single token source → nx generateTheme emits the CSS vars +
accessor; parity test asserts token-for-token match with twenty-ui.

Migrated a first `Toggle` component with its stories to allow
@charlesBochet to wire the new pixel-diff system.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:42:57 +00:00
Abdullah. b60a91a075 fix(website): render releases from local notes, drop GitHub gate (#21238)
The releases page gated visible notes on fetchLatestGithubReleaseTag();
when that unauthenticated GitHub call was rate-limited at build (common
on shared Cloudflare build IPs, no GITHUB_TOKEN), it returned null and
getVisibleReleaseNotes returned [], rendering 'No releases are visible
yet for the current published version' — the exact prod symptom.
force-static made each deploy a coin flip.

Committed MDX (+images) is the single source of truth, so gate
visibility on nothing: render all local notes deterministically with no
network call. Removes the dev/prod divergence and deletes the now-dead
fetch-latest-release-tag.ts and get-visible-releases.ts.
2026-06-05 08:59:40 +00:00
Charles Bochet cd540098f1 fix: pass reference_commit to Argos to resolve orphan PR builds (#21245)
## Summary

- Fixes Argos CI builds showing as "Orphan" (no reference branch) for PR
builds
- Computes the merge-base SHA between the PR head and `main` using the
GitHub API (`compareCommitsWithBasehead`) in the dispatch workflow
- Passes `reference_commit` in the `ci-privileged` dispatch payload so
it can be forwarded to the Argos upload API

## Context

PR builds on Argos were showing as "Orphan" because `ci-privileged`
(where the actual Argos upload happens) has no git history of the
`twenty` repo — it cannot compute the merge-base locally. Without a
`referenceCommit`, Argos can't determine which `main` build to compare
against.

The local `visual-diff.sh` script already passes
`ARGOS_REFERENCE_COMMIT` via `git merge-base HEAD main`, but the CI
pipeline was missing this. This PR adds equivalent logic using the
GitHub API (no checkout needed).

## Note for ci-privileged

The `upload-to-argos.ts` script in `ci-privileged` needs a corresponding
update to read `reference_commit` from the dispatch payload and pass it
as `referenceCommit` in the Argos API call:

```typescript
referenceCommit: process.env.REFERENCE_COMMIT || undefined,
```

## Test plan

- [ ] Verify the workflow runs successfully on a PR (merge-base step
computes a SHA)
- [ ] Confirm Argos PR builds are no longer marked as "Orphan" after the
ci-privileged counterpart is updated
2026-06-05 10:51:16 +02:00
Félix Malfait 1b30983307 fix(settings): gate the AI settings page on AI_SETTINGS, not the chat flag (#21239)
## Summary

Closes #21229.

The two AI role permissions behaved **opposite to their labels**. The
trap is that the flag's code name is the inverse of its UI label:

| `PermissionFlagType` | UI label | Section | Means |
|---|---|---|---|
| `AI` | **"Ask AI"** | Actions | End-user: chat with AI |
| `AI_SETTINGS` | **"AI"** | Member / settings | Admin: configure AI
agents |

Before this PR (on `main`):
- `AI` ("Ask AI", chat) gated **both** the AI chat **and** the AI
settings page.
- `AI_SETTINGS` ("AI", configure agents) gated **nothing** the user
could see.

So a chat-only user could reach the whole AI **configuration** page, and
toggling the "AI" settings permission did nothing — exactly the
misalignment reported in #21229.

## Root cause

`PermissionFlagType.AI` *reads* like "the AI permission", so it looks
like the natural gate for the AI settings page — but it's actually the
**chat** flag. The settings page (nav item + route) had been pointed at
`AI` in #21072 to match the Overview stats query
(`findWorkspaceAiStats`), which was itself mis-gated on `AI`. Both the
stats query and the rest of the settings surface are admin/config
features, so they belong on `AI_SETTINGS`.

## Changes

All three move the **AI settings surface** from the chat flag (`AI`) to
the settings flag (`AI_SETTINGS`); chat keeps following `AI`:

- `useSettingsNavigationItems.tsx` — AI nav item → `AI_SETTINGS`
- `SettingsRoutes.tsx` — AI settings route group → `AI_SETTINGS`
- `ai-workspace-stats.resolver.ts` — `findWorkspaceAiStats`
(settings-only, drives the Overview tab) → `AI_SETTINGS`

After this: the "AI" permission controls the AI settings page + its
Overview; the "Ask AI" permission controls the chat. Both toggles now
match their labels.

## Test plan

- [ ] Role with **only "Ask AI"** (`AI`): AI chat tabs/pane visible;
**Settings → AI is hidden** and the route is not reachable.
- [ ] Role with **only "AI"** (`AI_SETTINGS`): Settings → AI is visible,
Overview stats load; chat nav is hidden.
- [ ] Admin (both flags): everything works as before.

## Known follow-ups (out of scope — pre-existing, shared endpoints)

These remain on `AI` because they're shared with non-settings surfaces
and need either OR-gating or a resolver split, so a role with
`AI_SETTINGS` but **not** `AI` still can't use them yet:

- `getAiSystemPromptPreview` (Models/Prompts tabs) lives in the chat
resolver, class-gated `AI`; NestJS guards are additive so it can't be
cleanly method-overridden — it should be pulled into a settings
resolver.
- Agent reads `findManyAgents` / `findOneAgent` (agent create/edit
forms) are class-gated `AI` and shared with the **Workflow** editor and
**Roles** pages; these want a guard that accepts `AI ∨ AI_SETTINGS ∨
WORKFLOWS`.
2026-06-05 08:18:20 +00:00
martmull 128d2d394d feat: allow apps to add view fields to existing views (defineViewField) (#21160)
## Summary

Lets a Twenty application add **view fields (columns) to an existing
view it does not own** — including standard views like the People index
view — without redeclaring/owning that view. This mirrors the existing,
working pattern by which an app adds a custom field to a standard object
via `defineField` + `objectUniversalIdentifier`.

The asymmetry being removed was purely in the manifest schema:
`ViewFieldManifest` only existed *nested* inside
`ViewManifest.fields[]`, so adding a view field forced declaring a
`ViewManifest` — which the sync treats as a view the app creates and
owns, and rejects when the UID is a standard view's. Validation,
persistence, the FK aggregator machinery, and uninstall cleanup were
already generic and cross-app-safe, so no engine changes were needed.

### Changes
- **twenty-shared:** new top-level `StandaloneViewFieldManifest`
(`ViewFieldManifest & { viewUniversalIdentifier }`),
`Manifest.viewFields`, and a `SyncableEntity.ViewField` member.
- **twenty-sdk:** `defineViewField` (validates `universalIdentifier` +
`viewUniversalIdentifier` + `fieldMetadataUniversalIdentifier`), CLI
manifest assembly of a top-level `viewFields` list, and `dev:add
viewField` scaffolding.
- **twenty-server:** one top-level loop over `manifest.viewFields` that
reuses the existing `fromViewFieldManifestToUniversalFlatViewField`
converter (already parameterized by `viewUniversalIdentifier`). No
validator/persistence/aggregator changes.

### Notes for maintainers
- Confirm the `Manifest.viewFields` optionality convention — implemented
as a **required** array to mirror `fields`/`views`.
- Two different apps adding a column for the same field to the same view
conflicts on the existing unique `(fieldMetadataId, viewId)` partial
index; the existing `flat-view-field-validator` duplicate check surfaces
this as a structured validation error.
- `dev:add viewField` scaffolding is included (was optional in the
plan).

## Test Plan
- [x] `twenty-shared` typecheck
- [x] `twenty-sdk` 364 unit tests + `buildManifest` assembly test
(rich-app fixture) + typecheck + prettier
- [x] `twenty-server` typecheck + `lint:diff-with-main`
- [x] **Server integration suite**
`successful-manifest-update-view-field.integration-spec.ts` (4/4):
- standalone view field attaches to the standard `allPeople` view
without recreating it (sync succeeds, no
`INVALID_VIEW_DATA`/`ENTITY_ALREADY_EXISTS`)
- uninstall removes the contributed column while the standard view + its
columns remain intact
  - duplicate `(view, field)` rejected with `METADATA_VALIDATION_FAILED`
  - unknown target view rejected
- [x] Sibling `successful-manifest-update-field.integration-spec.ts`
still green (no harness regression)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-05 08:06:30 +00:00
github-actions[bot] ff9b5a5cad chore: sync AI model catalog from models.dev (#21242)
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-06-05 09:22:38 +02:00
Ratish jain 979047d004 fix: allow email change verification on self-hosted instances (#20123)
fixes #20117 

## Technical Details
Flow after fix:
1. User submits email change request
2. user.service.ts:517-524 calls sendVerificationEmail() with
verificationTrigger: EMAIL_UPDATE
3. Guard checks: verificationTrigger === SIGN_UP → false → guard skipped
4. Verification token generated, email rendered and sent via
emailService.send()
5. User receives confirmation email at new address
6. User clicks confirmation link → email update completes
---
Impact
- Minimal change: Only 3 lines modified in a single file
- No breaking changes: Sign-up verification behavior unchanged
- Security preserved: Email changes always require verification (correct
security behavior)
- Self-hosted friendly: Instance admins can disable sign-up verification
while keeping email change verification active

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-04 18:56:15 +00:00
748 changed files with 17934 additions and 6253 deletions
+28 -3
View File
@@ -6,18 +6,43 @@ permissions:
on:
push:
tags:
- 'v*'
- 'twenty/v*'
- 'sdk/v*'
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
deploy-tag:
dispatch-tag:
timeout-minutes: 3
runs-on: ubuntu-latest
steps:
- name: Resolve dispatch event from tag family
id: target
env:
REF_NAME: ${{ github.ref_name }}
run: |
case "$REF_NAME" in
twenty/v*)
event_type=auto-deploy-twenty
;;
sdk/v*)
event_type=auto-publish-npm
;;
*)
echo "Unsupported tag '$REF_NAME', expected 'twenty/v*' or 'sdk/v*'." >&2
exit 1
;;
esac
printf 'event_type=%s\n' "$event_type" >> "$GITHUB_OUTPUT"
- name: Repository Dispatch
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
EVENT_TYPE: ${{ steps.target.outputs.event_type }}
REF_NAME: ${{ github.ref_name }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches \
-f event_type=auto-deploy-tag \
-f "event_type=$EVENT_TYPE" \
-f "client_payload[github][ref_name]=$REF_NAME"
-63
View File
@@ -1,63 +0,0 @@
name: "Release: on merge"
permissions:
contents: write
on:
pull_request:
types:
- closed
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Check PR Author
id: check_author
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail
if [[ "$PR_AUTHOR" != "github-actions[bot]" ]]; then
echo "PR author ($PR_AUTHOR) is not trusted. Exiting."
exit 1
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: main
- name: Get version from PR title
id: extract_version
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
set -euo pipefail
VERSION=$(printf '%s' "$PR_TITLE" | sed -n 's/.*Release v\([0-9][0-9.]*\).*/\1/p')
if [ -z "$VERSION" ]; then
echo "No valid version found in PR title. Exiting."
exit 1
fi
printf 'VERSION=%s\n' "$VERSION" >> "$GITHUB_ENV"
- name: Push new tag
run: |
set -euo pipefail
git config --global user.name 'Github Action Deploy'
git config --global user.email 'github-action-deploy@twenty.com'
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
- uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5
if: contains(github.event.pull_request.labels.*.name, 'create_release')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: v${{ env.VERSION }}
@@ -11,6 +11,7 @@ on:
permissions:
actions: read
contents: read
pull-requests: read
jobs:
@@ -83,6 +84,33 @@ jobs:
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}`);
- name: Compute merge-base for Argos reference
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
id: merge-base
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const headSha = context.payload.workflow_run.head_sha;
try {
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
owner: context.repo.owner,
repo: context.repo.repo,
basehead: `main...${headSha}`,
});
if (comparison.merge_base_commit?.sha) {
core.setOutput('sha', comparison.merge_base_commit.sha);
core.info(`Merge base: ${comparison.merge_base_commit.sha}`);
} else {
core.info('Could not determine merge base — will skip reference_commit');
core.setOutput('sha', '');
}
} catch (error) {
core.warning(`Failed to compute merge base: ${error instanceof Error ? error.message : String(error)}`);
core.setOutput('sha', '');
}
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
env:
@@ -92,15 +120,23 @@ jobs:
REPOSITORY: ${{ github.repository }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
COMMIT: ${{ github.event.workflow_run.head_sha }}
REFERENCE_COMMIT: ${{ steps.merge-base.outputs.sha }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
--method POST \
-f event_type=visual-regression \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
-f "client_payload[repo]=$REPOSITORY" \
-f "client_payload[branch]=$BRANCH" \
ARGS=(
--method POST
-f event_type=visual-regression
-f "client_payload[pr_number]=$PR_NUMBER"
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
-f "client_payload[repo]=$REPOSITORY"
-f "client_payload[branch]=$BRANCH"
-f "client_payload[commit]=$COMMIT"
)
if [ -n "$REFERENCE_COMMIT" ]; then
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
fi
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
dispatch-main:
if: >-
+1 -1
View File
@@ -4,7 +4,7 @@
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center">The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
+1
View File
@@ -52,6 +52,7 @@
"packages/twenty-server",
"packages/twenty-emails",
"packages/twenty-ui",
"packages/twenty-new-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.10.0",
"version": "2.11.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
universalIdentifier: 'e8031eca-d6ea-4a4b-b828-38227dba896a',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -0,0 +1,10 @@
import { defineViewField } from 'twenty-sdk/define';
import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view';
export default defineViewField({
fieldMetadataUniversalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
position: 5,
universalIdentifier: 'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54',
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
isVisible: true,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.10.0",
"version": "2.11.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1434,11 +1434,6 @@ type EnterpriseSubscriptionStatusDTO {
isCancellationScheduled: Boolean!
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
@@ -1787,6 +1782,7 @@ enum FeatureFlagKey {
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
IS_SETTINGS_DISCOVERY_HERO_ENABLED
IS_CALL_RECORDING_ENABLED
IS_WORKFLOW_RUN_STEP_LOGS_ENABLED
}
@@ -2620,6 +2616,11 @@ type SendEmailOutput {
error: String
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type EventLogRecord {
event: String!
timestamp: DateTime!
@@ -3006,7 +3007,6 @@ type Query {
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
@@ -3027,6 +3027,7 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
@@ -3199,8 +3200,6 @@ type Mutation {
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
skipBookOnboardingStep: OnboardingStepSuccess!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
@@ -3253,7 +3252,6 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3282,6 +3280,7 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -3343,6 +3342,8 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
duplicateDashboard(id: UUID!): DuplicatedDashboard!
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
@@ -3359,7 +3360,7 @@ type Mutation {
syncMarketplaceCatalog: Boolean!
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
syncApplication(manifest: JSON!): WorkspaceMigration!
syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration!
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean!
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
@@ -3715,11 +3716,6 @@ input RevokeApiKeyInput {
id: UUID!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input CreateApprovedAccessDomainInput {
domain: String!
email: String!
@@ -4429,6 +4425,11 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input SendEmailInput {
connectedAccountId: String!
to: String!
@@ -4497,6 +4498,7 @@ type Subscription {
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
onAgentChatEvent(threadId: UUID!): AgentChatEvent!
eventLogsLive(table: EventLogTable!): [EventLogRecord!]
}
input LogicFunctionLogsInput {
@@ -1091,12 +1091,6 @@ export interface EnterpriseSubscriptionStatusDTO {
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
@@ -1414,7 +1408,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_CALL_RECORDING_ENABLED' | 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -2314,6 +2308,12 @@ export interface SendEmailOutput {
__typename: 'SendEmailOutput'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface EventLogRecord {
event: Scalars['String']
timestamp: Scalars['DateTime']
@@ -2613,7 +2613,6 @@ export interface Query {
indexMetadatas: IndexConnection
findManyAgents: Agent[]
findOneAgent: Agent
myConnectedAccounts: ConnectedAccountPublicDTO[]
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
@@ -2625,6 +2624,7 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
@@ -2724,8 +2724,6 @@ export interface Mutation {
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
createObjectEvent: Analytics
trackAnalytics: Analytics
skipSyncEmailOnboardingStep: OnboardingStepSuccess
skipBookOnboardingStep: OnboardingStepSuccess
checkoutSession: BillingSession
@@ -2778,7 +2776,6 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
deleteConnectedAccount: ConnectedAccountPublicDTO
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2807,6 +2804,7 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -2868,6 +2866,8 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createObjectEvent: Analytics
trackAnalytics: Analytics
duplicateDashboard: DuplicatedDashboard
impersonate: Impersonate
sendEmail: SendEmailOutput
@@ -2892,16 +2892,17 @@ export interface Mutation {
__typename: 'Mutation'
}
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
export interface Subscription {
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
onAgentChatEvent: AgentChatEvent
eventLogsLive?: EventLogRecord[]
__typename: 'Subscription'
}
@@ -4049,13 +4050,6 @@ export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
@@ -5364,6 +5358,13 @@ export interface SendEmailOutputGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventLogRecordGenqlSelection{
event?: boolean | number
timestamp?: boolean | number
@@ -5671,7 +5672,6 @@ export interface QueryGenqlSelection{
filter: IndexFilter} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
@@ -5689,6 +5689,7 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
@@ -5811,8 +5812,6 @@ export interface MutationGenqlSelection{
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
@@ -5865,7 +5864,6 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5894,6 +5892,7 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
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)} })
@@ -5955,6 +5954,8 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
duplicateDashboard?: (DuplicatedDashboardGenqlSelection & { __args: {id: Scalars['UUID']} })
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
@@ -5972,7 +5973,7 @@ export interface MutationGenqlSelection{
syncMarketplaceCatalog?: boolean | number
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} })
uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} })
upgradeApplication?: { __args: {appRegistrationId: Scalars['String'], targetVersion: Scalars['String']} }
renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} })
@@ -6356,6 +6357,7 @@ export interface SubscriptionGenqlSelection{
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
onAgentChatEvent?: (AgentChatEventGenqlSelection & { __args: {threadId: Scalars['UUID']} })
eventLogsLive?: (EventLogRecordGenqlSelection & { __args: {table: EventLogTable} })
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -7003,14 +7005,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
@@ -8139,6 +8133,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const EventLogRecord_possibleTypes: string[] = ['EventLogRecord']
export const isEventLogRecord = (obj?: { __typename?: any } | null): obj is EventLogRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventLogRecord"')
@@ -8812,6 +8814,7 @@ export const enumFeatureFlagKey = {
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const,
IS_CALL_RECORDING_ENABLED: 'IS_CALL_RECORDING_ENABLED' as const,
IS_WORKFLOW_RUN_STEP_LOGS_ENABLED: 'IS_WORKFLOW_RUN_STEP_LOGS_ENABLED' as const
}
@@ -8989,17 +8992,17 @@ export const enumUsageOperationType = {
WEB_SEARCH: 'WEB_SEARCH' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumWorkspaceMigrationActionType = {
delete: 'delete' as const,
create: 'create' as const,
update: 'update' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumFileFolder = {
ProfilePicture: 'ProfilePicture' as const,
WorkspaceLogo: 'WorkspaceLogo' as const,
File diff suppressed because it is too large Load Diff
+3 -39
View File
@@ -6,10 +6,10 @@ services:
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
ports:
- "3000:3000"
- "3010:3000"
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
@@ -46,11 +46,6 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost:3000/healthz
interval: 5s
@@ -64,7 +59,7 @@ services:
- server-local-data:/app/packages/twenty-server/.local-storage
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
@@ -101,38 +96,7 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
server:
condition: service_healthy
restart: always
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${PG_DATABASE_NAME:-default}
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:-postgres}
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
healthcheck:
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
interval: 5s
timeout: 5s
retries: 10
restart: always
redis:
image: redis
restart: always
command: ["--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
db-data:
server-local-data:
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

@@ -283,6 +283,7 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IS_CALL_RECORDING_ENABLED = 'IS_CALL_RECORDING_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
@@ -1648,6 +1648,7 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IS_CALL_RECORDING_ENABLED = 'IS_CALL_RECORDING_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
@@ -3314,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = {
export type MutationSyncApplicationArgs = {
dryRun?: InputMaybe<Scalars['Boolean']>;
manifest: Scalars['JSON'];
};
@@ -4931,12 +4933,18 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
eventLogsLive?: Maybe<Array<EventLogRecord>>;
logicFunctionLogs: LogicFunctionLogs;
onAgentChatEvent: AgentChatEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
export type SubscriptionEventLogsLiveArgs = {
table: EventLogTable;
};
export type SubscriptionLogicFunctionLogsArgs = {
input: LogicFunctionLogsInput;
};
@@ -7402,7 +7410,14 @@ export type EventLogsQueryVariables = Exact<{
}>;
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null, isCustom?: boolean | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsLiveSubscriptionVariables = Exact<{
table: EventLogTable;
}>;
export type EventLogsLiveSubscription = { __typename?: 'Subscription', eventLogsLive?: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }> | null };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
@@ -8253,7 +8268,8 @@ export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsLiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"EventLogsLive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"table"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogTable"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogsLive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"table"},"value":{"kind":"Variable","name":{"kind":"Name","value":"table"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}}]}}]} as unknown as DocumentNode<EventLogsLiveSubscription, EventLogsLiveSubscriptionVariables>;
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
@@ -30,7 +30,7 @@ export default meta;
export type Story = StoryObj<typeof RecordIndexPage>;
export const Default: Story = {
// oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore
decorators: [LoadingDecorator, PageDecorator],
play: async ({ canvasElement }) => {
@@ -79,7 +79,7 @@ export type Story = StoryObj<typeof RecordIndexPage>;
export const Default: Story = {
parameters: userMetadataLoaderMocks,
// oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore
decorators: [PageDecorator],
play: async ({ canvasElement }) => {
@@ -690,7 +690,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={PermissionFlagType.AI}
settingsPermission={PermissionFlagType.AI_SETTINGS}
/>
}
>
@@ -8,6 +8,7 @@ import { originalDragSelectionComponentState } from '@/object-record/record-drag
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
@@ -82,8 +83,13 @@ export const RecordBoardDragDropContext = ({
}
const existingRecordSorts = store.get(currentRecordSorts);
const boardCardDropBehavior = getBoardCardDropBehavior({
hasRecordSorts: existingRecordSorts.length > 0,
sourceDroppableId: result.source.droppableId,
destinationDroppableId: result.destination.droppableId,
});
if (existingRecordSorts.length > 0) {
if (boardCardDropBehavior.shouldBlockDrop) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
@@ -91,7 +97,9 @@ export const RecordBoardDragDropContext = ({
}
try {
processBoardCardDrop(result, originalDragSelection);
processBoardCardDrop(result, originalDragSelection, {
shouldUpdatePosition: boardCardDropBehavior.shouldUpdatePosition,
});
} catch (error) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
@@ -0,0 +1,42 @@
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
describe('getBoardCardDropBehavior', () => {
it('should block same-column drops when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'new',
}),
).toEqual({
shouldBlockDrop: true,
shouldUpdatePosition: false,
});
});
it('should allow cross-column drops without position updates when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: false,
});
});
it('should allow drops with position updates when record sorting is inactive', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: false,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: true,
});
});
});
@@ -0,0 +1,17 @@
export const getBoardCardDropBehavior = ({
hasRecordSorts,
sourceDroppableId,
destinationDroppableId,
}: {
hasRecordSorts: boolean;
sourceDroppableId: string;
destinationDroppableId: string;
}) => {
const isMovingInsideSameRecordGroup =
sourceDroppableId === destinationDroppableId;
return {
shouldBlockDrop: hasRecordSorts && isMovingInsideSameRecordGroup,
shouldUpdatePosition: !hasRecordSorts,
};
};
@@ -40,9 +40,15 @@ export const useProcessBoardCardDrop = () => {
);
const processBoardCardDrop = useCallback(
(boardCardDropResult: DropResult, selectedRecordIds: string[]) => {
(
boardCardDropResult: DropResult,
selectedRecordIds: string[],
options?: { shouldUpdatePosition?: boolean },
) => {
if (!isDefined(selectFieldMetadataItem)) return;
const shouldUpdatePosition = options?.shouldUpdatePosition ?? true;
processGroupDrop({
groupDropResult: boardCardDropResult,
store,
@@ -51,7 +57,10 @@ export const useProcessBoardCardDrop = () => {
recordIndexRecordIdsByGroupCallbackFamilyState,
onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => {
updateDroppedRecordOnBoard(
{ recordId, position },
{
recordId,
position: shouldUpdatePosition ? position : undefined,
},
targetRecordGroupValue,
);
},
@@ -45,10 +45,6 @@ export const useUpdateDroppedRecordOnBoard = () => {
recordStoreFamilyState.atomFamily(recordId),
) as Record<string, unknown> | null | undefined;
if (!isDefined(newPosition)) {
return;
}
if (!isDefined(initialRecord)) {
return;
}
@@ -94,7 +90,10 @@ export const useUpdateDroppedRecordOnBoard = () => {
const isSamePosition = initialRecord.position === newPosition;
if (movingInsideSameRecordGroup && isSamePosition) {
if (
movingInsideSameRecordGroup &&
(!isDefined(newPosition) || isSamePosition)
) {
return;
}
@@ -126,25 +125,32 @@ export const useUpdateDroppedRecordOnBoard = () => {
);
}
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
if (isDefined(newPosition)) {
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
} else {
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
[...currentRecordIdsInTargetRecordGroup, recordId],
);
}
upsertRecordsInStore({
partialRecords: [
@@ -155,7 +161,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
(initialRecord as { __typename?: string })?.__typename ??
'Record',
[selectFieldMetadataItem.name]: targetRecordGroupValue,
position: newPosition,
...(isDefined(newPosition) && { position: newPosition }),
} as ObjectRecord,
],
});
@@ -164,7 +170,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
idToUpdate: recordId,
updateOneRecordInput: {
[selectFieldMetadataItem.name]: targetRecordGroupValue,
position: newPosition,
...(isDefined(newPosition) && { position: newPosition }),
},
});
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

@@ -1,4 +1 @@
export {
SettingsDatePickerInput as EventLogDatePickerInput,
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
} from '@/settings/components/SettingsDatePickerInput';
export { SettingsDatePickerInput as EventLogDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
@@ -12,18 +12,16 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import {
type EventLogRecord,
EventLogTable,
type EventLogTable,
} from '~/generated-metadata/graphql';
import {
type ColumnConfig,
getColumnsForEventLogTable,
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
type EventLogResultsTableProps = {
records: EventLogRecord[];
@@ -106,9 +104,6 @@ export const EventLogResultsTable = ({
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const showApplicationLogColumns =
selectedTable === EventLogTable.APPLICATION_LOG;
const baseColumns = getColumnsForEventLogTable(selectedTable);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
@@ -117,7 +112,6 @@ export const EventLogResultsTable = ({
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
// Reset column widths when switching tables to avoid undefined widths for new columns
useEffect(() => {
setColumnWidths(
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
@@ -242,85 +236,21 @@ export const EventLogResultsTable = ({
</StyledResizableHeaderContainer>
))}
</TableRow>
{records.map((record, index) => (
{records.map((record) => (
<TableRow
key={`${record.timestamp}-${record.event}-${index}`}
key={`${record.timestamp}-${record.event}`}
gridTemplateColumns={gridTemplateColumns}
>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.event}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{beautifyPastDateRelativeToNow(record.timestamp)}
</TableCell>
{showApplicationLogColumns ? (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.level ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.message ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.executionId ?? '-'}
</TableCell>
</>
) : (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.userId ?? '-'}
</TableCell>
{showObjectEventColumns && (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.recordId ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.objectMetadataId ?? '-'}
</TableCell>
</>
)}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<EventLogJsonCell value={record.properties} />
</TableCell>
</>
)}
{baseColumns.map((column) => (
<TableCell
key={column.id}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{column.renderCell(record)}
</TableCell>
))}
</TableRow>
))}
</Table>
@@ -1,3 +1,5 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Select } from '@/ui/input/components/Select';
@@ -8,34 +10,26 @@ type EventLogTableSelectorProps = {
onChange: (value: EventLogTable) => void;
};
const TABLE_LABELS: Record<EventLogTable, MessageDescriptor> = {
[EventLogTable.PAGEVIEW]: msg`Page Views`,
[EventLogTable.WORKSPACE_EVENT]: msg`Workspace Events`,
[EventLogTable.OBJECT_EVENT]: msg`Object Events`,
[EventLogTable.USAGE_EVENT]: msg`Usage Events`,
[EventLogTable.APPLICATION_LOG]: msg`Application Logs`,
};
export const EventLogTableSelector = ({
value,
onChange,
}: EventLogTableSelectorProps) => {
const { t } = useLingui();
const options = [
{
value: EventLogTable.PAGEVIEW,
label: t`Page Views`,
},
{
value: EventLogTable.WORKSPACE_EVENT,
label: t`Workspace Events`,
},
{
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.USAGE_EVENT,
label: t`Usage Events`,
},
{
value: EventLogTable.APPLICATION_LOG,
label: t`Application Logs`,
},
];
const options = (
Object.entries(TABLE_LABELS) as [EventLogTable, MessageDescriptor][]
).map(([table, label]) => ({
value: table,
label: t(label),
}));
return (
<Select
@@ -1,24 +1,51 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
import { useEventLogsLiveStream } from '@/settings/event-logs/hooks/useEventLogsLiveStream';
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
IconArrowUp,
IconLock,
IconPlayerPause,
IconPlayerPlay,
} from 'twenty-ui/display';
import { Button, IconButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EventLogTable } from '~/generated-metadata/graphql';
import {
BillingEntitlementKey,
EventLogTable,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const StyledRoot = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
margin: 0 auto;
max-width: 760px;
min-height: 0;
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledCardContent = styled.div`
display: flex;
@@ -40,8 +67,10 @@ const StyledSelectorGrow = styled.div`
const StyledResults = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-height: 0;
`;
const StyledRecordCount = styled.span`
@@ -50,10 +79,9 @@ const StyledRecordCount = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
// The results table scrolls internally and loads more as you reach the bottom,
// so it needs a bounded height.
const StyledTableWrapper = styled.div`
height: 480px;
flex: 1;
min-height: 0;
overflow: hidden;
`;
@@ -64,45 +92,64 @@ export const SettingsLogs = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const billing = useAtomStateValue(billingState);
const navigateSettings = useNavigateSettings();
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasAuditLogsEntitlement =
currentWorkspace?.billingEntitlements?.some(
(entitlement) =>
entitlement.key === BillingEntitlementKey.AUDIT_LOGS &&
entitlement.value,
) === true;
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
EventLogTable.PAGEVIEW,
);
const [filters, setFilters] = useState<EventLogFiltersState>({});
const [isPaused, setIsPaused] = useState(false);
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
const canQuery =
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
isClickHouseConfigured && (isApplicationLog || hasAuditLogsEntitlement);
const {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
} = useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
const { records, totalCount, hasNextPage, loading, error, loadMore } =
useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
first: RECORDS_PER_PAGE,
},
first: RECORDS_PER_PAGE,
},
{ skip: !canQuery },
{ skip: !canQuery },
);
const hasActiveFilters =
isDefined(filters.eventType) ||
isDefined(filters.userWorkspaceId) ||
isDefined(filters.recordId) ||
isDefined(filters.objectMetadataId) ||
isDefined(filters.dateRange?.start) ||
isDefined(filters.dateRange?.end);
const liveRecords = useEventLogsLiveStream({
table: selectedTable,
enabled: !isPaused && !hasActiveFilters && canQuery,
});
const displayedRecords = useMemo(
() => [...liveRecords, ...records],
[liveRecords, records],
);
const handleTableChange = (table: EventLogTable) => {
@@ -114,39 +161,63 @@ export const SettingsLogs = () => {
setFilters(newFilters);
};
const renderUpgradeCard = () => (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Upgrade to access audit logs`}
description={t`Only application logs are available on your current plan. Other log types require an Enterprise subscription.`}
Button={
<Button
title={t`Upgrade`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
: SettingsPath.AdminPanelEnterprise,
)
}
/>
}
/>
</Card>
);
const renderResults = () => {
if (!isApplicationLog && !hasEnterpriseAccess) {
return (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access this log type.`}
buttonTitle={t`Activate`}
/>
);
if (!isApplicationLog && !hasAuditLogsEntitlement) {
return renderUpgradeCard();
}
if (!isClickHouseConfigured) {
return (
<SettingsEmptyPlaceholder>
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
{t`Logs require ClickHouse to be configured. Please contact your administrator.`}
</SettingsEmptyPlaceholder>
);
}
if (isDefined(error)) {
if (isGraphqlErrorOfType(error, 'NO_ENTITLEMENT')) {
return renderUpgradeCard();
}
return (
<SettingsEmptyPlaceholder>
{t`Something went wrong while loading audit logs. Please try again.`}
{t`Something went wrong while loading logs. Please try again.`}
</SettingsEmptyPlaceholder>
);
}
return (
<StyledResults>
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
<StyledRecordCount>{t`${displayedRecords.length} of ${totalCount + liveRecords.length}`}</StyledRecordCount>
<StyledTableWrapper>
<EventLogResultsTable
records={records}
records={displayedRecords}
loading={loading}
hasNextPage={hasNextPage}
onLoadMore={loadMore}
@@ -158,7 +229,7 @@ export const SettingsLogs = () => {
};
return (
<>
<StyledRoot>
<Card rounded fullWidth>
<StyledCardContent>
<StyledSelectorRow>
@@ -168,17 +239,15 @@ export const SettingsLogs = () => {
onChange={handleTableChange}
/>
</StyledSelectorGrow>
<IconButton
Icon={IconRefresh}
variant="secondary"
size="medium"
ariaLabel={t`Refresh`}
onClick={() => {
if (canQuery) {
void refetch();
}
}}
/>
{canQuery && (
<IconButton
Icon={isPaused ? IconPlayerPlay : IconPlayerPause}
variant="secondary"
size="medium"
ariaLabel={isPaused ? t`Resume` : t`Pause`}
onClick={() => setIsPaused((previous) => !previous)}
/>
)}
</StyledSelectorRow>
<EventLogFilters
table={selectedTable}
@@ -189,6 +258,6 @@ export const SettingsLogs = () => {
</Card>
{renderResults()}
</>
</StyledRoot>
);
};
@@ -10,7 +10,6 @@ export const GET_EVENT_LOGS = gql`
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const EVENT_LOGS_LIVE_SUBSCRIPTION = gql`
subscription EventLogsLive($table: EventLogTable!) {
eventLogsLive(table: $table) {
event
timestamp
userId
properties
recordId
objectMetadataId
}
}
`;
@@ -0,0 +1,68 @@
import { print, type ExecutionResult } from 'graphql';
import { useEffect, useState } from 'react';
import { EVENT_LOGS_LIVE_SUBSCRIPTION } from '@/settings/event-logs/graphql/subscriptions/EventLogsLiveSubscription';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { captureException } from '@sentry/react';
import { isDefined } from 'twenty-shared/utils';
import {
type EventLogRecord,
type EventLogTable,
} from '~/generated-metadata/graphql';
type EventLogsLivePayload = {
eventLogsLive: EventLogRecord[] | null;
};
const EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY = print(EVENT_LOGS_LIVE_SUBSCRIPTION);
export const useEventLogsLiveStream = ({
table,
enabled,
}: {
table: EventLogTable;
enabled: boolean;
}): EventLogRecord[] => {
const sseClient = useAtomStateValue(sseClientState);
const [liveRecords, setLiveRecords] = useState<EventLogRecord[]>([]);
useEffect(() => {
setLiveRecords([]);
}, [table]);
useEffect(() => {
if (!enabled) {
setLiveRecords([]);
}
}, [enabled]);
useEffect(() => {
if (!enabled || !isDefined(sseClient)) {
return;
}
const dispose = sseClient.subscribe<EventLogsLivePayload>(
{
query: EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY,
variables: { table },
},
{
next: (value: ExecutionResult<EventLogsLivePayload>) => {
const incoming = value.data?.eventLogsLive;
if (isDefined(incoming) && incoming.length > 0) {
setLiveRecords((previous) => [...incoming, ...previous]);
}
},
error: (error) => captureException(error),
complete: () => {},
},
);
return () => dispose();
}, [enabled, sseClient, table]);
return liveRecords;
};
@@ -20,7 +20,7 @@ export const useEventLogs = (
input: EventLogQueryInput,
options?: { skip?: boolean },
) => {
const { data, loading, error, refetch, fetchMore } = useQuery<
const { data, loading, error, fetchMore } = useQuery<
EventLogsData,
EventLogsVariables
>(GET_EVENT_LOGS, {
@@ -70,7 +70,6 @@ export const useEventLogs = (
hasNextPage,
loading,
error,
refetch,
loadMore,
};
};
@@ -1,121 +0,0 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogTable } from '~/generated-metadata/graphql';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 150 },
{
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
},
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 130,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'properties',
label: msg`Properties`,
minWidth: 150,
defaultWidth: 300,
},
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Resource Type`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Function`,
minWidth: 100,
defaultWidth: 160,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'level', label: msg`Level`, minWidth: 60, defaultWidth: 80 },
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -0,0 +1,125 @@
import { type ReactNode } from 'react';
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
import {
type EventLogRecord,
EventLogTable,
} from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
renderCell: (record: EventLogRecord) => ReactNode;
};
const EVENT_COLUMN: ColumnConfig = {
id: 'event',
label: msg`Event`,
minWidth: 100,
defaultWidth: 200,
renderCell: (record) => record.event,
};
const TIMESTAMP_COLUMN: ColumnConfig = {
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => beautifyPastDateRelativeToNow(record.timestamp),
};
const USER_COLUMN: ColumnConfig = {
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => record.userId ?? '-',
};
const PROPERTIES_COLUMN: ColumnConfig = {
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => <EventLogJsonCell value={record.properties} />,
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
EVENT_COLUMN,
TIMESTAMP_COLUMN,
USER_COLUMN,
PROPERTIES_COLUMN,
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, defaultWidth: 180 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 130 },
{ ...USER_COLUMN, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.recordId ?? '-',
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.objectMetadataId ?? '-',
},
{ ...PROPERTIES_COLUMN, minWidth: 150, defaultWidth: 300 },
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Resource Type`, defaultWidth: 130 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{ ...USER_COLUMN, defaultWidth: 130 },
{ ...PROPERTIES_COLUMN, label: msg`Details` },
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Function`, defaultWidth: 160 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{
id: 'level',
label: msg`Level`,
minWidth: 60,
defaultWidth: 80,
renderCell: (record) => record.properties?.level ?? '-',
},
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => record.properties?.message ?? '-',
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
renderCell: (record) => record.properties?.executionId ?? '-',
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -171,7 +171,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`AI`,
path: SettingsPath.AI,
Icon: IconSparkles,
isHidden: !permissionMap[PermissionFlagType.AI],
isHidden: !permissionMap[PermissionFlagType.AI_SETTINGS],
},
{
label: t`Email`,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

@@ -32,7 +32,7 @@ export const StepBar = ({ activeStep, children }: StepBarProps) => {
}
// If the child is not a Step, return it as-is
// oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-expect-error
if (child.type?.displayName !== Step.displayName) {
return child;
@@ -64,7 +64,7 @@ export type Story = StoryObj<typeof RecordShowPage>;
// TEMP_DISABLED_TEST: Temporarily commented out due to test failure
// export const Default: Story = {
// // oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// // oxlint-disable-next-line typescript/ban-ts-comment
// // @ts-ignore
// decorators: [PageDecorator, ContextStoreDecorator],
// play: async ({ canvasElement }) => {
@@ -1,169 +0,0 @@
import { useContext, useState } from 'react';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { CodeEditor, IconButton } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
`;
const StyledConfigButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
position: absolute;
right: ${themeCssVariables.spacing[3]};
top: ${themeCssVariables.spacing[3]};
z-index: 1;
`;
const StyledCoverCardContent = styled(CardContent)`
padding: 0;
`;
const StyledCopyButton = styled(IconButton)`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
`;
const StyledEditorContainer = styled.div`
.monaco-editor,
.monaco-editor .overflow-guard {
background-color: transparent !important;
border: none !important;
}
.monaco-editor .line-hover {
background-color: transparent !important;
}
`;
type McpAuthMethod = 'oauth' | 'api-key';
export const SettingsAiMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [authMethod, setAuthMethod] = useState<McpAuthMethod>('oauth');
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-mcp-cover-light.svg'
: '/images/ai/ai-mcp-cover-dark.svg';
const oauthConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
},
},
},
null,
2,
);
const apiKeyConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
},
null,
2,
);
const isOAuth = authMethod === 'oauth';
const activeConfig = isOAuth ? oauthConfig : apiKeyConfig;
const editorHeight = isOAuth ? 170 : 230;
const codeEditorOptions = {
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none' as const,
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off' as const,
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off' as const,
scrollBeyondLastLine: false,
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
};
return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<Card rounded>
<StyledCoverCardContent divider>
<StyledCoverImage
style={{ backgroundImage: `url('${coverImage}')` }}
/>
</StyledCoverCardContent>
<StyledCoverCardContent>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledConfigButtonsContainer>
<Select
dropdownId="mcp-auth-method-select"
value={authMethod}
onChange={(value) => setAuthMethod(value as McpAuthMethod)}
options={[
{ label: t`OAuth`, value: 'oauth' },
{ label: t`API Key`, value: 'api-key' },
]}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownOffset={{ x: 0, y: 4 }}
/>
<StyledCopyButton
Icon={IconCopy}
onClick={() => {
copyToClipboard(
activeConfig,
t`MCP Configuration copied to clipboard`,
);
}}
size="small"
/>
</StyledConfigButtonsContainer>
<CodeEditor
value={activeConfig}
language="json"
options={codeEditorOptions}
height={editorHeight}
/>
</StyledEditorContainer>
</StyledCoverCardContent>
</Card>
</Section>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 92 KiB

@@ -50,10 +50,6 @@ export const SettingsGeneral = () => {
);
const renderActiveTabContent = () => {
if (activeTabId === GENERAL_TAB_LOGS) {
return <SettingsLogs />;
}
if (activeTabId === GENERAL_TAB_SECURITY) {
return <SettingsSecuritySettings />;
}
@@ -97,7 +93,13 @@ export const SettingsGeneral = () => {
}
links={[{ children: t`Workspace` }, { children: t`General` }]}
>
<SettingsPageContainer>{renderActiveTabContent()}</SettingsPageContainer>
{activeTabId === GENERAL_TAB_LOGS ? (
<SettingsLogs />
) : (
<SettingsPageContainer>
{renderActiveTabContent()}
</SettingsPageContainer>
)}
</SettingsPageLayout>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,4 +1,4 @@
// oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore
import { type WorkflowRun } from '@/workflow/types/Workflow';
import { StepStatus } from 'twenty-shared/workflow';
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"jsPlugins": ["../twenty-oxlint-rules/dist/oxlint-plugin.mjs"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "generated", "**/*.module.scss.d.ts"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": [
"warn",
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"twenty/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
}
}
+6
View File
@@ -0,0 +1,6 @@
dist
storybook-static
coverage
# Generated by vite-plugin-sass-dts (committed; regenerated in dev mode).
*.module.scss.d.ts
+78
View File
@@ -0,0 +1,78 @@
[
{
"name": ". (root barrel)",
"path": "dist/index.mjs",
"limit": "60 kB",
"import": "*"
},
{
"name": "accessibility",
"path": "dist/accessibility.mjs",
"limit": "10 kB"
},
{
"name": "assets",
"path": "dist/assets.mjs",
"limit": "10 kB"
},
{
"name": "testing",
"path": "dist/testing.mjs",
"limit": "10 kB"
},
{
"name": "components",
"path": "dist/components.mjs",
"limit": "40 kB"
},
{
"name": "display",
"path": "dist/display.mjs",
"limit": "30 kB"
},
{
"name": "feedback",
"path": "dist/feedback.mjs",
"limit": "20 kB"
},
{
"name": "input",
"path": "dist/input.mjs",
"limit": "50 kB"
},
{
"name": "layout",
"path": "dist/layout.mjs",
"limit": "20 kB"
},
{
"name": "navigation",
"path": "dist/navigation.mjs",
"limit": "20 kB"
},
{
"name": "json-visualizer",
"path": "dist/json-visualizer.mjs",
"limit": "30 kB"
},
{
"name": "theme",
"path": "dist/theme.mjs",
"limit": "55 kB"
},
{
"name": "theme-constants",
"path": "dist/theme-constants.mjs",
"limit": "20 kB"
},
{
"name": "utilities",
"path": "dist/utilities.mjs",
"limit": "10 kB"
},
{
"name": "style.css",
"path": "dist/style.css",
"limit": "40 kB"
}
]
+58
View File
@@ -0,0 +1,58 @@
import type { StorybookConfig } from '@storybook/react-vite';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import checker from 'vite-plugin-checker';
const dirname =
typeof __dirname !== 'undefined'
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
const isVitest = Boolean(process.env.VITEST);
const config: StorybookConfig = {
stories: ['../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
addons: [
'@storybook-community/storybook-addon-cookie',
'@storybook/addon-links',
'@storybook/addon-coverage',
'@storybook/addon-a11y',
'storybook-addon-pseudo-states',
'@storybook/addon-vitest',
],
framework: '@storybook/react-vite',
viteFinal: async (viteConfig) => {
const plugins = [...(viteConfig.plugins ?? [])];
if (!isVitest) {
plugins.push(
checker({
typescript: {
tsconfigPath: path.resolve(dirname, '../tsconfig.json'),
},
}),
);
}
return {
...viteConfig,
plugins,
resolve: {
...viteConfig.resolve,
alias: {
...(viteConfig.resolve?.alias ?? {}),
'@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs',
},
},
};
},
};
export default config;
// To customize your Vite configuration you can use the viteFinal field.
// Check https://storybook.js.org/docs/react/builders/vite#configuration
// and https://nx.dev/recipes/storybook/custom-builder-configs
@@ -0,0 +1,34 @@
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.7/iframeResizer.contentWindow.min.js"></script>
<style type="text/css">
body {
margin: 0;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
font-size: 13px;
}
.sbdocs-wrapper {
padding: 0 !important;
}
*::-webkit-scrollbar {
height: 4px;
width: 4px;
}
*::-webkit-scrollbar-corner {
background-color: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 2px;
}
</style>
@@ -0,0 +1,24 @@
import { type Preview } from '@storybook/react-vite';
import '@new-ui/theme-constants/theme-light.css';
import '@new-ui/theme-constants/theme-dark.css';
import { ThemeProvider } from '@new-ui/theme-constants';
const preview: Preview = {
tags: ['autodocs'],
parameters: {
a11y: {
test: 'error',
},
},
decorators: [
(Story) => {
return (
<ThemeProvider colorScheme="light">
<Story />
</ThemeProvider>
);
},
],
};
export default preview;
@@ -0,0 +1,5 @@
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// Apply Storybook's preview configuration to Vitest runs.
setProjectAnnotations([projectAnnotations]);
+13 -57
View File
@@ -1,6 +1,10 @@
# twenty-new-ui
> **Status:** Planning. This is a design document; no implementation has started.
> **Status:** Phase 0 — Foundations in progress. The package is scaffolded and builds
> on SCSS Modules + Base UI; the theme layer is ported from `twenty-ui` with a parity
> test, and the size/Storybook/a11y harnesses are wired up. Remaining Phase 0 work: the
> CI diff-table workflow, the `twenty-ui` component inventory, and the `modules/ui` triage.
> The sections below remain the design document for the full effort.
`twenty-new-ui` is the next generation of Twenty's UI library, replacing [`twenty-ui`](../twenty-ui).
It is built on a headless component library and a zero-runtime, CSS-variable styling layer.
@@ -30,8 +34,9 @@ components are now **in scope** — they migrate into `twenty-new-ui` (see [Appl
## Decision 1 — Headless library: Base UI
Adopt **Base UI** (`@base-ui-components/react`, MIT) as the behavioral foundation; build Twenty's
visual design on top of it.
Adopt **Base UI** ([`mui/base-ui`](https://github.com/mui/base-ui), published to npm as
[`@base-ui/react`](https://base-ui.com), MIT) as the behavioral foundation; build Twenty's visual
design on top of it.
| | Base UI | shadcn/ui | Radix |
| --- | --- | --- | --- |
@@ -84,7 +89,7 @@ packages/twenty-new-ui/
├── vitest.config.ts # storybook component tests
├── .storybook/
├── .size-limit.json # per-entry bundle budgets
├── scripts/ # generateBarrels.ts, generateTheme.ts
├── scripts/ # generateBarrels.ts
└── src/
├── styles/ # global: reset, theme vars, mixins, breakpoints
├── theme/ theme-constants/
@@ -106,8 +111,8 @@ as-is.
- Keep the public API identical: `ThemeProvider`, `ThemeContext`, `useTheme`, the `themeCssVariables` shape, `ThemeType`, color helpers, and the `theme-light.css` / `theme-dark.css` exports.
- Reuse `twenty-ui`'s token values verbatim to guarantee identical design.
- Tokens are authored in `src/theme/` and generated into CSS variables + a typed accessor.
- A generated-output diff test asserts the new theme CSS produces the same `--t-*` values as `twenty-ui`.
- Tokens live in `src/theme/` (`THEME_LIGHT` / `THEME_DARK`); the `--t-*` CSS variables and the `themeCssVariables` accessor are static files mirrored token-for-token from `twenty-ui` (matching `twenty-ui`'s own static-CSS approach).
- A theme parity test asserts the theme CSS and `themeCssVariables` stay identical to `twenty-ui`'s `--t-*` values.
## Component migration map
@@ -219,7 +224,7 @@ CI surfaces a per-PR diff table (`twenty-ui` vs `twenty-new-ui`) for size, a11y,
- Vite library mode, dual ESM/CJS, `vite-plugin-dts`, `vite-plugin-svgr`; SCSS via Vite's built-in `sass`; no Babel.
- `sideEffects: ["**/*.css", "**/*.scss"]`; emit per-entry CSS plus `style.css` / `theme-light.css` / `theme-dark.css`.
- Public package (remove `private`); scoped name (e.g. `@twenty/ui`).
- Public package (remove `private`); ships as `twenty-new-ui` until cut-over, then claims the `twenty-ui` name once the old package is removed.
- **Changesets** for semver + changelog; GitHub Actions release with `npm publish --provenance`.
- Declare `react` / `react-dom` as peer dependencies; validate the `exports`/types map with `publint` + `@arethetypeswrong/cli`.
- Publish the Storybook as living documentation.
@@ -259,59 +264,10 @@ a passing visual-parity diff, and a within-budget size entry.
## Open questions
1. Published package name/scope (proposed `@twenty/ui`).
1. Published package name: `twenty-new-ui` now, renamed to `twenty-ui` at cut-over (Phase 6).
2. Styling: confirm SCSS Modules vs vanilla-extract vs plain CSS Modules.
3. Variants helper: `clsx` + `data-*` vs `cva`.
4. Visual regression tooling: Chromatic vs self-hosted image snapshots.
5. How aggressively to drop `framer-motion` in favor of CSS/Base UI transitions.
6. Scope of `assets` / `testing` / `json-visualizer`: port verbatim or modernize.
7. Where to draw the generic-vs-app-specific line for `modules/ui`, and whether hybrid components live as a headless core in `twenty-new-ui` with a thin app wrapper in `twenty-front`.
## Appendix — example component pattern
`src/input/components/Toggle.tsx`
```tsx
import { Switch } from '@base-ui-components/react/switch';
import { clsx } from 'clsx';
import styles from './Toggle.module.scss';
type ToggleProps = {
value?: boolean;
onChange?: (value: boolean) => void;
disabled?: boolean;
size?: 'small' | 'medium';
};
export const Toggle = ({ value, onChange, disabled, size = 'medium' }: ToggleProps) => (
<Switch.Root
checked={value}
onCheckedChange={onChange}
disabled={disabled}
className={clsx(styles.root, styles[size])}
>
<Switch.Thumb className={styles.thumb} />
</Switch.Root>
);
```
`src/input/components/Toggle.module.scss`
```scss
.root {
background: var(--t-background-quaternary);
border-radius: var(--t-border-radius-pill);
transition: background var(--t-animation-duration-fast) ease;
&[data-checked] { background: var(--t-color-blue); }
&[data-disabled] { opacity: 0.32; cursor: not-allowed; }
}
.thumb {
background: var(--t-background-primary);
border-radius: 50%;
transition: translate var(--t-animation-duration-fast) ease;
.root[data-checked] & { translate: 100% 0; }
}
```
@@ -0,0 +1 @@
export default 'test-file-stub';
@@ -0,0 +1,7 @@
// Proxy so `styles.anyClassName` resolves to its key when SCSS is imported in Jest.
module.exports = new Proxy(
{},
{
get: (_target, key) => (key === '__esModule' ? false : String(key)),
},
);
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { pathsToModuleNameMapper } from 'ts-jest';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tsConfigPath = resolve(__dirname, './tsconfig.json');
const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
const jestConfig = {
displayName: 'twenty-new-ui',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['./setupTests.ts'],
testEnvironment: 'jsdom',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } },
},
},
],
},
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|webp|svg|svg)$': '<rootDir>/__mocks__/imageMockUi.js',
'\\.(scss|css)$': '<rootDir>/__mocks__/styleMock.js',
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageDirectory: './coverage',
};
export default jestConfig;
+208
View File
@@ -0,0 +1,208 @@
{
"name": "twenty-new-ui",
"//name": "Named `twenty-new-ui` because the target name `twenty-ui` is still taken by the existing packages/twenty-ui workspace (Yarn forbids duplicate workspace names). Renamed to `twenty-ui` at the Phase 6 cut-over once the old package is removed.",
"version": "0.0.0",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"style": "./dist/style.css",
"type": "module",
"sideEffects": [
"**/*.css",
"**/*.scss"
],
"devDependencies": {
"@argos-ci/storybook": "^6.0.6",
"@prettier/sync": "^0.5.2",
"@size-limit/preset-small-lib": "^11.1.6",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-links": "^10.3.3",
"@storybook/addon-vitest": "^10.2.13",
"@storybook/react-vite": "^10.3.3",
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/browser-playwright": "4.0.18",
"jest": "29.7.0",
"jest-environment-jsdom": "30.0.0-beta.3",
"prettier": "^3.1.1",
"sass": "^1.83.0",
"sass-embedded": "^1.83.0",
"size-limit": "^11.1.6",
"slash": "^5.1.0",
"storybook-addon-pseudo-states": "^10.3.3",
"ts-jest": "^29.1.1",
"tsx": "^4.19.3",
"vite-plugin-checker": "^0.10.2",
"vite-plugin-dts": "3.8.1",
"vite-plugin-sass-dts": "^1.3.31",
"vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "4.0.18"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/colors": "^3.0.0",
"@tabler/icons-react": "^3.31.0",
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"framer-motion": "^11.18.0",
"glob": "^11.1.0",
"hex-rgb": "^5.0.0",
"jotai": "^2.17.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"twenty-shared": "workspace:*",
"zod": "^4.1.11"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1"
},
"scripts": {
"build": "npx vite build"
},
"files": [
"dist",
"accessibility",
"assets",
"components",
"display",
"feedback",
"input",
"json-visualizer",
"layout",
"navigation",
"testing",
"theme",
"theme-constants",
"utilities"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./style.css": "./dist/style.css",
"./theme-light.css": "./dist/theme-light.css",
"./theme-dark.css": "./dist/theme-dark.css",
"./accessibility": {
"types": "./dist/accessibility/index.d.ts",
"import": "./dist/accessibility.mjs",
"require": "./dist/accessibility.cjs"
},
"./assets": {
"types": "./dist/assets/index.d.ts",
"import": "./dist/assets.mjs",
"require": "./dist/assets.cjs"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components.mjs",
"require": "./dist/components.cjs"
},
"./display": {
"types": "./dist/display/index.d.ts",
"import": "./dist/display.mjs",
"require": "./dist/display.cjs"
},
"./feedback": {
"types": "./dist/feedback/index.d.ts",
"import": "./dist/feedback.mjs",
"require": "./dist/feedback.cjs"
},
"./input": {
"types": "./dist/input/index.d.ts",
"import": "./dist/input.mjs",
"require": "./dist/input.cjs"
},
"./json-visualizer": {
"types": "./dist/json-visualizer/index.d.ts",
"import": "./dist/json-visualizer.mjs",
"require": "./dist/json-visualizer.cjs"
},
"./layout": {
"types": "./dist/layout/index.d.ts",
"import": "./dist/layout.mjs",
"require": "./dist/layout.cjs"
},
"./navigation": {
"types": "./dist/navigation/index.d.ts",
"import": "./dist/navigation.mjs",
"require": "./dist/navigation.cjs"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"import": "./dist/testing.mjs",
"require": "./dist/testing.cjs"
},
"./theme": {
"types": "./dist/theme/index.d.ts",
"import": "./dist/theme.mjs",
"require": "./dist/theme.cjs"
},
"./theme-constants": {
"types": "./dist/theme-constants/index.d.ts",
"import": "./dist/theme-constants.mjs",
"require": "./dist/theme-constants.cjs"
},
"./utilities": {
"types": "./dist/utilities/index.d.ts",
"import": "./dist/utilities.mjs",
"require": "./dist/utilities.cjs"
}
},
"typesVersions": {
"*": {
"accessibility": [
"dist/accessibility/index.d.ts"
],
"assets": [
"dist/assets/index.d.ts"
],
"components": [
"dist/components/index.d.ts"
],
"display": [
"dist/display/index.d.ts"
],
"feedback": [
"dist/feedback/index.d.ts"
],
"input": [
"dist/input/index.d.ts"
],
"json-visualizer": [
"dist/json-visualizer/index.d.ts"
],
"layout": [
"dist/layout/index.d.ts"
],
"navigation": [
"dist/navigation/index.d.ts"
],
"testing": [
"dist/testing/index.d.ts"
],
"theme": [
"dist/theme/index.d.ts"
],
"theme-constants": [
"dist/theme-constants/index.d.ts"
],
"utilities": [
"dist/utilities/index.d.ts"
]
}
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"name": "twenty-new-ui",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-new-ui/src",
"projectType": "library",
"tags": ["scope:shared"],
"targets": {
"build": {
"dependsOn": ["^build"],
"outputs": [
"{projectRoot}/dist",
"{projectRoot}/accessibility/package.json",
"{projectRoot}/accessibility/dist",
"{projectRoot}/assets/package.json",
"{projectRoot}/assets/dist",
"{projectRoot}/components/package.json",
"{projectRoot}/components/dist",
"{projectRoot}/display/package.json",
"{projectRoot}/display/dist",
"{projectRoot}/feedback/package.json",
"{projectRoot}/feedback/dist",
"{projectRoot}/input/package.json",
"{projectRoot}/input/dist",
"{projectRoot}/json-visualizer/package.json",
"{projectRoot}/json-visualizer/dist",
"{projectRoot}/layout/package.json",
"{projectRoot}/layout/dist",
"{projectRoot}/navigation/package.json",
"{projectRoot}/navigation/dist",
"{projectRoot}/testing/package.json",
"{projectRoot}/testing/dist",
"{projectRoot}/theme/package.json",
"{projectRoot}/theme/dist",
"{projectRoot}/theme-constants/package.json",
"{projectRoot}/theme-constants/dist",
"{projectRoot}/utilities/package.json",
"{projectRoot}/utilities/dist"
]
},
"generateBarrels": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "{projectRoot}/scripts/generateBarrels.ts"],
"outputs": [
"{projectRoot}/src/**/*/index.ts",
"{projectRoot}/package.json"
],
"options": { "command": "tsx {projectRoot}/scripts/generateBarrels.ts" }
},
"build:individual": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist/individual"],
"options": {
"cwd": "{projectRoot}",
"command": "npx vite build -c vite.config.individual.ts"
}
},
"size": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["build"],
"inputs": ["{projectRoot}/dist", "{projectRoot}/.size-limit.json"],
"options": {
"cwd": "{projectRoot}",
"command": "npx size-limit"
},
"configurations": {
"why": { "command": "npx size-limit --why" }
}
},
"clean": {
"executor": "nx:run-commands",
"options": { "command": "rimraf {projectRoot}/dist" }
},
"lint": {},
"fmt": { "options": { "files": "src" }, "configurations": { "fix": {} } },
"test": {},
"typecheck": {},
"storybook:build": { "configurations": { "test": {} } },
"storybook:serve:dev": { "options": { "port": 6008 } },
"storybook:serve:static": {
"options": {
"buildTarget": "twenty-new-ui:storybook:build",
"port": 6008
},
"configurations": { "test": {} }
},
"storybook:test": {},
"storybook:test:no-coverage": {},
"storybook:visual-diff": {
"executor": "nx:run-commands",
"dependsOn": ["storybook:build"],
"options": {
"cwd": "{projectRoot}",
"command": "bash scripts/visual-diff.sh"
}
},
"storybook:coverage": {}
}
}
@@ -0,0 +1,505 @@
import prettier from '@prettier/sync';
import * as fs from 'fs';
import { globSync } from 'glob';
import path from 'path';
import { type Options } from 'prettier';
import slash from 'slash';
import ts from 'typescript';
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
const INDEX_FILENAME = 'index';
const PACKAGE_JSON_FILENAME = 'package.json';
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
const PACKAGE_PATH = path.resolve('packages/twenty-ui');
const SRC_PATH = path.resolve(`${PACKAGE_PATH}/src`);
const PACKAGE_JSON_PATH = path.join(PACKAGE_PATH, PACKAGE_JSON_FILENAME);
const NX_PROJECT_CONFIGURATION_PATH = path.join(
PACKAGE_PATH,
NX_PROJECT_CONFIGURATION_FILENAME,
);
const prettierConfigFile = prettier.resolveConfigFile();
if (prettierConfigFile == null) {
throw new Error('Prettier config file not found');
}
const prettierConfiguration = prettier.resolveConfig(prettierConfigFile);
const prettierFormat = (str: string, parser: Options['parser']) =>
prettier.format(str, {
...prettierConfiguration,
parser,
});
type createTypeScriptFileArgs = {
path: string;
content: string;
filename: string;
};
const createTypeScriptFile = ({
content,
path: filePath,
filename,
}: createTypeScriptFileArgs) => {
const header = `
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, |
* |___/
*/
`;
const formattedContent = prettierFormat(
`${header}\n${content}\n`,
'typescript',
);
fs.writeFileSync(
path.join(filePath, `${filename}.ts`),
formattedContent,
'utf-8',
);
};
const getLastPathFolder = (pathStr: string) => path.basename(pathStr);
const getSubDirectoryPaths = (directoryPath: string): string[] => {
const pattern = slash(path.join(directoryPath, '*/'));
return globSync(pattern, {
ignore: [...EXCLUDED_DIRECTORIES],
cwd: SRC_PATH,
nodir: false,
maxDepth: 1,
}).sort((a, b) => a.localeCompare(b));
};
const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => {
return declarations.reduce<{
typeAndInterfaceDeclarations: DeclarationOccurrence[];
otherDeclarations: DeclarationOccurrence[];
}>(
(acc, { kind, name }) => {
if (kind === 'type' || kind === 'interface') {
return {
...acc,
typeAndInterfaceDeclarations: [
...acc.typeAndInterfaceDeclarations,
{ kind, name },
],
};
}
return {
...acc,
otherDeclarations: [...acc.otherDeclarations, { kind, name }],
};
},
{
typeAndInterfaceDeclarations: [],
otherDeclarations: [],
},
);
};
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
return exportByBarrel.map<createTypeScriptFileArgs>(
({ barrel: { moduleDirectory }, allFileExports }) => {
const content = allFileExports
.sort((a, b) => a.file.localeCompare(b.file))
.map(({ exports, file }) => {
const { otherDeclarations, typeAndInterfaceDeclarations } =
partitionFileExportsByType(exports);
const fileWithoutExtension = path.parse(file).name;
const pathToImport = slash(
path.relative(
moduleDirectory,
path.join(path.dirname(file), fileWithoutExtension),
),
);
const mapDeclarationNameAndJoin = (
declarations: DeclarationOccurrence[],
) => declarations.map(({ name }) => name).join(', ');
const typeExport =
typeAndInterfaceDeclarations.length > 0
? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"`
: '';
const othersExport =
otherDeclarations.length > 0
? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"`
: '';
return [typeExport, othersExport]
.filter((el) => el !== '')
.join('\n');
})
.join('\n');
return {
content,
path: moduleDirectory,
filename: INDEX_FILENAME,
};
},
);
};
type JsonUpdate = Record<string, any>;
type WriteInJsonFileArgs = {
content: JsonUpdate;
file: string;
};
const updateJsonFile = ({ content, file }: WriteInJsonFileArgs) => {
const updatedJsonFile = JSON.stringify(content);
const formattedContent = prettier.format(updatedJsonFile, {
...prettierConfiguration,
filepath: file,
});
fs.writeFileSync(file, formattedContent, 'utf-8');
};
const writeInPackageJson = (update: JsonUpdate) => {
const rawJsonFile = fs.readFileSync(PACKAGE_JSON_PATH, 'utf-8');
const initialJsonFile = JSON.parse(rawJsonFile);
updateJsonFile({
file: PACKAGE_JSON_PATH,
content: {
...initialJsonFile,
...update,
},
});
};
const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => {
const rawJsonFile = fs.readFileSync(NX_PROJECT_CONFIGURATION_PATH, 'utf-8');
const initialJsonFile = JSON.parse(rawJsonFile);
updateJsonFile({
file: NX_PROJECT_CONFIGURATION_PATH,
content: {
...initialJsonFile,
targets: {
...initialJsonFile.targets,
build: {
...initialJsonFile.targets.build,
outputs,
},
},
},
});
};
type ExportOccurrence = {
types: string;
import: string;
require: string;
};
type ExportsConfig = Record<string, ExportOccurrence | string>;
const generateModulePackageExports = (moduleDirectories: string[]) => {
return moduleDirectories.reduce<ExportsConfig>(
(acc, moduleDirectory) => {
const moduleName = getLastPathFolder(moduleDirectory);
if (moduleName === undefined) {
throw new Error(
`Should never occur, moduleName is undefined ${moduleDirectory}`,
);
}
return {
...acc,
[`./${moduleName}`]: {
types: `./dist/${moduleName}/index.d.ts`,
import: `./dist/${moduleName}.mjs`,
require: `./dist/${moduleName}.cjs`,
},
};
},
{
'./style.css': './dist/style.css',
'./theme-light.css': './dist/theme-light.css',
'./theme-dark.css': './dist/theme-dark.css',
},
);
};
const computePackageJsonFilesAndExportsConfig = (
moduleDirectories: string[],
) => {
const entrypoints = moduleDirectories.map(getLastPathFolder);
const exports = {
'.': {
types: './dist/index.d.ts',
import: './dist/index.mjs',
require: './dist/index.cjs',
},
...generateModulePackageExports(moduleDirectories),
} satisfies ExportsConfig;
const typesVersionsEntries = entrypoints.reduce<Record<string, string[]>>(
(acc, moduleName) => ({
...acc,
[`${moduleName}`]: [`dist/${moduleName}/index.d.ts`],
}),
{},
);
return {
exports,
typesVersions: { '*': typesVersionsEntries },
files: ['dist', ...entrypoints],
};
};
const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => {
const dynamicOutputsPath = moduleDirectories
.map(getLastPathFolder)
.flatMap((barrelName) =>
['package.json', 'dist'].map(
(subPath) => `{projectRoot}/${barrelName}/${subPath}`,
),
);
return ['{projectRoot}/dist', ...dynamicOutputsPath];
};
const EXCLUDED_EXTENSIONS = [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.ts',
'**/*.stories.tsx',
] as const;
const EXCLUDED_DIRECTORIES = [
'**/__tests__/**',
'**/__mocks__/**',
'**/__stories__/**',
'**/internal/**',
] as const;
function getTypeScriptFiles(
directoryPath: string,
includeIndex: boolean = false,
): string[] {
const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}'));
const files = globSync(pattern, {
cwd: SRC_PATH,
nodir: true,
ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES],
});
return files.filter(
(file) =>
!file.endsWith('.d.ts') &&
(includeIndex ? true : !file.endsWith('index.ts')),
);
}
const getKind = (
node: ts.VariableStatement,
): Extract<ExportKind, 'const' | 'let' | 'var'> => {
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
if (isConst) {
return 'const';
}
const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0;
if (isLet) {
return 'let';
}
return 'var';
};
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
const exports: DeclarationOccurrence[] = [];
function visit(node: ts.Node) {
if (!ts.canHaveModifiers(node)) {
return ts.forEachChild(node, visit);
}
const modifiers = ts.getModifiers(node);
const isExport = modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExport && !ts.isExportDeclaration(node)) {
return ts.forEachChild(node, visit);
}
switch (true) {
case ts.isTypeAliasDeclaration(node):
exports.push({
kind: 'type',
name: node.name.text,
});
break;
case ts.isInterfaceDeclaration(node):
exports.push({
kind: 'interface',
name: node.name.text,
});
break;
case ts.isEnumDeclaration(node):
exports.push({
kind: 'enum',
name: node.name.text,
});
break;
case ts.isFunctionDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'function',
name: node.name.text,
});
break;
case ts.isVariableStatement(node):
node.declarationList.declarations.forEach((decl) => {
const kind = getKind(node);
if (ts.isIdentifier(decl.name)) {
exports.push({
kind,
name: decl.name.text,
});
} else if (ts.isObjectBindingPattern(decl.name)) {
decl.name.elements.forEach((element) => {
if (
!ts.isBindingElement(element) ||
!ts.isIdentifier(element.name)
) {
return;
}
exports.push({
kind,
name: element.name.text,
});
});
}
});
break;
case ts.isClassDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'class',
name: node.name.text,
});
break;
case ts.isExportDeclaration(node):
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach((element) => {
const exportName = element.name.text;
// Check both the declaration and the individual specifier for type-only exports
const isTypeExport =
node.isTypeOnly || ts.isTypeOnlyExportDeclaration(node);
if (isTypeExport) {
// should handle kind
exports.push({
kind: 'type',
name: exportName,
});
return;
}
exports.push({
kind: 'const',
name: exportName,
});
});
}
break;
}
return ts.forEachChild(node, visit);
}
visit(sourceFile);
return exports;
}
type ExportKind =
| 'type'
| 'interface'
| 'enum'
| 'function'
| 'const'
| 'let'
| 'var'
| 'class';
type DeclarationOccurrence = { kind: ExportKind; name: string };
type FileExports = Array<{
file: string;
exports: DeclarationOccurrence[];
}>;
function findAllExports(directoryPath: string): FileExports {
const results: FileExports = [];
const files = getTypeScriptFiles(directoryPath);
for (const file of files) {
const sourceFile = ts.createSourceFile(
file,
fs.readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const exports = extractExportsFromSourceFile(sourceFile);
if (exports.length > 0) {
results.push({
file,
exports,
});
}
}
return results;
}
type ExportByBarrel = {
barrel: {
moduleName: string;
moduleDirectory: string;
};
allFileExports: FileExports;
};
const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
return barrelDirectories.map<ExportByBarrel>((moduleDirectory) => {
const moduleExportsPerFile = findAllExports(moduleDirectory);
const moduleName = getLastPathFolder(moduleDirectory);
if (!moduleName) {
throw new Error(
`Should never occur moduleName not found ${moduleDirectory}`,
);
}
return {
barrel: {
moduleName,
moduleDirectory,
},
allFileExports: moduleExportsPerFile,
};
});
};
const main = () => {
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories);
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
const packageJsonConfig =
computePackageJsonFilesAndExportsConfig(moduleDirectories);
const nxBuildOutputsPath =
computeProjectNxBuildOutputsPath(moduleDirectories);
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
writeInPackageJson(packageJsonConfig);
moduleIndexFiles.forEach(createTypeScriptFile);
};
main();
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
ARGOS_API_BASE_URL="${ARGOS_API_BASE_URL:-http://127.0.0.1:4002/v2/}"
ARGOS_TOKEN="${ARGOS_TOKEN:?ARGOS_TOKEN is required set it in packages/twenty-new-ui/.env}"
USERNAME=$(whoami)
GIT_BRANCH="${ARGOS_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")}"
COMMIT="${ARGOS_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")}"
REFERENCE_COMMIT="${ARGOS_REFERENCE_COMMIT:-$(git merge-base HEAD main 2>/dev/null || echo "")}"
export ARGOS_API_BASE_URL
export ARGOS_TOKEN
export ARGOS_BUILD_NAME="${USERNAME}/twenty-new-ui"
export ARGOS_BRANCH="${USERNAME}/${GIT_BRANCH}"
export ARGOS_COMMIT="$COMMIT"
export ARGOS_REFERENCE_COMMIT="${REFERENCE_COMMIT}"
echo "Argos visual diff"
echo " API: $ARGOS_API_BASE_URL"
echo " Build name: $ARGOS_BUILD_NAME"
echo " Branch: $ARGOS_BRANCH"
echo " Commit: ${ARGOS_COMMIT:0:12}"
echo " Ref commit: ${ARGOS_REFERENCE_COMMIT:0:12}"
echo ""
npx http-server storybook-static --port 6007 --silent &
HTTP_PID=$!
trap "kill $HTTP_PID 2>/dev/null || true" EXIT
SERVER_UP=false
for i in $(seq 1 30); do
if curl -sf http://localhost:6007 > /dev/null 2>&1; then SERVER_UP=true; break; fi
sleep 1
done
if [[ "$SERVER_UP" != "true" ]]; then
echo "Storybook static server did not start on http://localhost:6007 after 30s" >&2
exit 1
fi
export STORYBOOK_URL="http://localhost:6007"
npx vitest run
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
+7
View File
@@ -0,0 +1,7 @@
declare global {
interface Window {
_env_?: Record<string, string>;
}
}
export {};
+4
View File
@@ -0,0 +1,4 @@
// Side-effect import: the reset must ship exactly once in the aggregated style.css.
import './styles/base/reset.scss';
export {};
@@ -0,0 +1,15 @@
// Entry point for the individual/self-contained build (vite.config.individual.ts).
// Re-exports all public modules so a single bundle contains every component with
// internal deps bundled, while React stays external for the consumer's bundler.
export * from './accessibility';
export * from './components';
export * from './display';
export * from './feedback';
export * from './input';
export * from './json-visualizer';
export * from './layout';
export * from './navigation';
export * from './theme';
export * from './theme-constants';
export * from './utilities';
@@ -0,0 +1,64 @@
.root {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
align-self: flex-start;
border-radius: 10px;
background-color: var(--t-background-transparent-medium);
cursor: pointer;
transition: background-color duration(normal) ease;
&[data-checked] {
background-color: var(--toggle-on-color, var(--t-color-blue));
}
&[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
}
.centered {
align-self: center;
}
.small {
width: 24px;
height: 16px;
}
.medium {
width: 32px;
height: 20px;
}
.thumb {
position: absolute;
top: 50%;
left: 0;
display: block;
border-radius: 50%;
background-color: var(--t-background-primary);
transition: transform duration(normal) ease;
}
.small .thumb {
width: 12px;
height: 12px;
transform: translate(2px, -50%);
}
.medium .thumb {
width: 16px;
height: 16px;
transform: translate(2px, -50%);
}
.small[data-checked] .thumb {
transform: translate(10px, -50%);
}
.medium[data-checked] .thumb {
transform: translate(14px, -50%);
}
@@ -0,0 +1,8 @@
declare const classNames: {
readonly root: 'root';
readonly centered: 'centered';
readonly small: 'small';
readonly medium: 'medium';
readonly thumb: 'thumb';
};
export default classNames;
@@ -0,0 +1,50 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { ThemeProvider } from '@new-ui/theme-constants';
import { Toggle } from './Toggle';
const meta: Meta<typeof Toggle> = {
title: 'Input/Toggle',
component: Toggle,
args: { toggleSize: 'medium', 'aria-label': 'Example toggle' },
};
export default meta;
type Story = StoryObj<typeof Toggle>;
export const On: Story = { args: { value: true } };
export const Off: Story = { args: { value: false } };
export const Small: Story = { args: { value: true, toggleSize: 'small' } };
export const CustomColor: Story = {
args: { value: true, color: 'color(display-p3 0.2 0.7 0.4)' },
};
export const Disabled: Story = { args: { value: true, disabled: true } };
const ControlledToggle = () => {
const [value, setValue] = useState(false);
return (
<Toggle value={value} onChange={setValue} aria-label="Example toggle" />
);
};
export const Interactive: Story = {
render: () => <ControlledToggle />,
};
export const Dark: Story = {
args: { value: true },
decorators: [
(Story) => (
<ThemeProvider colorScheme="dark">
<Story />
</ThemeProvider>
),
],
};
@@ -0,0 +1,54 @@
import { Switch } from '@base-ui/react/switch';
import { clsx } from 'clsx';
import styles from './Toggle.module.scss';
export type ToggleSize = 'small' | 'medium';
export type ToggleProps = {
id?: string;
value?: boolean;
onChange?: (value: boolean, e?: React.MouseEvent<HTMLDivElement>) => void;
color?: string;
toggleSize?: ToggleSize;
className?: string;
centered?: boolean;
disabled?: boolean;
'aria-label'?: string;
'aria-labelledby'?: string;
};
export const Toggle = ({
id,
value = false,
onChange,
color,
toggleSize = 'medium',
className,
centered,
disabled,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: ToggleProps) => (
<Switch.Root
id={id}
checked={value}
disabled={disabled}
onCheckedChange={(checked) => onChange?.(checked)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={clsx(
styles.root,
styles[toggleSize],
centered && styles.centered,
className,
)}
style={
color
? ({ '--toggle-on-color': color } as React.CSSProperties)
: undefined
}
>
<Switch.Thumb className={styles.thumb} />
</Switch.Root>
);
@@ -0,0 +1,2 @@
export { Toggle } from './components/Toggle';
export type { ToggleProps, ToggleSize } from './components/Toggle';
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,18 @@
@use 'sass:map';
// Mirrors twenty-ui's MOBILE_VIEWPORT (768px).
$breakpoints: (
'mobile': 768px,
);
@mixin respond-to($name) {
$value: map.get($breakpoints, $name);
@if $value == null {
@error 'Unknown breakpoint: #{$name}';
}
@media (max-width: $value) {
@content;
}
}
@@ -0,0 +1,5 @@
// duration(fast) => calc(var(--t-animation-duration-fast) * 1s)
// Theme animation durations are stored unitless; this scales them to seconds.
@function duration($name) {
@return calc(var(--t-animation-duration-#{$name}) * 1s);
}
@@ -0,0 +1,6 @@
@mixin focus-ring {
&:focus-visible {
outline: 2px solid var(--t-color-blue);
outline-offset: 1px;
}
}
@@ -0,0 +1,15 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
@@ -0,0 +1,2 @@
// Auto-generated barrel placeholder — populated as components migrate into this entry point.
export {};
@@ -0,0 +1,121 @@
import { createContext, useLayoutEffect, useState } from 'react';
import { themeCssVariables } from './themeCssVariables';
type StringLeaves<T> = {
[K in keyof T]: T[K] extends string ? string : StringLeaves<T[K]>;
};
type DeepMerge<T, U> = {
[K in keyof T]: K extends keyof U
? U[K] extends Record<string, unknown>
? T[K] extends Record<string, unknown>
? DeepMerge<T[K], U[K]>
: U[K]
: U[K]
: T[K];
};
// CSS variables that resolve to pure numbers at runtime
type NumericOverrides = {
icon: {
size: { sm: number; md: number; lg: number; xl: number };
stroke: { sm: number; md: number; lg: number };
};
animation: {
duration: { instant: number; fast: number; normal: number; slow: number };
};
text: {
lineHeight: { lg: number; md: number };
iconSizeMedium: number;
iconSizeSmall: number;
iconStrikeLight: number;
iconStrikeMedium: number;
iconStrikeBold: number;
};
spacingMultiplicator: number;
lastLayerZIndex: number;
};
export type ThemeType = DeepMerge<
StringLeaves<typeof themeCssVariables>,
NumericOverrides
>;
export type ThemeContextType = {
theme: ThemeType;
colorScheme: 'light' | 'dark';
};
const computeThemeFromCss = (): ThemeType => {
if (
typeof document === 'undefined' ||
typeof getComputedStyle !== 'function'
) {
return themeCssVariables as unknown as ThemeType;
}
const computedStyle = getComputedStyle(document.documentElement);
const resolve = (obj: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const value = obj[key];
if (typeof value === 'string' && value.startsWith('var(')) {
const varName = value.slice(4, -1);
const raw = computedStyle.getPropertyValue(varName).trim();
const num = Number(raw);
result[key] = raw !== '' && !isNaN(num) ? num : raw;
} else if (typeof value === 'object' && value !== null) {
result[key] = resolve(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
};
return resolve(
themeCssVariables as unknown as Record<string, unknown>,
) as unknown as ThemeType;
};
const applyColorSchemeClass = (colorScheme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (!root?.classList) return;
root.classList.toggle('dark', colorScheme === 'dark');
root.classList.toggle('light', colorScheme === 'light');
};
export const ThemeContext = createContext<ThemeContextType>({
theme: themeCssVariables as unknown as ThemeType,
colorScheme: 'light',
});
export const ThemeProvider = ({
children,
colorScheme,
}: {
children: React.ReactNode;
colorScheme: 'light' | 'dark';
}) => {
const [theme, setTheme] = useState<ThemeType>(() => {
applyColorSchemeClass(colorScheme);
return computeThemeFromCss();
});
useLayoutEffect(() => {
applyColorSchemeClass(colorScheme);
setTheme(computeThemeFromCss());
}, [colorScheme]);
return (
<ThemeContext.Provider value={{ theme, colorScheme }}>
{children}
</ThemeContext.Provider>
);
};
@@ -0,0 +1,28 @@
import {
MAIN_COLOR_NAMES,
type ThemeColor,
} from '@new-ui/theme/constants/MainColorNames';
import { getNextThemeColor } from '../getNextThemeColor';
describe('getNextThemeColor', () => {
it('returns the next theme color', () => {
const currentColor: ThemeColor = MAIN_COLOR_NAMES[0];
const nextColor: ThemeColor = MAIN_COLOR_NAMES[1];
expect(getNextThemeColor(MAIN_COLOR_NAMES, currentColor)).toBe(nextColor);
});
it('returns the first color when reaching the end', () => {
const currentColor: ThemeColor =
MAIN_COLOR_NAMES[MAIN_COLOR_NAMES.length - 1];
const nextColor: ThemeColor = MAIN_COLOR_NAMES[0];
expect(getNextThemeColor(MAIN_COLOR_NAMES, currentColor)).toBe(nextColor);
});
it('returns the first color when currentColorIsUndefined', () => {
const firstColor: ThemeColor = MAIN_COLOR_NAMES[0];
expect(getNextThemeColor(MAIN_COLOR_NAMES, undefined)).toBe(firstColor);
});
});
@@ -0,0 +1,50 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { themeCssVariables as newThemeCssVariables } from '../themeCssVariables';
import { themeCssVariables as oldThemeCssVariables } from '../../../../twenty-ui/src/theme-constants/themeCssVariables';
const NEW_DIR = resolve(__dirname, '..');
const OLD_DIR = resolve(__dirname, '../../../../twenty-ui/src/theme-constants');
// Parse a theme CSS file into ordered [name, value] pairs, normalizing
// whitespace so formatting differences (multi-line vs single-line values,
// header comment) don't affect the comparison. Stops each value at the ';'
// that terminates it, skipping ';' embedded in values (e.g. data URIs).
const parseTokens = (dir: string, file: string): [string, string][] => {
const text = readFileSync(resolve(dir, file), 'utf8');
const body = text.slice(text.indexOf('{') + 1, text.lastIndexOf('}'));
const re = /--t-([a-z0-9_-]+):\s*([\s\S]*?);(?=\s*(?:--t-|$))/g;
const tokens: [string, string][] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body)) !== null) {
const value = match[2]
.trim()
.replace(/\s+/g, ' ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')');
tokens.push([match[1], value]);
}
return tokens;
};
// twenty-new-ui must produce the exact same --t-* tokens and values (and order)
// as twenty-ui, so the swap is token-for-token with no visual change.
// Comparison ignores incidental formatting (header, whitespace) but not values.
describe('theme parity with twenty-ui', () => {
it('theme-light.css tokens match twenty-ui', () => {
expect(parseTokens(NEW_DIR, 'theme-light.css')).toEqual(
parseTokens(OLD_DIR, 'theme-light.css'),
);
});
it('theme-dark.css tokens match twenty-ui', () => {
expect(parseTokens(NEW_DIR, 'theme-dark.css')).toEqual(
parseTokens(OLD_DIR, 'theme-dark.css'),
);
});
it('themeCssVariables is deeply equal to twenty-ui', () => {
expect(newThemeCssVariables).toEqual(oldThemeCssVariables);
});
});
@@ -0,0 +1,3 @@
// CSS custom properties don't work in media queries, so MOBILE_VIEWPORT
// must be a static number rather than a var(--...) reference.
export const MOBILE_VIEWPORT = 768;
@@ -0,0 +1,15 @@
import { type ThemeColor } from '@new-ui/theme/constants/MainColorNames';
export const getNextThemeColor = (
colorNames: ThemeColor[],
currentColor?: ThemeColor,
): ThemeColor => {
if (currentColor === null || currentColor === undefined) {
return colorNames[0];
}
const currentColorIndex = colorNames.findIndex(
(color) => color === currentColor,
);
const nextColorIndex = (currentColorIndex + 1) % colorNames.length;
return colorNames[nextColorIndex];
};
@@ -0,0 +1,14 @@
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export { MOBILE_VIEWPORT } from './constants';
export { getNextThemeColor } from './getNextThemeColor';
export { themeCssVariables } from './themeCssVariables';
export type { ThemeType, ThemeContextType } from './ThemeProvider';
export { ThemeContext, ThemeProvider } from './ThemeProvider';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
import * as RadixColors from '@radix-ui/colors';
import { COLOR_DARK } from '@new-ui/theme/constants/ColorsDark';
export const ACCENT_DARK = {
primary: COLOR_DARK.blue5,
secondary: COLOR_DARK.blue5,
tertiary: COLOR_DARK.blue3,
quaternary: COLOR_DARK.blue2,
accent3570: COLOR_DARK.blue8,
accent4060: COLOR_DARK.blue8,
accent1: RadixColors.indigoDarkP3.indigo1,
accent2: RadixColors.indigoDarkP3.indigo2,
accent3: RadixColors.indigoDarkP3.indigo3,
accent4: RadixColors.indigoDarkP3.indigo4,
accent5: RadixColors.indigoDarkP3.indigo5,
accent6: RadixColors.indigoDarkP3.indigo6,
accent7: RadixColors.indigoDarkP3.indigo7,
accent8: RadixColors.indigoDarkP3.indigo8,
accent9: RadixColors.indigoDarkP3.indigo9,
accent10: RadixColors.indigoDarkP3.indigo10,
accent11: RadixColors.indigoDarkP3.indigo11,
accent12: RadixColors.indigoDarkP3.indigo12,
};

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