b7c68e082ebff7ab63514ee6311161a824460617
1977 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
14d8105f22 |
fix(front): remove runtime default-view creation fallback (#21652)
## Problem `useCreateDefaultViewForObject` was a temporary runtime fallback that created a view + one view field per field (each with a fresh `v4()` id) whenever `RecordIndexLoadBaseOnContextStoreEffect` found no view for the current view id. Because the created view got a fresh id that never matched the requested `contextStoreCurrentViewId`, the next load missed again and re-created another duplicate — leaking `core.view` / `core.viewField` rows without bound (notably during the 2.13.0 cache-first bootstrap window). #21592 made the fallback idempotent as a stop-gap, but the mechanism is no longer needed at all: standard/index views are created server-side at object creation and during standard app installation, so the client never needs to mint them. ## Change Remove the fallback entirely: - Delete `useCreateDefaultViewForObject`. - In `RecordIndexLoadBaseOnContextStoreEffect`, when no view resolves for the current id, do nothing and let the loaded views settle (the effect re-runs once the view is present and loads it). ## Note This removes the leak at the source for any client running the new bundle. Clients still on old cached JS will keep creating duplicates until they reload; the already-leaked rows are being cleaned up separately via SQL. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21652?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
504eaa5600 |
Fix relation-traversal filters showing no options on select fields (#21616)
Relation-traversal filters (e.g. *Company → Industry*) on `SELECT`/`MULTI_SELECT` fields rendered an empty option picker, so the filter couldn't be configured. The value inputs resolved the select options from the **source relation field** (which has no options) instead of the **relation target field**. Fixed in both filter UIs: - Dashboard chart filters and workflow "Find Records" (`AdvancedFilterSidePanelValueFormInput`) - Record-index simple & advanced filters and the role-permission filter builder (`ObjectFilterDropdownOptionSelect`) Both now resolve the value-input field and its options from `relationTargetFieldMetadataId` when a filter traverses a relation, falling back to the source field otherwise. ## Before <img width="802" height="702" alt="CleanShot 2026-06-15 at 17 27 35@2x" src="https://github.com/user-attachments/assets/032b0875-8a5a-4e09-a2ad-4c4a8a319f49" /> ## After <img width="804" height="796" alt="CleanShot 2026-06-15 at 17 27 05@2x" src="https://github.com/user-attachments/assets/88248409-5781-49c7-aa9e-3502ff8c68e4" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21616?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
4be76e3fd1 |
Support morph relations in workflow record nodes (#21403)
## Support morph (polymorphic) relations in workflow record nodes
Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person`
or `Company`) were not selectable in the workflow **Create / Update /
Upsert Record** nodes. This PR adds full support for setting them.
### What changed
**Frontend**
- `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so
morph fields appear in record forms.
- New `FormMorphRelationToOneFieldInput`: a polymorphic record picker
across the morph's target objects, storing a self-describing value `{
targetObjectMetadataId, id }`.
- Wired the morph branch into `FormFieldInput`.
**Backend**
- New `formatWorkflowRecordMorphRelationFields` util: resolves the form
value (stored under the base field name, e.g. `owner`) into the correct
per-target join column (`ownerCompanyId`), nulling siblings to keep
exactly one target referenced.
- Wired into the create / update / upsert workflow actions (update also
expands `fieldsToUpdate` to the concrete join columns).
### Permissions handling
- The picker's search is scoped to only the morph targets the user can
read (`canReadObjectRecords`), so it no longer breaks when a target
object is inaccessible.
- If an existing value points to an object the user can't read, the
field shows the reused **"Not shared"** lock display instead of an empty
field, while remaining editable when other targets are readable.
### Notes
- No data schema / migration changes — reuses the existing per-target
morph columns and stores the selection in the existing workflow step
JSON settings.
<img width="607" height="717" alt="Screenshot 2026-06-10 at 14 57 40"
src="https://github.com/user-attachments/assets/496442a1-04a5-40f8-8b56-b28e38b00d5a"
/>
Also handles the case where the selected record is not readable
<img width="596" height="737" alt="image"
src="https://github.com/user-attachments/assets/c5ffb94e-3838-4db5-853e-f8e490331f23"
/>
|
||
|
|
fb4608e437 |
chore(deps): upgrade Tier-1 deps (googleapis 173, gaxios 7, express 5, jsdom 29, date-fns 4, stripe 20) (#21570)
## What Security-driven upgrade of the biggest-drift Tier-1 dependencies (staying on latest = staying patched). Bundled because they share the lockfile and the googleapis/gaxios pair must move together. | Package | From | To | Gap | |---|---|---|---| | googleapis | 105.0.0 | **173.0.0** | 68 majors | | gaxios | 5.1.3 | **7.1.5** | 2 majors | | express | 4.22.2 | **5.2.1** | 1 major | | jsdom | 26.1.0 | **29.1.1** | 3 majors | | date-fns | 2.30.0 | **4.4.0** | 2 majors | | date-fns-tz | 2.0.0 | **3.2.0** | 1 major | | stripe | 19.3.1 | **20.4.1** | 1 major | `yarn npm audit` reports **0 high/critical** advisories before and after. ## Code changes - **gaxios v7** — `GaxiosError.code` is now `string | number` (guard the calendar network-error check by `typeof`); `GaxiosError` config/response use `URL` + `Headers`; and crucially the v7 constructor drops `response.data` unless `bodyUsed` is set — updated the synthetic gmail error mocks accordingly (production gaxios sets it, so real error parsing is unaffected). - **google-auth-library / gaxios dedup** — `googleapis-common@8.0.2` exact-pins `google-auth-library@10.5.0` + `gaxios@7.1.3` while `googleapis` pulls `^10.2.0`; the two copies made `OAuth2Client`/`GaxiosError` type-identities diverge across every gmail/calendar service. Added two singleton `resolutions` (documented inline in root `package.json`). - **express 5** — no source changes. `@nestjs/platform-express@11.1.24` already resolves `express@5.2.1` internally; the old `4.22.2` pin was the override. - **jsdom 29** — no source changes, but it now pulls ESM-only transitive deps (`@csstools/*` `.mjs`, `parse5`, `entities`, `tough-cookie`, `@exodus/bytes`). Extended the server jest `transformIgnorePatterns` allowlist and added `.mjs` to the transform/extensions so jest can load jsdom. - **stripe 20** — `Subscription` gained a required `customer_account` field; added to mocks. No runtime changes. - **date-fns v4** — `Locale` is no longer ambient (import explicitly in 5 files); per-locale entrypoints dropped the typed `default` export (the locale loader now reads the single named export); fixed the default locale import in `formatTimeZoneLabel`. ## Tests - Full suites green locally: **twenty-server 5709 passed**, **twenty-front 4937 passed**, twenty-ui / twenty-ui-deprecated green; typecheck + builds (swc + vite) + lint all pass. - Added regression tests for the two runtime behaviors these upgrades touch and that had no coverage: - `getDateFnsLocale` — named-export locale resolution (date-fns v4). - `sanitizeFile` — jsdom 29 + DOMPurify still strips `<script>`/event handlers from uploaded SVGs (security guard). ## Deliberately deferred (not in this PR) - **stripe → 21/22**: stripe **21** bundles a runtime `Decimal` type for money fields **and** jumps the pinned API version to `2026-03-25.dahlia` (changes webhook/billing payload behavior) — too risky to fold into a deps bump on billing code. stripe **22** additionally drops the node10-resolvable `types` entry, which would force a repo-wide `moduleResolution` change. Capped at the latest clean **20.x**. - **openid-client → 6**: v6 is a full functional rewrite and its passport strategy manages the OAuth `state` internally, but our SSO flow uses `state` to carry `identityProviderId` across the shared `/auth/oidc/callback`. That needs an auth-flow redesign (session-carried provider id) on Enterprise SSO code with no integration harness — it deserves its own focused PR rather than riding along here. ## Tier-1 source Originated from a dependency-drift audit; remaining Tier-1 items (date-fns done here) plus Tier-2/3 follow-ups tracked separately. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21570?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
53de4c557b |
Fix record index sync when view fields arrive via SSE (#19069)
Fixes #19023 ## What changed This updates the record index/view field state flow so the current view can react to late-arriving `viewFields` coming from SSE without requiring a page refresh. Changes: - extracted a narrower `syncRecordIndexViewFields` path in `useLoadRecordIndexStates` - kept the initial full record-index load for first entry into a view - added a follow-up sync in `RecordIndexLoadBaseOnContextStoreEffect` when the same view receives updated `viewFields` - updated `ViewBarRecordFieldEffect` so it re-syncs current record fields when `currentView.viewFields` changes instead of only initializing once ## Why There is a race when a user navigates to a custom object while AI is still creating metadata. In that case, the record index can initialize from a partial view, and later SSE `viewFields` updates were not being applied to the active view state. That could leave the table visually empty or incomplete until a refresh. ## Impact This should allow: - record index columns to update live when view fields arrive via SSE - view bar field state to update live as well - the current view to stay usable without a refresh while AI-created metadata is still streaming in ## Validation Validated locally with: - `npx prettier --check` on modified files - `npx oxlint --type-aware` on modified files Manual verification: - confirmed live record creation appeared without refresh - manual AI/SSE testing was partially limited by Groq TPM/token caps on the selected model, but the state-sync path was verified in code and local behavior checks <img width="3024" height="1964" alt="image" src="https://github.com/user-attachments/assets/f71c7490-bf57-4357-9d5f-087b2424b53b" /> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
f45c54679c |
[Fix] : fix: Allow label identifier system fields in view creation and fix resulting duplicate header columns (#19009)
fixes #18994 After : <img width="767" height="230" alt="Screenshot 2026-03-26 at 7 11 04 PM" src="https://github.com/user-attachments/assets/73de0154-7da1-48fa-92fc-d51a3ef5b06e" /> Before : <img width="905" height="314" alt="Screenshot 2026-03-26 at 6 56 38 PM" src="https://github.com/user-attachments/assets/89b1249b-8889-4034-a6c8-d41330154c1a" /> --------- Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
7c0136b97b |
feat(deps): migrate frontend to React 19 (#21531)
## What Migrates the frontend stack from **React 18.3 → 19.2**. The website, sdk, companion and emails packages were already on React 19; this brings the remaining holdouts (`twenty-front`, `twenty-ui`, `twenty-ui-deprecated`, `twenty-front-component-renderer`) and `twenty-server`'s email rendering onto 19, and pins a single React version repo-wide. ## Why React 18.x is now the legacy line. Staying current keeps us on the patched/maintained branch and unblocks downstream library majors (react-router 7, mantine 9, etc.) that require React 19 peers. ## Dependency bumps (required by React 19 peers / removed APIs) | Package | From | To | Reason | |---|---|---|---| | react / react-dom | 18.3.1 | 19.2.3 | core | | @hello-pangea/dnd | 16 | 18 | peer `^18 \|\| ^19` | | react-datepicker | 6 | 9 | v<7 used removed `findDOMNode`; drops `@types/react-datepicker` | | react-data-grid | beta.13 | beta.59 | peer `^19.2`; new render API | | graphiql (+ @graphiql/react, plugin-explorer) | 3 / 0.23 / 1 | 5 / 0.37 / 5.1 | peer `^18 \|\| ^19` | | react-helmet-async | 1.3 | **@dr.pogodin/react-helmet** 3.2 | upstream caps peer at `^18`; drop-in React 19 fork | A `resolutions` pin enforces a single React (19.2.3) + `@types/react` (19.2.14) across the monorepo to avoid duplicate copies / type-identity splits. Versions are the aged lockfile patches (clears the `npmMinimalAgeGate`). ## Code changes - **Global `JSX` shim** (`react-jsx-global.d.ts` per package): React 19 moved the `JSX` namespace under `React.JSX`; several deps' published types (notably `@linaria/react`'s `styled.d.ts`, which types every `styled.x` via `keyof JSX.IntrinsicElements`) still reference the global namespace. Without the shim, every styled component degrades to `any` props. - **Ref nullability**: `useRef<T>(null)` now returns `RefObject<T | null>`; widened consumer prop/hook ref types accordingly (incl. the shared `useListenClickOutside`). - **react-datepicker v9**: `onChange`/`onSelect` accept `Date | null`, `calendarStartDay` typing, `ReactDatePickerProps`→`DatePickerProps`, relaxed the dynamic `selectsMultiple` discriminated union. - **react-data-grid beta.59**: `formatter`→`renderCell`, `editor`→`renderEditCell`, `headerRenderer`→`renderHeaderCell`, `components`→`renderers`, `onRowClick`→`onCellClick`, object-shaped `useRowSelection`, Set-based selection. - **dnd style cast**: `@radix-ui/react-popper` augments `CSSProperties` with a `--radix-*` index signature that dnd's closed `DraggingStyle` doesn't satisfy → cast at the spread. ## Status / testing - ✅ `typecheck` green: twenty-front, twenty-ui, twenty-ui-deprecated, twenty-front-component-renderer, twenty-server - ⏳ build / lint / unit tests / storybook+argos / runtime smoke-test in progress Draft until local + CI verification completes. Notable behavior to QA manually: spreadsheet import (data-grid), date pickers, drag-and-drop boards/lists, GraphQL playground, page titles/favicon. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21531?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
5d0a4b8db4 |
fix(twenty-front): keep paging record board columns past the second page (#21348)
Fixes #21355 ## Problem On a Record Board (Kanban) view, columns that contain more than 20 records stop loading at exactly 20. The initial query loads the first page, one automatic fetch brings the column to 20, and then the loading placeholder at the bottom of the column **spins forever** — scrolling all the way down triggers no further requests. Every column is permanently capped at `2 * RECORD_BOARD_QUERY_PAGE_SIZE` (20) records. ### Steps to reproduce 1. Open any object in board view, grouped by a field where at least one group has > 20 matching records. 2. Wait for the board to load — the first 20 cards in the large column appear. 3. Scroll that column to the bottom. **Expected:** more cards load as you approach the bottom, until the column is exhausted. **Actual:** the placeholder stays forever; no additional group-by request is fired. ## Root cause An **edge-triggered consumer reading a level signal that is stuck high.** The fetch-more trigger (`RecordBoardFetchMoreInViewTriggerComponent`) is an `IntersectionObserver` sentinel that writes its `inView` state into the board-level `recordBoardShouldFetchMoreComponentState`. Its `rootMargin` is: ```ts const rootMargin = `${estimatedCardHeight * RECORD_BOARD_QUERY_PAGE_SIZE * 2}px`; ``` With `estimatedCardHeight ≈ 130px` and `RECORD_BOARD_QUERY_PAGE_SIZE = 10`, that's ~2600px — roughly two pages, i.e. as tall as the entire already-loaded board. So the sentinel reports `inView = true` across the whole loaded board, and the boolean **latches `true` after the first auto-fetch and never toggles back**. The consumer in `RecordBoardQueryEffect` only reacts to the **false→true edge** of that boolean, and `triggerRecordBoardFetchMore` is a stable `useCallback`. Once the boolean is stuck `true` and the dependency array stops changing, the effect never re-runs — so it fetches exactly once. The signal is *level* ("the bottom is in view, keep loading") but it's consumed as an *edge* ("the bottom just appeared, load once"), and the oversized `rootMargin` guarantees the level is permanently high so the single edge never repeats. The large `rootMargin` is intentional prefetch buffering and is not the bug; the consumer simply needs to keep paging while the signal is high. ## Fix Make the consumer **re-arm** the trigger after every page that actually returned records: 1. `useTriggerRecordBoardFetchMore` now returns a `boolean` — `true` only once at least one column received records this round, `false` on every early-exit / empty result. 2. `RecordBoardQueryEffect` resets `recordBoardShouldFetchMoreComponentState` to `false` after a **productive** fetch. The sentinel is still inside the inflated `rootMargin`, so the observer immediately re-asserts `true`, which re-runs the effect and fetches the next page. The loop terminates naturally and never spins: - **Buffer filled** — enough cards load that the sentinel finally leaves the `rootMargin` → observer reports `false` → loop stops. As the user scrolls, it re-arms (normal infinite scroll). - **Columns exhausted** — `triggerRecordBoardFetchMore` returns `false` (per-column `shouldFetchMore` flags already get set `false` when a page returns `< PAGE_SIZE`), so the boolean is not reset and no further fetch fires — no busy-loop on a fully-loaded board. The existing `recordBoardIsFetchingMore` re-entrancy guard prevents any overlapping/double fetch during the round-trip. ## Test - `npx nx typecheck twenty-front` → passes - `npx nx lint twenty-front` (oxlint --type-aware + oxfmt) → 0 warnings, 0 errors, formatting clean - Manually verified on a board with columns of 38 and 74 records: pre-fix both froze at 20; post-fix they page to completion on scroll, and a fully-loaded board issues no extra requests. ## Notes / alternatives considered - **Shrinking `rootMargin`** would mask the bug for tall boards but defeat the intended prefetch buffering and reintroduce it whenever the buffer is smaller than the loaded content. The level/edge mismatch is the real defect. - **Moving the loop into the trigger component** was rejected — it only knows `inView`, not whether a fetch was productive or whether columns are exhausted, so self-looping there would increase coupling. The query effect is the right owner of fetch orchestration. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
fefb6cdb94 |
feat(page-layout): add number format option to aggregate chart widget (#21521)
## Context Closes #21522 Large values in the dashboard **Number** widget are always abbreviated (e.g. `1300090` → `1.3m`) with no way to display the full number. Following a discussion with the core team who were interested in this feature (https://discordapp.com/channels/1130383047699738754/1509604545381142649) , this adds a **Format** option in the **Style** section of the Number (aggregate chart) widget, letting users choose between **Short** (abbreviated, current behavior) and **Full** (complete number with thousand separators). Only the displayed value of the Number widget is affected — axes, labels and tooltips of other chart types are intentionally left untouched. ## What's inside **Server** - New `ChartNumberFormat` GraphQL enum (`SHORT` / `FULL`), following the `AxisNameDisplay` pattern - The existing — and previously unused — `format` field on `AggregateChartConfigurationDTO` is now typed with this enum and validated with `@IsEnum` - The dashboard AI tool schema (`widget.schema.ts`) accepts the new `format` option - Regenerated GraphQL types and the `twenty-client-sdk` metadata client to reflect the enum **Front** - New **Format** setting in the Style section of the Number widget settings, with a Short/Full selection dropdown (same pattern as the Axis name setting) - `transformAggregateRawValueIntoAggregateDisplayValue` takes an optional `numberFormat`: - `FULL` → full number via `formatNumber` (currency values keep up to 2 decimals) - `SHORT` → abbreviated via `formatToShortNumber` - not set → behavior unchanged (currency short, number full), so existing widgets and the record table/board footers render exactly as before ## Screenshots | Full UI Look | <img width="1917" height="955" alt="Twenty_Showcas_FullShort" src="https://github.com/user-attachments/assets/05d05779-395d-4e1a-8ff0-964f6fbef182" /> | Menu UI Look | <img width="291" height="308" alt="Screenshot_2" src="https://github.com/user-attachments/assets/82b5a1de-32fe-46ec-a9b8-add11ab4c6cd" /> ## Tests - Extended `transformAggregateRawValueIntoAggregateDisplayValue` unit tests with SHORT/FULL cases for currency and number fields - Updated the page-layout-widget creation/update integration tests and snapshots to use `ChartNumberFormat.SHORT` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21521?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
5d892bdfd0 |
[WIP] Feat/marketing emails (#21173)
Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.
## Model
Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.
Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.
Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.
## Sending
- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.
## Unsubscribe
- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.
## Architecture
Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.
## Frontend
- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
|
||
|
|
036d9a2bcf |
feat: gate record creation on isUICreatable only, decoupled from isSystem (#21527)
## Context The generic "create a record" UI affordance previously required `!isSystem`, conflating two distinct concerns: **"hidden from Data Model"** and **"not user-creatable"**. This blocked legitimately creatable system objects (e.g. marketing message lists kept `isSystem: true` only to stay out of the Data Model). This PR makes creatability depend on `isUICreatable` alone, so visibility (`isSystem`) and creatability (`isUICreatable`) become independent. ## Changes - **Front gate** (`canCreateRecordsForObjectMetadataItem.ts`): drop the `!isSystem` clause — creatability is now `isUICreatable && !readOnly`. Updated comment + unit test. - **Command menu** (`standard-command-menu-item.constant.ts`): drop the matching `not objectMetadataItem.isSystem` clause from the `createNewRecord` availability expression so the "Create new X" command mirrors the front gate. Existing workspaces get this via the already-present `SyncCreateRecordCommandAvailabilityExpressionCommand`, which re-syncs from the live definition. - **Standard object audit** (`create-standard-flat-object-metadata.util.ts`): the 15 sync/system-created standard objects that relied on `!isSystem` to stay non-creatable now set `isUICreatable: false` (attachment, blocklist, calendar*/message*/note/task targets, message, messageThread, messageParticipant, timelineActivity, callRecording, workflowAutomatedTrigger, …). `workflowRun`/`workflowVersion`/`workspaceMember` were already `false`. Non-system objects (company, person, note, opportunity, dashboard, task, workflow) are untouched. - **Backfill**: new fast instance command (`2-13-…-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts`) runs `UPDATE core.objectMetadata SET isUICreatable = false` for those standard system objects (symmetric `down`), registered in `instance-commands.constant.ts`. `isSystem` and Data-Model visibility logic are unchanged. ## Verification - Front gate unit test (7 pass), standard-application suite incl. callRecording (10 pass) - `oxlint` clean on all changed files - `twenty-server` and `twenty-front` typecheck green 🤖 https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3 --- _Generated by [Claude Code](https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21527?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b14195428a |
Fix navigation and settings UI polish (#21523)
## Summary This PR groups the requested UI polish pass across navigation, settings, AI settings, data model, community/lab, and dashboard chips. ### Navigation and drawer polish - Smooths app/settings route switching with a 300ms content transition. - Smooths app menu/settings menu drawer swaps with a fade transition while preserving the existing drawer dimensions. - Keeps navigation and AI chat history panes mounted to avoid flicker when switching tabs. - Aligns the first app/settings section with the AI chat history section title. - Removes the main app navigation "Other" section. - Aligns the settings exit header with the workspace switcher header. - Sets the settings exit icon gap to 8px, uses the small icon stroke token, and keeps the icon color on text-secondary. - Makes the workspace switcher button 28px high inside its 32px container, moves the dropdown up so the workspace name does not jump, and keeps a 2px gap between header icon buttons. - Moves Settings below Support in the workspace switcher menu. - Pins the Advanced toggle to the bottom of the settings drawer and aligns its right padding with nav items. ### Settings surface polish - Fixes vertically cropped dropdown menu headers. - Makes wizard parent titles tertiary when a nested wizard title is visible. - Places danger-zone buttons side by side. - Uses a 32px Visualize button. - Restores 8px top/bottom padding on settings tables. - Separates AI overview counts into three columns like layout overview. - Adds separators inside setting cards, including the Smart Model / Fast Model card. - Groups lab/early-access toggles into one card with separators and full-width row backgrounds. - Replaces deprecated Enterprise adornment tags with the Organization adornment on gated AI/security/settings surfaces. - Uses the Discord brand icon in Community while keeping the standard icon component rendering pattern. - Tightens Skills/Tools search-to-table spacing so switching tabs does not shake the table top. - Fixes the small gap in nested navigation breadcrumbs between Emails and Calendars. ### Dashboard/data table polish - Fixes vertically cropped "Not shared" chips on dashboards. - Keeps table row/header spacing stable after the settings table padding restoration. ## Validation - `npx nx lint:diff-with-main twenty-front` - `npx tsc -p packages/twenty-front/tsconfig.json --noEmit` - Manual Chrome pass on `apple.localhost:3001` for profile, app navigation, workspace switcher, AI overview/models/skills/tools/usage, community/lab, data model, new-field wizard, and dashboards. ## Visual QA ### Settings surface fixes <img width="1324" height="2668" alt="Settings surface fixes before/after board" src="https://github.com/user-attachments/assets/312131fa-3922-4724-9460-46fd373754d1" /> ### Navigation drawer fixes <img width="1324" height="1816" alt="Navigation drawer fixes before/after board" src="https://github.com/user-attachments/assets/ca0b29e5-e393-46c0-81f7-e6bed2557b59" /> ### Tables, wizards, gates and chips <img width="1324" height="2242" alt="Tables, wizards, gates and chips before/after board" src="https://github.com/user-attachments/assets/5f358240-c31c-4c15-8ca8-04ed761fbf85" /> |
||
|
|
1efa3567ef |
Rename isUIReadOnly to isUIEditable, add isUICreatable, expose both to app developers (#21504)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# UI capability flags: `isUIEditable` + `isUICreatable`
## Per-verb capability model
This PR replaces the negative `isUIReadOnly` metadata flag with
positive, per-verb capability flags (à la Salesforce
`createable`/`updateable`):
- **`isUIEditable: boolean`, default `true`** — rename of `isUIReadOnly`
with inverted polarity, on **both** `objectMetadata` and
`fieldMetadata`. It is one concept ("can the user edit this through the
generic UI?") at two altitudes, so it carries one name at both levels.
- **`isUICreatable: boolean`, default `true`** — new, **object-level
only** (fields have no create verb). When `false`, no generic UI
affordance to create a record of this object appears anywhere (table "+"
buttons, board column add, calendar add, relation-section "Add new",
record picker "Add new", command-menu create action and its keyboard
shortcut).
Both flags are **UI-affordance flags only**: the server does not block
create/edit mutations based on them, so the system, API, and workflows
continue to mutate these records freely. They are orthogonal statements
about the object's nature with no implication rule in the data model.
Because today's inline creation UX creates a blank record the user must
then edit, the frontend create predicate currently requires both
`isUICreatable` and effective editability.
There is no CREATE permission in `ObjectPermissions`; the frontend keeps
gating creation on `canUpdateObjectRecords` as a proxy, ANDed with the
new flags.
## Unified create predicate
All generic creation entry points now flow through one predicate,
`canCreateRecordsForObjectMetadataItem` (`isUICreatable` && not
`isSystem` && not effectively read-only, where effective read-only
covers `isUIEditable`, `isRemote`, and the `canUpdateObjectRecords`
proxy via `isObjectMetadataReadOnly`). This deletes the previously
hardcoded suppression lists:
- `isRecordTableCreateDisabled.ts` and its hardcoded
`WorkflowRun`/`WorkflowVersion` list — deleted; those objects (plus
`workspaceMember`) now declare `isUICreatable: false` in the standard
application instead.
- The hardcoded `workspaceMember` guard inside
`useAddNewRecordAndOpenSidePanel.ts` — deleted.
- The `CREATE_NEW_RECORD` command menu item's availability expression
now checks `objectMetadataItem.isUICreatable`, `isUIEditable`,
`isSystem`, and `isRemote`; a workspace upgrade command re-syncs the
expression in existing workspaces.
Component-local conditions (soft-delete filter active, layout
customization mode) stay in their components.
## GraphQL compatibility and removal plan
The schema delta versus main is **purely additive plus deprecations —
zero breaking changes**:
- `isUIReadOnly` remains on both the ObjectMetadata and FieldMetadata
GraphQL output types for **one release** as a deprecated field computed
as `!isUIEditable` (`deprecationReason: 'Use isUIEditable'`). The Twenty
frontend no longer queries it.
- `isUIReadOnly` also remains on the **input side** for one release
(`CreateFieldInput`, `UpdateFieldInput`, `FieldFilter`, `ObjectFilter`),
keeping the schema shape identical to main for those members. On create
it acts as a legacy alias mapped to `!isUIReadOnly` (`isUIEditable` wins
when both are provided); on update it is ignored, exactly as on main (it
was never an editable property). Filtering on the deprecated member
keeps working until the column is dropped at upgrade time; after that it
is a deprecated no-op surface kept only for schema compatibility.
**Removal plan for next release: drop `isUIReadOnly` from the output
DTOs (and resolvers' `@ResolveField`s), from the input/filter types,
from the create-input mapping, and the `@WasRemovedInUpgrade`-retained
entity columns and decorators.**
## ⚠️ Webhook / database-event payload shape change
The `database-event-payload` type in `twenty-shared` got a clean rename
(no alias): metadata snapshots in webhook and database-event payloads
now carry `isUIEditable` (and `isUICreatable` at object level) **instead
of** `isUIReadOnly`, with inverted polarity. Consumers of these payloads
that read `isUIReadOnly` must switch to `isUIEditable`.
## New manifest properties (app-developer DX)
Application developers can now set these flags in their app manifests
(purely additive — existing manifests and older `twenty-sdk` versions
are unaffected, defaults apply when omitted):
- `objects[].isUICreatable?: boolean` (default `true`)
- `objects[].isUIEditable?: boolean` (default `true`)
- `fields[].isUIEditable?: boolean` (default `true`)
The manifest converters previously hardcoded `isUIReadOnly: false`; they
now read the manifest values with `?? true` defaults. The types are
re-exported through `twenty-sdk` from `twenty-shared`.
## Migration & backfill
- One fast instance command: adds `isUIEditable` (NOT NULL default
`true`) on `core."objectMetadata"` and `core."fieldMetadata"`, backfills
`isUIEditable = false` exactly where `isUIReadOnly = true`, drops
`isUIReadOnly`, and adds `isUICreatable` (default `true`) on
`objectMetadata`. The `down` is the exact inverse. Uses `ADD/DROP COLUMN
IF (NOT) EXISTS`, matching the 2-12 drop-`isCustom` precedent. Verified
up and down in separate transactions against a dev database with exact
backfill counts.
- **Cross-version upgrade safety (multi-version self-hosted jumps):**
the upgrade sequence interleaves per version (instance → workspace
commands), so pre-2.13 workspace commands run **before** the 2.13 rename
when an old instance jumps several versions. Following the `isCustom`
precedent: `isUIEditable`/`isUICreatable` are marked
`@WasIntroducedInUpgrade` and `isUIReadOnly` stays on both entities as
`@WasRemovedInUpgrade`, so the upgrade-aware entity metadata adapter
hides the not-yet-existing columns (and keeps the legacy column live) at
pre-2.13 cursors. **No committed upgrade command outside the 2-13
directory is modified**: the old 1-21/2-8/2-9 commands keep their
original `isUIReadOnly: true` inputs, which still compile (entity
property retained, deprecated create-input alias mapped) and still
produce the correct legacy column writes pre-rename.
- A 2-13 workspace command (`sync-standard-ui-capability-flags`)
re-syncs `isUICreatable` **and** `isUIEditable` on standard objects and
`isUIEditable` on standard fields from the standard-application
definitions. This backfills `isUICreatable: false` on
`workflowRun`/`workflowVersion`/`workspaceMember` and heals fields
created mid-cross-upgrade by pre-2.13 commands (whose hidden
`isUIEditable` value cannot reach the insert). Both 2-13 sync commands
pass `isSystemBuild: true` — the flat metadata validator otherwise
rejects direct updates to system objects (verified against a
deliberately drifted dev database; the run is idempotent).
- A second 2-13 workspace command re-syncs the create-record command
availability expression.
## Testing
- Unit tests for `canCreateRecordsForObjectMetadataItem`
(flag/permission/system combinations) and for the manifest converters
(flags set / omitted → defaults).
- Full `upgrade --dry-run` boots the sequence (107 steps) and validates
the upgrade-aware decorator references; both 2-13 sync commands verified
end to end against real drift and re-run idempotently.
- Schema verified by live introspection after the input-alias restore:
all four input/filter members match main, output deprecations intact;
frontend metadata types and `twenty-client-sdk` schema regenerated from
the running server.
- Read-only-related and touched jest suites pass on both packages;
typecheck and lint pass on `twenty-server` and `twenty-front`.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a
href="https://cursor.com/agents/bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a
href="https://cursor.com/background-agent?bcId=bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21504?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
|
||
|
|
ee6fcdbec2 |
fix(front): show readable targets in morph relation picker (#21513)
## Problem The **morph relation picker is broken** when the user does not have read permission on *every* object a morph relation can point to. A morph (polymorphic) relation can target several objects. The single-record picker passed **all** of those target objects to the `search` query via `includedObjectNameSingulars`. The backend runs the per-object searches inside a single `Promise.all`, so if **one** target object is forbidden, the whole search rejects and the picker shows **"No records found"** — even for the target objects the user *can* read. This makes the morph relation picker unusable in any workspace where a role restricts read access to one of the morph targets (e.g. the demo workspace's `Object-restricted` role, which denies reading `Rocket` — the picker for a Pet's polymorphic owner then shows nothing, hiding the readable `Survey result` records too). ## Fix Filter the searched target objects down to the ones the current user is allowed to read before querying, in `useSingleRecordPickerPerformSearch`. This mirrors what the multiple-record picker (`useMultipleRecordPickerPerformSearch`) already does via `filteredSearchableObjectMetadataItems`. For a normal (single-target) relation this is a no-op; for a morph relation the picker now lists records from every target the user can read and silently skips the forbidden ones. ## Test Added `useSingleRecordPickerPerformSearch.test.tsx`: - excludes morph target objects the user cannot read from the search - keeps all targets when the user can read all of them ## Verification Reproduced locally on a Pet's "Polymorphic Owner" morph relation (targets `Rocket` + `Survey result`) with `Rocket` read denied: - **Before:** picker shows "No records found". - **After:** picker lists the readable `Survey result` records and omits the `Rocket` ones. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21513?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
cb653e4ecc |
feat: inline image thumbnails and legacy-label fallback for FILES field chips (#21294)
## Summary Custom FILES field chips now show an inline image thumbnail for image attachments and fall back to the legacy label when an attachment predates filename storage. This covers two of the UX complaints n2ojim collected in #20942: image files were indistinguishable from other attachments, and older attachments rendered with an empty chip label. The 10-file cap from the same issue already shipped in #20950; the gallery/grid layout and hover-delete affordances are deliberately left for follow-ups per the maintainer's cost notes on the thread. ## Why this matters #20942 is founder-tagged UX feedback on the new custom FILES field: once a record carries more than a couple of attachments, users scan chips visually, and a thumbnail answers "which one is the screenshot" without opening anything. The fallback keeps old records readable instead of showing blank chips. Changes stay inside `FileChip.tsx` and follow the existing file-display patterns; Storybook stories cover both behaviors. ## Testing Added 9 Storybook stories: image attachment (thumbnail), non-image (icon unchanged), missing filename (legacy fallback label), long names, and combinations. Targeted typecheck of the changed files surfaced no errors; the monorepo's CI lint/build covers the rest. Refs #20942 --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
fefd9d7704 |
feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
184c4948d6 |
security: strip Node dev headers from images + lingui 5.9.5 (drops vulnerable esbuild) (#21448)
## Context
AWS Inspector flags the `prod-twenty` image (built from current main)
with 16 findings, and Dependabot alert 174 flags esbuild. This PR fixes
the OpenSSL scanner findings and the esbuild CVE. The typeorm bump
(CVE-2025-60542) was **pulled out of this PR** — see "typeorm status"
below.
## Changes
### Strip `/usr/local/include/node` from runtime stages
(`twenty-server`, `twenty-app-dev`)
15 OpenSSL CVEs (June 9 advisory, incl. CRITICAL CVE-2026-34182) are all
detected via **Node's bundled OpenSSL dev headers**: 3 GENERIC
`openssl/openssl` 3.5.6 detections per CVE at
`/usr/local/include/node/openssl/archs/linux-x86_64/{asm,asm_avx2,no-asm}/include/openssl/opensslv.h`.
The headers are only needed by node-gyp and native addons are compiled
in the build stages — nothing compiles at runtime. Dropping them clears
all 45 detection instances and permanently ends this class of finding
(third occurrence: 3.5.5 → 3.5.6 → 3.5.7). None of these CVEs are
reachable through Node (no CMS/PKCS#7 API, `pfx` is operator-supplied,
Node's QUIC uses ngtcp2, ASN.1 issues need ~2GB inputs).
**Follow-up (~June 17, 2026):** the `node` binary itself still
statically links OpenSSL 3.5.6 — invisible to the scanner after this PR
and unreachable in practice, but the real fix is bumping the pinned
`node:24-alpine` digest once the [announced June 17 Node.js security
releases](https://nodejs.org/en/blog/vulnerability/june-2026-security-releases)
ship a 24.x linking OpenSSL ≥ 3.5.7 (verify via
`deps/openssl/openssl/VERSION.dat` on the release tag — 24.16.0 is still
on 3.5.6). A dated TODO sits next to the cleanup in the Dockerfile.
### esbuild dev-server CORS CVE (Dependabot alert 174,
GHSA-67mh-4wv8-2f99)
`@lingui/cli@5.1.2` (pins `esbuild ^0.21.5`) was the last parent
resolving a vulnerable esbuild (≤ 0.24.2 lets any website send requests
to the dev server and read responses). Instead of a resolution override,
this bumps the lockstepped **lingui suite 5.1.2 → 5.9.5** (within-major;
lingui adopted `esbuild ^0.25.1` in 5.4.1), which:
- removes `esbuild@0.21.5` and all its platform packages from the
lockfile with no forced ranges;
- drops the `@lingui/core` lockstep resolution (its comment marked it
droppable on the next coordinated lingui bump — the tree now resolves a
single `@lingui/core@5.9.5`);
- `@lingui/swc-plugin` stays at `^5.11.0` (peers on `@lingui/core: 5`;
its 6.x line targets lingui 6).
**lingui 5.9.5 behavioral fallout handled here:**
- Translation functions now **throw without an active locale** (5.1.2
fell back silently). The global `i18n` singleton that backs server-side
`` t`…` `` calls only had a messages compiler set, never an activated
locale → activate the source locale in `I18nService.loadTranslations()`,
mirrored in the server jest setup (unit tests bypass Nest bootstrap).
- `msg`/`t` placeholders are now strictly typed (reject
`null`/`undefined`/`unknown`) → one server call site and 16 twenty-front
files adapted with minimal nullish-coalescing fixes that preserve
rendering.
- `.po`/compiled-catalog churn from the new extractor/compiler
(reference reordering, sorted keys — verified content-identical on
unchanged `.po` inputs) is intentionally not committed: the scheduled
i18n workflows regenerate those.
## typeorm status (pulled out)
typeorm 0.3.20 → 0.3.26 was originally in this PR but **made workspace
metadata sync intermittently lossy**: `example-app-postcard` failed
twice with a *different* field missing from the synced PostCard object
each run, and one integration shard's `DataSeedWorkspaceCommand` died
with "Could not find flat entity with universal identifier …" — versus
zero such failures on recent main. Local runs (db reset + seed, group-by
integration suite 19/19) pass, so it is a nondeterministic
CI-load-sensitive regression that needs dedicated debugging (typeorm
changed LIMIT/OFFSET 0 semantics, lazy count for `getManyAndCount`,
upsert WHERE construction, and topological-sort internals in that
range). The resolutions comment documents this as the blocker;
CVE-2025-60542 is MySQL-driver-only (`sqlstring`), so Postgres-only
Twenty is not exposed in the meantime.
## Verification
- `npx nx typecheck twenty-server` / `twenty-front` — clean (no cache)
- `npx nx test twenty-server` — full suite green
- `lingui:extract` + `lingui:compile` — clean for twenty-server /
twenty-emails / twenty-front
- `oxfmt --check` — clean for both packages
- Lockfile diff: lingui 5.9.5 entries, `esbuild@0.21.5` +
`@esbuild/*@0.21.5` platform packages removed, no typeorm changes
|
||
|
|
20c83e1f86 |
fix(kanban): preserve scroll on board re-init + propagate same-column reorders via SSE (#20637)
closes https://discord.com/channels/1130383047699738754/1504130730840821860 https://github.com/user-attachments/assets/d5833031-01c6-4e46-b699-c29c42435a53 ## Summary Fixes two related issues with the kanban (board view) collaboration experience: 1. **Scroll-to-top on every data change** — `triggerRecordBoardInitialQuery` always scrolled the board to the top, even when re-initializing for a single-record data change (SSE echo of your own mutation, a collaborator's update). Scroll reset only makes sense when the dataset itself changes (filter / sort / group). 2. **Same-column reorders by other users did not propagate** — the server's diff function stripped `FieldMetadataType.POSITION`, so position-only updates produced empty `updatedFields` and short-circuited event emission entirely. SSE clients never received them. ## What's in here - **Frontend** — `useTriggerRecordBoardInitialQuery` now exposes a `triggerRecordBoardInitialQueryWithoutScrollReset` variant; data-driven re-inits in `RecordBoardDataChangedEffect` use it, while genuine filter / sort / group changes keep the scroll-resetting `triggerRecordBoardInitialQuery`. `getRecordBoardEffectsForUpdateInputs` classifies each update as `trigger-initial-query` / `reposition-records` / `none`. For position- or group-only changes we skip the re-query and reposition records in place in the store (`useRepositionRecordsOnBoard`), which avoids the flicker and preserves scroll. - **Server** — removes `POSITION` from `objectRecordChangedValues`' strip list, so position-only updates emit a non-empty diff and flow through SSE. Position is now treated as a field like any other across all event consumers (SSE, webhooks, workflows, logic functions); a trigger with an explicit field filter still excludes it. |
||
|
|
941c9e7586 |
fix: match relation field filters in optimistic & RLS record matchers (#21301)
Closes #21345. ## What It should be caused by the GraphQL optimistic query. `isRecordMatchingFilter` (front, Apollo optimistic cache) and `isRecordMatchingRLSRowLevelPermissionPredicate` (server, RLS) now handle a view filter that targets a **relation field object** (e.g. an "is (not) empty" filter on a relation) by matching against the related record id, instead of throwing. <img width="3436" height="2250" alt="CleanShot 2026-06-08 at 06 44 01@2x" src="https://github.com/user-attachments/assets/1dccbd1e-133c-4f4a-a0a9-7ccd02a9a0ae" /> <img width="1496" height="380" alt="CleanShot 2026-06-08 at 06 45 34@2x" src="https://github.com/user-attachments/assets/e5c2071e-69df-4d99-bb7d-66d503a175b6" /> ## Why Both matchers only implemented the relation **join column** branch (`fooId`) and threw `Not implemented yet, use UUID filter instead on the corresponding "fooId" field` for the relation field itself (`foo`). In practice the UI still stores relation filters keyed on the relation object, so any view with such a filter made every create/update/delete on that object throw: the optimistic effect re-evaluates all active view filters against the changed record and hits the unimplemented branch. Repro: add a self-relation field on People (e.g. "Referred By"), put it in a view filter as "is not empty", then edit any Person. The optimistic update throws. ## Behaviour change | Scenario | Before | After | |---|---|---| | View filter on relation object (`referredBy is not empty`), then edit a record | Throws `Not implemented yet...` | Record matched by related id; update succeeds | | Filter on relation join column (`referredById`) | Worked | Unchanged | ## Test plan ```bash cd packages/twenty-front && npx jest isRecordMatchingFilter cd packages/twenty-server && npx jest is-record-matching-rls-row-level-permission-predicate ``` - [x] Front: relation `is empty` / `is not empty` / `in` match by related id; join-column path still passes (20/20) - [x] Server: relation `is empty` / `is not empty` match by related id (9/9) - [x] `lint:diff-with-main` clean on both packages Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
f1c7aecadb |
fix(front): sanitize optimistic input when creating a record (#21076)
## Summary Closes #15800. Clicking **+ Add New** from a relation cell to create a **Task** or **Note** (e.g. from a custom object's Tasks/Notes section in the list view) throws: ``` Uncaught (in promise) Error: Should never occur, encountered unknown fields name in objectMetadataItem task ``` ### Root cause `useCreateOneRecord` computes a **sanitized** input (with `sanitizeRecordInput`, which strips fields that don't belong to the object) and sends it to the GraphQL mutation. But it still feeds the **raw** input to the optimistic cache computation: ```ts const sanitizedInput = { ...sanitizeRecordInput({ objectMetadataItem, recordInput }), id: idForCreation }; const optimisticRecordInput = computeOptimisticRecordFromInput({ ... recordInput: { ...computeOptimisticCreateRecordBaseRecordInput(objectMetadataItem), ...recordInput, // ← raw input, may contain fields unknown to the object id: idForCreation, }, ... }); // mutation uses the sanitized input: mutate({ variables: { input: sanitizedInput } }); ``` `computeOptimisticRecordFromInput` asserts that every input key maps to a field on the object and `throw`s otherwise. So when the create input carries a field the target object doesn't have (the relation-create path passes a `name`, but Task/Note use `title`), the optimistic step throws before the mutation ever runs. `useCreateManyRecords` does **not** have this problem — it already feeds the sanitized input to `computeOptimisticRecordFromInput`. ### Fix Feed the sanitized input to the optimistic computation in `useCreateOneRecord`, exactly as `useCreateManyRecords` does: ```ts recordInput: { ...computeOptimisticCreateRecordBaseRecordInput(objectMetadataItem), ...sanitizedInput, }, ``` This is safe and behavior-preserving for valid creates: `computeOptimisticRecordFromInput` only ever reads *known* fields (it iterates the object's field metadata); unknown input keys never contribute to the optimistic record — they only trip the invariant. Relations are resolved through their join columns, which sanitization keeps. ## Test plan - [x] `npx oxlint --type-aware` — passes on the changed files - [x] `npx oxfmt --check` — passes - [x] `tsc --noEmit` — no type errors in the changed files - [x] `npx jest computeOptimisticRecordFromInput` — passes, including a new case asserting that input which has been through `sanitizeRecordInput` no longer trips the "Should never occur, encountered unknown fields" invariant (the existing test already covers the raw input throwing) - [ ] Manual: from a custom object's Notes/Tasks relation, use **+ Add New** to create a Note/Task — no error, the record is created ### Note on test scope The crash only reproduces through the full relation-create flow with live metadata; at the hook level in jsdom the create resolves regardless, so a hook-level test would not guard the regression. The added test instead locks the underlying mechanism the fix relies on — that sanitized input is safe for `computeOptimisticRecordFromInput` — alongside the existing test that proves raw unknown fields throw. --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
6c65ae8257 |
perf(twenty-front): stop Sentry Replay from re-serializing record-table mutations on navigation (#21381)
## Problem
Navigating between record-index pages (e.g. People ↔ Companies) blocks
the main thread for seconds, on every navigation, for ~every user.
Profiling pointed at **Sentry Session Replay(rrweb)**, not app code.
## Root cause
Swapping one record table for another produces a large DOM mutation
batch. rrweb serializes that batch **synchronously on the main thread**
(`_isParentRemoved` / mutation processing).
The built-in `mutationLimit` safety valve doesn't help: it's a *count*
threshold (default 10000), but our cost is *per-mutation serialization*
on a wide/deep table DOM — the batch is expensive, not numerous, so it
slips under the limit.
## Fix
```ts
replayIntegration({
_experiments: {
ignoreMutations: ['[id^="row-virtual-index-"]'],
},
}),
```
- ignoreMutations tells rrweb to drop mutation batches originating from
the virtualized row containers (StyledVirtualizedRowContainer, ids
row-virtual-index-N) — the source of the
table-swap churn. The table still appears in replays (initial snapshot;
text is already masked by default), its live row updates just aren't
re-serialized.
## Test
Measured locally
```
┌────────────────────────────────────────────────────────────┬───────────┬───────────────┐
│ │ Baseline │ With fix │
├────────────────────────────────────────────────────────────┼───────────┼───────────────┤
│ Replay/rrweb total │ 4,112 ms │ 188 ms (−95%) │
├────────────────────────────────────────────────────────────┼───────────┼───────────────┤
│ _isParentRemoved │ 2,195 ms │ 6 ms │
├────────────────────────────────────────────────────────────┼───────────┼───────────────┤
```
## Tradeoff
ignoreMutations tells rrweb to skip mutation batches coming from the
virtualized record-table rows, so session replays won't reflect live
changes inside the table — rows scrolling, cells updating, inline edits
will appear "frozen" at the last full snapshot. The table still shows in
the replay (initial render), and **its text is masked by default anyway,
so in practice we lose little**: the surrounding UI, navigation, clicks,
and interactions are all still recorded. The cost we're removing
(multi-second main-thread freeze on every navigation, for ~all users)
**far outweighs not seeing table row churn in replays** imho.
(@FelixMalfait @charlesBochet)
Two caveats worth noting: _experiments.ignoreMutations is an
experimental Sentry API, and it's batch-coarse, if a mutation batch
contains any matching element, the whole batch is dropped, so an
unrelated change occasionally batched with table mutations could be
missed. During navigation these batches are almost entirely table
mutations, so collateral is minimal.
If it ever proves insufficient, the reliable fallback is
`data-sentry-block` on the record-table body (which turns the table into
a placeholder box in replays).
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
|
||
|
|
ba4ac6b70e |
fix(twenty-front): restore top-bar-title testid to unbreak merge queue (#21367)
## Problem The merge queue is broken. Every queued PR (#21357, #21361, #21364, #21366, …) fails on the same E2E assertion in `workflow-creation.spec.ts:36`: ``` Locator: getByTestId('top-bar-title').getByPlaceholder('Name') Error: element(s) not found ``` All other E2E tests pass, which pointed to a regression already on `main` rather than any individual PR. ## Root cause #21308 ("generalize the page primary/secondary bars (flat redesign)") switched `RecordShowPageHeader` from `PageHeader` to the new `PageCardHeader`. - The old `PageHeader` wrapped its title in `<StyledTitleContainer data-testid="top-bar-title">`. - The new `PageCardHeader` renders the breadcrumb slot **without** that `data-testid`. The editable record title cell (the `Name` input the test fills in) still renders fine inside `ObjectRecordShowPageBreadcrumb` — it just lost the `top-bar-title` wrapper that the E2E suite locates it by. The testid is also used by the `blank-workflow` fixture. ## Fix Restore `data-testid="top-bar-title"` on the record-show breadcrumb container, which wraps exactly what `PageHeader` previously did (the editable `Name` input and, after save, the record name text). Minimal and behavior-preserving; record-index and standalone pages use different header slots and were unaffected (their E2E tests passed throughout). |
||
|
|
bfefcd3755 |
feat(twenty-front): generalize the page primary/secondary bars (flat redesign) (#21308)
Replaces #21279 and #21282 with one clean PR from `main`. Generalizes the settings primary-bar / secondary-bar card chrome to the record index, record show and standalone pages via a shared `PageCardLayout` + `PageCardHeader` (the side panel sits as a sibling of the content card), and applies the new flat design direction: square corners on the card, side panel and loading skeletons. Iterating toward the new design (Figma node 102282-221623); the confirmed direction and the explicit "remove rounded corners" change are in, remaining designer specifics to follow. |
||
|
|
c596a5e342 |
Rename twenty-ui to twenty-ui-deprecated and twenty-new-ui to twenty-ui to prepare package release (#21315)
## Description Promotes the next-gen UI library (formerly `twenty-new-ui`) to the name **`twenty-ui`** (v0.1.0, publishable) and renames the old package to **`twenty-ui-deprecated`**. Rewrites ~1,730 `twenty-ui` imports → `twenty-ui-deprecated`, updates all configs/CI/Docker/deps, and migrates twenty-front's `Toggle` to the new package (first consumer) as a drop-in. ## Next steps - Wire the `ui/v*` publish dispatch (`cd-deploy-tag.yaml` + `.yarnrc.yml`), then tag `ui/v0.1.0` to publish. - Continue migrating components from `twenty-ui-deprecated` → `twenty-ui`. |
||
|
|
13e8e26d1c |
security: bump uuid 9 → 11 (server, shared, front) (#21326)
Clears the `uuid` "missing buffer bounds check in v3/v5/v6" advisory — patched in **11.1.1**. Bumps `twenty-server`, `twenty-shared`, `twenty-front` from 9 → `^11.1.1`. ### Why 11 and not 13 uuid **11.1.x still ships a CommonJS build**, so jest loads it with **no config changes**. uuid went **ESM-only at v12+**, which would otherwise force `transformIgnorePatterns` workarounds across the jest projects (and broke server/integration/storybook CI on the earlier 13 attempt). 11.1.1 is the actual patched version, so this is the minimal fix. ### Changes - `uuid` → `^11.1.1` in the three workspaces (lockfile regenerated under hardened mode) - one test (`useCreateManyRecords.test.tsx`): pin the mocked `v4` to its string-returning overload — uuid's types declare a `Uint8Array` overload that `jest.mocked` resolves to (present in v11 too, unrelated to ESM). All usages are named imports, so no source migration. typecheck passes (server/shared/front); affected specs pass. **No jest config changes.** |
||
|
|
e04eef0461 |
fix: wrong record count on deleted and normal records (#21292)
## Summary - Resolves #11977 - When looking into the deleted records from People tab (or any object list), the record detail header showing 0/(total records) instead of the correct position among deleted records only, e.g. 1/3 or 3/7. So, this PR makes the count match what users see in the deleted-records list. - Also normal records showing `0/N` in the header when opened from a list view (e.g. `0/48` -> `2/48`). ## Approach I tried to keep the change small and avoid extra server requests: - when a user came from a deleted-records view, we tell our existing queries to include soft-deleted records. - for the position number, we use the record list the user already had open (from the index view they came from) instead of apollo cache, which didn’t include records, especially deleted ones, but also normal records. - normal list behavior is not changed on the server side. ## Test plan - Open people/company, delete a record - Use the side menu -> “see deleted records” - open a deleted record’s details - confirm the header showing the correct position and total (e.g. 1/2, not 0/100) - for normal list: open People (normal list, not deleted) -> click a record -> open full page -> confirm header shows correct position and total (e.g. `2/48`, not `0/48`) ## Screenshots ### Before: <img width="1513" height="309" alt="Screenshot 2026-06-07 135204" src="https://github.com/user-attachments/assets/4754f1a7-8315-4a7a-815f-dda977b09331" /> <img width="1514" height="261" alt="Screenshot 2026-06-07 141735" src="https://github.com/user-attachments/assets/dd5b1834-5d84-49fe-8d20-633428d73502" /> ### After: <img width="1511" height="224" alt="Screenshot 2026-06-07 134946" src="https://github.com/user-attachments/assets/9450af7d-84b9-40bb-95e9-5a8665cc0923" /> <img width="1514" height="288" alt="Screenshot 2026-06-07 135045" src="https://github.com/user-attachments/assets/029ae632-ad7e-451e-8170-a4e4e71ac6f9" /> <img width="1512" height="229" alt="Screenshot 2026-06-07 141642" src="https://github.com/user-attachments/assets/576f4cad-a9e9-4380-aa67-e5f0e976a193" /> --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
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> |
||
|
|
1e336dbad1 |
feat: allow many-to-one relations as advanced filter leaves (#21147)
## What
Lets a many-to-one relation be selected as the **leaf** of an advanced
(nested) filter. Previously the nested-field submenu excluded relations,
so you could filter `Opportunities WHERE company.Name contains X` but
not `Opportunities WHERE company.accountOwner = me`.
## How it works
Selecting a relation leaf filters by its **foreign key** —
`company.accountOwnerId = X` — a single hop the backend already resolves
on the joined table (`{ company: { accountOwnerId: { in: [...] } } }`).
It is **not** a multi-hop traversal: filtering on a *scalar field of*
the related record (e.g. `company.accountOwner.name`) stays excluded,
since that needs a second join the backend caps at one hop.
Two changes:
- **`AdvancedFilterRelationTargetFieldSelectMenu`** — stop excluding
many-to-one relations from the nested-field submenu.
- **`ObjectFilterDropdownRecordSelect`** — resolve the record picker's
object from the *leaf* relation's target (e.g. WorkspaceMember,
including the "Me" pin) rather than the source relation's object. The
source-field fallback applies only when there is no leaf.
## Testing
- Added `turnRecordFilterIntoRecordGqlOperationFilter` unit cases
asserting a relation leaf (and `= me`) compiles to the FK form — 59/59.
- typecheck + lint green (twenty-front, twenty-shared).
Seeding an onboarding view that uses this filter will follow in a
separate PR.
|
||
|
|
2048efb75d |
fix(record-table): keep column header dropdown open after Move Left/Right (#21015)
Fixes #20999 ## Summary Fixes a UX issue where clicking **Move left** or **Move right** in the column header dropdown immediately closed the menu, forcing users to reopen it for every single move. ## Problem `handleColumnMoveLeft` and `handleColumnMoveRight` both called `closeDropdownAndToggleScroll()` unconditionally at the top of their handlers — before even checking `canMoveLeft` / `canMoveRight`. This immediately set the Jotai atom `isDropdownOpenComponentState` to `false`, unmounting the dropdown. Since move actions are **repeatable** — a user might want to shift a column several positions — they were forced into a frustrating loop: click header → click move → click header → click move → repeat for every step. ## Fix Removed the two `closeDropdownAndToggleScroll()` calls from the move handlers in `RecordTableColumnHeadDropdownMenu.tsx`. ```diff const handleColumnMoveLeft = () => { - closeDropdownAndToggleScroll(); - if (!canMoveLeft) return; moveTableColumn('left', recordField.fieldMetadataItemId); }; const handleColumnMoveRight = () => { - closeDropdownAndToggleScroll(); - if (!canMoveRight) return; moveTableColumn('right', recordField.fieldMetadataItemId); }; ``` All other handlers — **Filter, Sort, Hide** — are untouched and still close the dropdown correctly, since those are one-shot or navigation actions. ## Changes | File | Change | |---|---| | `RecordTableColumnHeadDropdownMenu.tsx` | Remove 2 `closeDropdownAndToggleScroll()` calls from move handlers | | `RecordTable.stories.tsx` | Add `HeaderMenuStaysOpenAfterMoveRight` regression story | ## Testing **Storybook interaction test** — `HeaderMenuStaysOpenAfterMoveRight`: clicks "Move right" then asserts the menu is still visible. **Manual checklist:** - [x] Move right → menu stays open - [x] Move right again → column moves again, menu still open - [x] Move left → menu stays open - [x] Move rightmost column → "Move right" disappears, menu stays open showing "Move left" - [x] Filter → menu closes *(unchanged)* - [x] Sort → menu closes *(unchanged)* - [x] Hide → menu closes *(unchanged)* - [x] Click outside → menu closes *(unchanged)* - [x] Escape → menu closes *(unchanged)* - [x] TypeScript: zero new errors (`tsc --noEmit`) --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
e1a00ea42f |
fix(twenty-front): enable text selection for display-mode fields (#21068)
## Description This PR resolves a usability issue where scalar field values (emails, phone numbers, dates, IDs, text, etc.) rendered in display-mode or read-only mode in the record detail side panel could not be highlighted, selected, or copied natively with the cursor. ## Root Cause Both `RecordInlineCellContainer` and `RecordInlineCellHoveredPortalContent` wrapper elements had `user-select: none;` hardcoded in their styled-component definitions. This styling propagated down to all nested display widgets, locking their content and preventing native text selection. ## Changes - Updated `StyledInlineCellBaseContainer` in `RecordInlineCellContainer.tsx` to use `user-select: text;` instead of `none;`. - Updated `StyledInlineCellBaseContainer` in `RecordInlineCellHoveredPortalContent.tsx` to use `user-select: text;` instead of `none;`. These changes restore natural browser text selection capabilities for record detail widgets without altering interactive edit-mode behaviors. ## Verification - Verified styling changes. - Tested locally to ensure that text highlighting and copy-pasting function correctly when dragging over read-only fields. Closes #21056 --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
6e00a122c6 |
fix(kanban): contain checkbox hover reveal within card bounds (#21100)
follow up to https://github.com/twentyhq/twenty/pull/20455 before - https://github.com/user-attachments/assets/e5a8a328-81ec-4dc4-8e54-1a54cf252135 after - https://github.com/user-attachments/assets/61cbb856-564c-487f-81e5-e27adc4a0d2d |
||
|
|
51202d5a32 |
fix(front): scroll long content in rich text editor (#20319)
## Summary Fixes scroll behavior in the rich text editor. Long content was unbounded — the popup grew off-screen, the expand-to-side-panel button became unreachable, and the side panel itself didn't scroll either. Closes #20309 ## Changes - `RichTextFieldInput`: cap the popup at `min(60vh, 500px)` and wrap the editor in a scrollable region. Collapse button stays at the top via `align-items: flex-start`. - `RecordInlineCellEditMode`: add `shift()` middleware to keep the popup inside the viewport after `flip()` triggers (previously the popup could extend above the viewport top with the collapse button out of reach). - `SidePanelContainer` / `SidePanelRouter`: add `min-height: 0` to the flex-column chain so the existing `overflow-y: auto` on the content area can actually clip and scroll long children. The previous attempt in #20310 added the same `min-height: 0` plus a nested overflow wrapper inside the rich-text page; the nested wrapper turned out to be the reason scrolling didn't work. ## Test plan - [x] Open a record with a long `RICH_TEXT` field - [x] Click the field — popup opens bounded; long content scrolls inside - [x] Click the expand button (top-right) — side panel opens - [x] Side panel: long content scrolls vertically - [x] Popup near the bottom of the viewport flips upward and stays fully inside the viewport (collapse button remains visible) - [x] `lint:diff-with-main`, `typecheck`, prettier — green https://github.com/user-attachments/assets/827be881-aadd-49ed-9ddc-7566c00cf4be --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3c97d9648b |
fix(address): show saved address in record detail when street1 is null (#21033)
## Fixes #20084 ### Problem A saved address is visible in the **table view** but shows **"Empty"** in the **record detail page** when `addressStreet1` is `null`. This reproduces with the default seed data out of the box — e.g. **Google** (city "Mountain View", no street), **Microsoft** (Redmond), **Meta** (Menlo Park) — which is why several users reported hitting it immediately. ### Root cause The frontend zod schema required `addressStreet1` to be a **non-null** string: ```ts // isFieldAddressValue.ts export const addressSchema = z.object({ addressStreet1: z.string(), // ← required non-null addressStreet2: z.string().nullable(), ... }); ``` …but the backend composite type marks it `isRequired: false` (`address.composite-type.ts`), and the DB column is nullable. So the API legitimately returns `addressStreet1: null` when only other subfields are filled. The two views diverge on how they render: - **Record detail** gates the value behind `useIsFieldEmpty()` → `isFieldValueEmpty()`, which for addresses calls `isFieldAddressValue()`. With `addressStreet1: null` the `safeParse` **fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"` placeholder is shown (`RecordInlineCellDisplayMode`). - **Table view** (`RecordTableCellDisplayMode`) renders `AddressFieldDisplay` directly with **no** empty check, so the address stays visible. This was a latent mismatch since the address guard was introduced. ### Fix Make `addressStreet1` nullable to match the backend and the other subfields: - `addressSchema` → `addressStreet1: z.string().nullable()` - `FieldAddressValue.addressStreet1` → `string | null` - `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the input/draft type consistent; the text input already renders `?? ''`) The change is strictly more permissive — persisting and the settings default-value form still accept string values; they now also accept `null`. ### Tests - `isFieldAddressValue.test.ts` — guard returns `true` for `addressStreet1: null` with other subfields filled. - `isFieldValueEmpty.test.ts` — new address coverage: empty address is empty; **`street1: null` + city filled is NOT empty**; normal address is not empty. (Added an `addressFieldDefinition` mock.) Both new assertions were confirmed to **fail before the fix** and pass after. ### Verification - `npx jest isFieldValueEmpty isFieldAddressValue normalize-address-field-value-for-persist` → 17 passed - `npx nx typecheck twenty-front` → pass - `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors |
||
|
|
bb4e28904f |
Support the "Me" filter for workspace members in dashboard widgets and add multi select (#20971)
Fixes https://github.com/twentyhq/twenty/issues/20225 The "Me" filter (current workspace member) worked in view filters but not in dashboard widget filters — the server never resolved the placeholder, and the widget side-panel UI had no "Me" option and only allowed single selection. Backend: `ChartDataQueryService` now forwards the current workspace member id (from authContext) into filterValueDependencies, so the shared filter logic resolves "Me" the same way it does for view filters. Added unit tests for the converter. Frontend: new multi-select picker for workspace member filters in the widget side panel, mirroring the view filter's actor select: search input, "Me" pinned item, and a multi-select workspace member list. ## Before <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16 36@2x" src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c" /> ## After <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14 05@2x" src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2" /> |
||
|
|
2f358a1775 |
Add a Table display mode to relation field widgets (#20929)
## Context Adds a new Table layout to the FIELD widget for to-many relation fields. On a record page, a relation can now be displayed as a full record table (the same component used for record indexes and dashboard table widgets) scoped to the records related to the current record. https://github.com/user-attachments/assets/320b24dc-f019-4d0e-bc71-3e64d032d75a https://github.com/user-attachments/assets/2f6d4f8e-de26-4fc1-ae12-c9b9c19654dc https://github.com/user-attachments/assets/3fb6d512-f83c-4818-823e-46ad2644fbc2 |
||
|
|
068d365731 |
feat(sdk): error on incompatible view filter operand at sync time (#20763)
view filters with mismatched operand + field type now error at sync -- was silently failing before |
||
|
|
323e66433e |
lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt regression Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog [post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094) This unifies our linting tooling --------- Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
138eb5a74a |
Add empty operands to UUID filter type in workflow filter action (#20821)
## Summary - Adds `IS_EMPTY` and `IS_NOT_EMPTY` operands to the UUID entry in `getStepFilterOperands`, aligning the workflow filter action with the find records (search) action which already includes these operands for ID-type fields. ## Test plan - [ ] Open a workflow with a filter action, select an ID-type field, and verify the operand dropdown now includes "Is empty" and "Is not empty" - [ ] Open a workflow with a find records action, select an ID-type field, and verify the operand dropdown is consistent with the filter action --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
1a9f786e42 |
refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary Alternative to #20717. Same goal (clean up the filter dispatcher API after #20670) but smaller and follows the codebase's "pass data, not behavior" style. The dispatcher takes a `fieldMetadataItems: FieldShared[]` array directly instead of a `findFieldMetadataItemById: (id) => FieldShared | undefined` callback. The util builds the id lookup internally — once per call, used for both source-field and relation-target-field lookups. No new types, no separate hydration step. ## What changes **`twenty-shared`** - `computeRecordGqlOperationFilter` / `turnRecordFilterIntoRecordGqlOperationFilter` / `turnRecordFilterGroupsIntoGqlOperationFilter`: replace `findFieldMetadataItemById` param with `fieldMetadataItems` / `fieldMetadataItemById` (internal Map). - Remove the exported `FindFieldMetadataItemById` type. - `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal `fieldById` Map for consistency. - Tests updated to pass arrays. **Frontend (15 call sites)** - Switch from `fieldMetadataItemByIdMapSelector` to `flattenedFieldMetadataItemsSelector`. - Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the dispatcher. - `useFindManyRecordsSelectedInContextStore` keeps the Map selector because it still does a per-filter lookup for the soft-delete check. **Server (5 call sites)** - Pass `Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`. ## Why this over #20717 #20717 moves resolution into a separate hydration step + introduces a `HydratedRecordFilter` type. The bug that #20717 originally surfaced was Sentry catching 4 critical runtime errors during review (`fieldMetadataItemByIdMap` declared but not passed). The added type and the explicit hydration boundary are extra surface area for not much benefit — the existing API was a callback wrapping a Map at every call site, and the natural simplification is to just pass the Map (or its array) directly. Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32 files. ## Test plan - [x] Shared filter unit tests pass (461 tests) - [x] Frontend filter/context-store tests pass (13 tests) - [x] Frontend typecheck passes - [x] Server typecheck passes - [x] Lint passes (frontend + server) - [ ] Integration tests on #20670 still pass — workflow find-records + chart-data with relation-traversal filter still work end-to-end through the new array param |
||
|
|
291ce5ccdb |
fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary Two relation-traversal bugs surfaced post-merge of #20533, both rooted in the same architectural smell: the GraphQL filter dispatcher took a flat `fields: FieldShared[]` array and silently dropped any filter whose `relationTargetFieldMetadataId` wasn't in that array. Callers had to remember to pre-augment the list with relation targets — and 16+ call sites did not all know this. This PR fixes both bugs and removes the smell. ### Bug 1 — Save as new view loses the relation target `useCreateViewFromCurrentView` built the create-filter input without `relationTargetFieldMetadataId`. The saved view's filter persisted without the traversal — on reload the chip showed "Company contains 'air'" instead of "Company → Name contains 'air'". Discarded at save time, not at read time. Fix: include `relationTargetFieldMetadataId` in the create input. (Commit 1.) ### Bug 2 — Workflow Search Records drops one-hop traversals `FindRecordsWorkflowAction` built its fields list from `flatObjectMetadata.fieldIds` only (source object's fields). The shared dispatcher then couldn't resolve the relation target field on the related object and silently dropped the filter — a configured "People where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`. This was the same shape as bugs already fixed in 5 other call sites (chart filters, view filters, record table, etc.). The pattern was: caller forgets to augment fields → dispatcher silently drops the filter. Fix (commit 2): change the dispatcher to take a `findFieldMetadataItemById: (id) => FieldShared | undefined` resolver callback. Both source-field and relation-target-field lookups go through the same resolver, so callers no longer need to know about the augmentation requirement. Frontend callers pass a workspace-wide resolver built from `flattenedFieldMetadataItemsSelector`; server callers wrap `findFlatEntityByIdInFlatEntityMaps` on `flatFieldMetadataMaps`. In both cases relation-target lookups just work, because the resolver can see fields on related objects. ## Why this matters Before: "if you call the dispatcher, pre-augment your fields list with relation targets, or filters get silently dropped." An invariant only enforceable by code review, broken often enough to ship two user-visible bugs in one week. After: the dispatcher resolves field ids itself. There's no list to forget to augment. The failure mode (filter silently dropped) becomes structurally impossible at the dispatcher boundary. Net diff: 240 insertions, 319 deletions. Removed `augmentFieldsWithRelationTargets` (frontend) and the workflow whack-a-mole code (server). ## Test plan - [ ] Save view: create an advanced filter using a one-hop relation traversal, click "Save as new view", reload, confirm the chip still reads "Source → Target operator value" - [ ] Workflow: configure a Search Records action with a relation-traversal filter, run the workflow, confirm the filter is actually applied - [ ] Dashboard chart: configure a chart with a relation-traversal filter, confirm the chart data respects it - [ ] Record table, group-by, calendar, total count, footer aggregates: all continue to work with both plain and relation-traversal filters |
||
|
|
c938fbf4d6 |
feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)
**Stacked on #20527** https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd ## Summary Surfaces the one-hop relation traversal added in #20527 through the existing **composite sub-field dropdown pattern**. Clicking a MANY_TO_ONE relation field in the "+ Filter" picker now opens the same second-level dropdown that composite fields (FULL_NAME, ADDRESS, CURRENCY, etc.) already use — populated with the target object's filterable fields. Picking one (e.g. `Company → Name`) builds a filter that serializes to the nested GraphQL filter the backend now accepts: `{ company: { name: { ilike: "%X%" } } }`. No new components. The whole feature reuses `AdvancedFilterSubFieldSelectMenu` + the existing `subFieldNameUsedInDropdownComponentState` + the existing `MenuItem hasSubMenu` indicator. Only the conditions that gate the sub-menu (and the sub-menu's content for relations) were broadened. ## What landed | File | Change | |---|---| | `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). | | `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu alongside composite clicks. | | `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu type is `'RELATION'`, render the target object's filterable fields via `useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite logic untouched. | | `objectFilterDropdownSubMenuFieldType` state | Widened to accept a `'RELATION'` sentinel. Role-permissions sub-field menu narrows it back out (it doesn't traverse relations). | | `useSelectFieldUsedInAdvancedFilterDropdown` | New optional `targetFieldMetadataItem` arg. When present, the stored RecordFilter's `type` is the target field's type so the operand picker and value input render the target's operands (`'TEXT'` operators when filtering `company.name`, etc.). | | `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter targets a `RELATION` field with a `subFieldName`, synthesize a field-metadata for the target, recurse to build the inner filter, then wrap it under the relation field's name → `{ relationName: { targetFieldName: { ...operator } } }`. | `RecordFilter.subFieldName` stays narrowly typed as `CompositeFieldSubFieldName` so the wide downstream consumers (`shouldShowFilterTextInput`, composite handlers in the serializer, etc.) don't change. The relation target field's name is stored through a narrowly-scoped cast at the dropdown's storage point — the serializer checks `filter.type === 'RELATION'` before interpreting it as a target field name, so the cast can't be mis-read by composite-only code paths. ## Test plan - [ ] Open a table view on People, click "+ Filter", click "Company" → sub-menu opens with Company's filterable fields - [ ] Pick "Name" → operand picker shows TEXT operators (Contains, Equals, …) - [ ] Type "Airbnb" → filter applies, table shows people whose company name contains "Airbnb" - [ ] Verify network tab: the GraphQL filter variable is `{ company: { name: { ilike: "%Airbnb%" } } }` - [ ] Same flow with a composite target field (e.g. `Company → annualRecurringRevenue → amountMicros`) — should work end-to-end (backend supports composite-within-relation; #20527 has an integration test covering this) - [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal sub-menu and filter correctly — no regression - [ ] Role-permissions field-select sub-field menu is unaffected (it bails out early on the RELATION sentinel) ## Out of scope - ONE_TO_MANY traversal (no backend support yet) - Aggregates (`people.count > 5`) - Persisting relation-traversal filters into a saved view (ViewFilter has no `relationPath` column yet; that's a separate slice) - REST API DSL changes - AI Tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e16977f97b |
[breaking: deploy server before front] feat(view-sort): pick sort sub-field inline on the chip (#20445)
## Summary Lets users choose which sub-field of a composite column to sort by — directly from the sort chip — by clicking the sub-field label and picking from a dropdown. Persists per view via a new nullable \`subFieldName\` column on \`ViewSort\`. Replaces #20438, which proposed a field-settings (admin) configuration for the same problem. The chip-level approach is more discoverable (the option lives where the user is looking) and per-view, so different views on the same object can sort by different sub-fields. ### What changes for users - **FullName columns**: previously sorted by \`firstName\` and \`lastName\` together as a stable dual-key sort. Now the user can pick which sub-field is primary (the other is the tie-breaker). Default remains \`firstName\` primary, \`lastName\` tie-breaker. - **Address columns**: previously not sortable at all (not in \`SORTABLE_FIELD_METADATA_TYPES\`). Now sortable, with a chip dropdown listing each enabled sub-field. Default is \`addressCity\` if enabled, else the first enabled sub-field. Disabling a sub-field at the field-metadata level (existing setting) removes it from the dropdown. - **Other composite types** (Currency, Phones, Emails, Links, Actor) and scalar fields keep their existing single-key sort behavior. ### UX ``` ┌─────────────────────────┐ ┌─────────────────────────┐ │ ↑ Name · Last name ✕ │ │ ↑ Address · City ✕ │ └────────┬────────────────┘ └────────┬────────────────┘ ▼ (click sub-field) ▼ ┌────────────┐ ┌────────────┐ │ First name │ │ Address 1 │ │ Last name ✓│ │ Address 2 │ └────────────┘ │ City ✓│ │ State │ │ Postcode │ │ Country │ └────────────┘ ``` The chip body still toggles direction on click — the \`Dropdown\`'s internal wrapper calls \`stopPropagation\` so the sub-field click doesn't bubble to the chip's onClick. ## What changed **Backend:** - \`ViewSortEntity\` — new nullable \`subFieldName: varchar\` column - \`ViewSortDTO\`, \`CreateViewSortInput\`, \`UpdateViewSortInputUpdates\` — new \`@Field(() => String, { nullable: true })\` - \`FLAT_VIEW_SORT_EDITABLE_PROPERTIES\` — \`'subFieldName'\` added so the property flows through the update merge path - \`ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME.viewSort\` — new \`subFieldName\` entry with \`toCompare: true\` so cache diffs notice it - \`fromCreateViewSortInputToFlatViewSortToCreate\` — threads \`subFieldName\` through - Instance command migration (\`add-sub-field-name-to-view-sort\`) — single \`ALTER TABLE core.viewSort ADD subFieldName varchar\` / \`DROP\` **Frontend:** - \`RecordSort\` and \`ViewSort\` types — \`subFieldName?: string | null\` - \`VIEW_SORT_FRAGMENT\` — adds \`subFieldName\` so the field round-trips - \`mapRecordSortToViewSort\` + \`areViewSortsEqual\` — carry the new field through, include it in the diff so the usual \`useSaveRecordSortsToViewSorts\` create/update flow fires when it changes - \`useSaveRecordSortsToViewSorts\` — passes \`subFieldName\` in both \`CreateViewSortInput\` and \`UpdateViewSortInputUpdates\` - \`getOrderByForFieldMetadataType(field, direction, subFieldName?)\` — new optional third arg. \`turnSortsIntoOrderBy\` threads \`sort.subFieldName\` into it. - \`Address\` added to \`SORTABLE_FIELD_METADATA_TYPES\` - New helpers: \`getEnabledAddressSubFields\` (filters by the field's \`subFields\` setting, falls back to the 6 default visible address sub-fields), \`getDefaultSortSubFieldForAddress\`, \`getDefaultSortSubFieldForFullName\` - New shared types/constants: \`AllowedFullNameSubField\`, \`ALLOWED_FULL_NAME_SUBFIELDS\`, \`DEFAULT_VISIBLE_ADDRESS_SUBFIELDS\` - \`SortOrFilterChip\` — new \`labelSubField?: ReactNode\` slot; renders as \` · {sub-field}\` with subdued weight after the main label - \`EditableSortChip\` — builds options from field metadata (\`ALLOWED_FULL_NAME_SUBFIELDS\` for FullName, \`getEnabledAddressSubFields\` for Address), uses i18n-wrapped labels, persists picks via \`upsertRecordSort\` ## Test plan - [x] \`npx nx typecheck\` passes for twenty-shared, twenty-front, twenty-server - [x] \`oxlint --type-aware\` on all 19 frontend + 9 server changed files: 0 errors - [x] \`prettier --check\`: clean - [x] 16 unit tests pass — \`getOrderByForFieldMetadataType\` covers the new \`subFieldName\` override branch for FULL_NAME and ADDRESS; \`getDefaultSortSubFieldForAddress\` covers the city/first-enabled fallback path; \`getDefaultSortSubFieldForFullName\` exercises its constant - [ ] Manual: sort a People view by Full Name → click the chip's sub-field label → switch between First name and Last name → reload page → choice is preserved - [ ] Manual: sort a Company view by Address → confirm dropdown lists only enabled sub-fields → disable Address \`addressCity\` in field settings → confirm dropdown options update and runtime falls back to the first enabled sub-field 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
d53f3e4ccc |
fix(kanban): give title full width when card is not hovered (#20455)
## Summary On kanban cards, the title was being truncated even when the checkbox wasn't displayed. The checkbox is hidden via `opacity: 0` on the card's non-hovered state, which keeps it in flex flow and still reserves its ~24px of width — so the title's flex item was shrinking unnecessarily. This change collapses the checkbox container's `max-width` to `0` (with `overflow: hidden`) while it's hidden, and expands it back to the checkbox's natural size (`spacing[6]` = 24px) on hover or when selected. The existing `transition: all ease-in-out 160ms` animates the title expanding into the freed space. ### Before Title truncates with ellipsis even though the checkbox slot is empty: <img width="350" alt="before" src="https://i.imgur.com/placeholder-before.png" /> ### After Title uses the full row width when not hovered; the checkbox slides in on hover (or when the card is selected) and the title reflows. ### Tooltip The full title is already exposed on hover when truncated — `RecordChip` → `Chip` already wraps the label in `OverflowingTextWithTooltip`, which detects overflow (`scrollWidth > clientWidth`) and renders an `AppTooltip` with the full text. No additional wiring needed. ## Test plan - [ ] On a kanban board, verify a long record title now uses the full card width when the card is not hovered (no ellipsis if the title fits). - [ ] Hover the card: the checkbox slides in smoothly (animated), and the title reflows (may now truncate if it doesn't fit). - [ ] Hover the (now-truncated) title: tooltip with the full title appears. - [ ] Select the card via the checkbox: checkbox stays visible (and title stays in its hover-state width) without hovering. - [ ] Compact view (eye icon) still renders correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
10876138d2 |
refactor: stop reading joinColumnName from relation field settings (#20304)
## Summary `joinColumnName` on relation field settings is always derivable from the field name (and the target object name for morph relations). This PR stops reading it from settings anywhere in production code; the stored value is no longer used. The settings field is **not** removed from data yet — a follow-up can drop it once we are confident nothing depends on the stored value. ## Helpers The helpers are split by layer because frontend and backend hold morph relations differently: the frontend has a base name plus a `morphRelations[]` array, the backend has one row per target with the name already morph-resolved. | Helper | Layer | When to use | |---|---|---| | `computeRelationGqlFieldJoinColumnName` | Shared / frontend (`gqlField`) | Non-morph relation on the frontend. | | `computeMorphRelationGqlFieldName` | Shared / frontend (`gqlField`) | Need the per-target morph gqlField name (e.g. `targetCompany`). | | `computeMorphRelationGqlFieldJoinColumnName` | Shared / frontend (`gqlField`) | Per-target morph join column on the frontend. Prefer over the non-morph helper for any morph field — it forces the per-target inputs. | | `computeMorphOrRelationFieldJoinColumnName` | Backend (`FlatFieldMetadata.name`) | Any backend read or write — the flat name is already morph-resolved, so one helper covers both cases. | | `computeMorphRelationFlatFieldName` | Backend (`FlatFieldMetadata.name`) | **Mutation paths only** (create / update / object rename). Reads consume the stored `field.name` and never call this. | ## Test plan - [x] Typecheck and lint (front, server, shared) - [x] Existing unit tests pass - [ ] CI green |
||
|
|
ee6c0ef904 |
Replace sign-in mocked metadata with hardcoded BackgroundMock (#20308)
## Summary When the user is logged out, we render the auth modal on top of a sample table to make the empty page feel alive. So far this was achieved by **loading a full set of mocked object / field / view / navigation-menu metadata into the runtime metadata store** and then mounting the real `RecordTable` and `AppNavigationDrawer` behind the modal. This had a few downsides: - Significant bundle weight pulled in for unauthenticated users (mocked GraphQL fixtures + the real `RecordTable` virtualization stack). - Plenty of code paths that had to know about the "showAuthModal" case (`useRecordIndexTableQuery`, `useTriggerInitialRecordTableDataLoad`, `MainContextStoreProvider`, `IsMinimalMetadataReadyEffect`...). - Any change to metadata-store internals or to the record-table runtime risked breaking the logged-out background. This PR replaces the entire flow with a small, self-contained `BackgroundMock` component tree that **does not consume any metadata** and **does not load any mocked metadata at runtime**. ### What changed - New module under `sign-in-background-mock`: - `BackgroundMockPage` + `BackgroundMockViewBar` + `BackgroundMockTable` + `BackgroundMockTableRow` render a hardcoded "Companies" table that visually mirrors the real one. - `BackgroundMockNavigationDrawer` renders a hardcoded sidebar with People / Companies / Opportunities / Tasks / Notes (with their standard colors). - Hardcoded constants in `BackgroundMockCompanies.ts`, `BackgroundMockColumns.ts`, `BackgroundMockNavigationItems.ts`. - `MinimalMetadataLoadEffect` no longer calls `loadMockedMetadataAtomic` for unauthenticated users — it just doesn't load anything. - `IsMinimalMetadataReadyEffect` now reports ready immediately when there is no access token pair, so the skeleton loader doesn't hang waiting for metadata that will never come. - `MainContextStoreProvider`, `useRecordIndexTableQuery`, and `useTriggerInitialRecordTableDataLoad` drop their `showAuthModal` branches — the real `RecordTable` is no longer mounted behind the modal. - `DefaultLayout` and `NotFound` now lazily load `BackgroundMockPage` / `BackgroundMockNavigationDrawer` instead of the deleted `SignInBackgroundMockPage` / `SignInAppNavigationDrawerMock`. - Removed: `SignInBackgroundMockPage`, `SignInBackgroundMockContainer`, `SignInBackgroundMockContainerEffect`, `SignInAppNavigationDrawerMock`, `SignInBackgroundMockColumnDefinitions`, `SignInBackgroundMockCompanies`, `SignInBackgroundMockViewFields`. `useLoadMockedMetadata` and `preloadMockedMetadata` are kept on purpose: Storybook decorators (`ObjectMetadataItemsDecorator`, `WorkflowStepDecorator`) still rely on the mocked metadata fixtures, but **production** unauthenticated runtime no longer touches them. ### Visual parity Side-by-side at 1440×900 on `/sign-in`: **Before** (loads mocked metadata + real RecordTable):  **After** (purely hardcoded BackgroundMock):  ## Test plan - [ ] `npx nx typecheck twenty-front` ✅ (passes locally) - [ ] `npx nx lint:diff-with-main twenty-front` ✅ (oxlint + prettier clean) - [ ] `npx jest useRecordIndexTableQuery` ✅ - [ ] Manually verify `/sign-in` renders the table + nav drawer behind the modal - [ ] Manually verify `/not-found` still renders the background - [ ] Verify CI: storybook, unit tests, e2e tests |
||
|
|
0608bae9ae |
fix(front): resolve labelIdentifier per target for morph relation depth=1 (#20305)
## Summary
On the show page, morph relations were showing "Untitled" entries for
targets whose `labelIdentifier` is not `name` (for example
`Note.title`). The GraphQL response only contained `id` for those
records.
`generateDepthRecordGqlFieldsFromFields` was hardcoding the morph
depth=1 sub-selection to `{ id, name }` for every target instead of
resolving each target's `labelIdentifier` (and `imageIdentifier`) from
`objectMetadataItems`, the way the non-morph relation branch already
does. The morph branch was also ignoring
`shouldOnlyLoadRelationIdentifiers`.
<img width="1300" height="860" alt="image"
src="https://github.com/user-attachments/assets/ebdb5287-0b4c-4a96-95a2-33b19b31446e"
/>
|
||
|
|
3d60e6dbfc |
Fix stale address coordinates after clearing autofill (#20264)
Closes #20082 |
||
|
|
36452ecc8b |
fix: show 'Not shared' for RLS-hidden morph relation records (#20272)
## Summary Follow-up to #20260. The `MorphRelationManyToOneFieldDisplay` component (used for polymorphic MANY_TO_ONE relations) was missing the FK-presence check that `RelationToOneFieldDisplay` already has. When RLS hides a related record (e.g., a Rocket with a policy filtering by name), the API response contains a populated FK (`polymorphicOwnerRocketId`) but a `null` relation object. The component was rendering an empty cell instead of the "Not shared" lock icon. **Fix:** - In `useMorphRelationToOneFieldDisplay`, read the record from the store and check if any morph relation FK field is populated while the relation value is null - In `MorphRelationManyToOneFieldDisplay`, render `<ForbiddenFieldDisplay />` when that condition is true | Scenario | FK in response | Relation object | Frontend display | |----------|---------------|-----------------|-----------------| | Live record | "abc" | `{ id: "abc", ... }` | Record chip | | Soft-deleted record | null | null | Empty cell | | RLS-hidden record | "abc" | null | "Not shared" | ## Test plan - Create a polymorphic MANY_TO_ONE relation (e.g., Pet → Rocket) - Add an RLS policy on the target object (e.g., Rocket name contains "Starship") - Verify the morph relation field shows "Not shared" (lock icon) for RLS-hidden records - Verify live records still display normally as record chips - Verify soft-deleted records still display as empty cells |
||
|
|
7c4302d02a |
fix: show empty cell instead of 'Not shared' for soft-deleted related records (#20260)
## Summary Fixes #20076 (supersedes #20250) When a related record is soft-deleted, the frontend displays "Not shared" (lock icon) because it sees a populated FK but a null relation object. This is misleading -- the record was deleted, not permission-restricted. **Backend fix** (`process-nested-relations-v2.helper.ts`): - For MANY_TO_ONE relations, widen the relation query with `.withDeleted()` and include `deletedAt` in the select - In `assignRelationResults`, if the matched record has `deletedAt` set, nullify both the FK and the relation object in the API response - Records filtered by RLS are still not returned (even with `withDeleted()`), so they correctly continue to show "Not shared" - Strip `deletedAt` from relation results before returning to the client **Frontend fix** (`RelationFromManyFieldDisplay.tsx`): - For ONE_TO_MANY junction relations, return `null` instead of `<ForbiddenFieldDisplay />` when junction records exist but target records are unavailable ### Three cases now handled correctly: | Scenario | FK in response | Relation object | Frontend display | |---|---|---|---| | **Live record** | `"abc"` | `{ id: "abc", ... }` | Record chip | | **Soft-deleted record** | `null` | `null` | Empty cell | | **RLS-hidden record** | `"abc"` | `null` | "Not shared" | ## Test plan - [ ] Create a record with a MANY_TO_ONE relation (e.g., a person linked to a company) - [ ] Soft-delete the related record (the company) - [ ] Verify the relation field shows an empty cell, not "Not shared" - [ ] Restore the related record and verify the relation reappears - [ ] Verify that RLS-hidden relations still show "Not shared" Made with [Cursor](https://cursor.com) Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
596ce32bd6 |
Fix front unit test on main (#20233)
Jest mocks runs before the const is defined |