Commit Graph

2068 Commits

Author SHA1 Message Date
Priyanshu Bartwal 17ff17bdec Dnd library migration fixes and changes (#23752)
Follow-up to #23211. Fixed issues, and simplified where possible.

The core idea: every sortable list now resolves its drop position the
same way —
"sortable over sortable", comparing the pointer against the hovered
item's
midpoint — instead of each surface owning bespoke droppable slots and
end-drop
zones.

## Refactors
- New `resolveDropFromPointer` handles both axes in one util; items can
tag their
  own `orientation`, so one provider can drive lists of mixed axes.
- Dropped `DragDropItemDroppableSlot` and `DragDropItemDropLine` path.
Record table/board headers, page-layout tabs & widgets, and fields
config all
derive the drop index from the hovered sortable, matching record-board
cards.
- `DragDropItemSortableCell` is now the single sortable primitive, with
drag
  optionally delegated to an explicit `DragDropItemSortableHandle`.
- Removed end-drop constants/types; lists now place a trailing append
target and
  resolve the append position in the consumer's own index space.

## Fixes
- Dragging a row within grouped records threw an error — the drag
overlay now
  resolves the source row's record-group context.
- Multi-select drag counter chip didn't show — drag state was read from
the
  wrong component scope instead of the active `recordIndexId`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23752?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: Félix Malfait <felix@twenty.com>
2026-08-05 14:51:51 +00:00
Félix Malfait 29e68a7f87 Refactor outbound email content compilation (#23782)
## Integration status

This is now the final landing PR for the reviewed editor/email
architecture stack.

| Order | Pull request | Scope | Status |
| --- | --- | --- | --- |
| 0 | #23657 | Advanced text editor capability presets | Merged into
`main` |
| 1 | **This PR** | Outbound email content compilation | Ready to land
into `main` |
| 2 | #23783 | Clean editor surface seam | Reviewed and merged into this
branch |
| 3 | #23790 | Shared editor block catalog | Reviewed and merged through
#23783 |
| 4 | #23791 | Canonical TipTap document persistence | Reviewed and
merged through #23790 |

The current branch tree contains the complete stack. Merging this PR
lands all four follow-up layers.

## Architecture

The stack establishes four reusable boundaries:

1. **Outbound compilation** — campaign, workflow, and one-to-one/tool
email share one compiler, sanitizer policy, renderer, and plain-text
derivation path.
2. **Editor surface profiles** — the generic editor owns rendering
mechanics while each consuming surface declares chrome, extensions, and
explicit compatibility readers.
3. **Shared block primitives** — sections, columns, HTML, images,
buttons, and related commands live in the neutral advanced-editor
catalog; email behavior is supplied by email schemas/rendering, not by
relocating reusable blocks into an email editor.
4. **Canonical persistence** — Twenty-owned authoring persists complete,
versioned TipTap JSON documents. HTML, Markdown, plain text, and
BlockNote are projections or explicitly owned legacy boundaries.

## Compatibility boundaries

Compatibility remains only where shipped data requires it:

- workflow Send Email: versionless TipTap JSON, HTML, and plain text
- inline email: HTML
- AI instructions: Markdown
- record rich text: BlockNote arrays and older Markdown/plain text

Campaign is unshipped, so its editor, stored rows, sendability
validation, and send-time compilation require the current canonical
schema version. AI chat drafts are canonical-only local state; old or
malformed drafts are rejected at hydration, and plain-text preprompts
are converted at their entry point.

## Outbound compiler details

The shared compiler owns:

- strict structured email-document parsing
- React-email rendering
- one cached DOMPurify/JSDOM policy for structured and legacy HTML
- plain-text derivation from sanitized HTML
- single-pass structured-document binding resolution across text,
variable tags, links, images, buttons, and raw HTML

Resolved workflow values remain inert, legacy workflow and one-to-one
HTML remain supported, and Campaign HTML/plain text come from the same
compiled result.

## Verification

- all automated standard/security reviews passed on the three merged
upper PRs with no unresolved threads
- shared TipTap/email codec tests: 20 passing
- editor, AI draft, and workflow compatibility tests: 14 passing
- campaign validation and compilation tests: 31 passing
- full shared suite during development: 223 suites / 1,738 tests passing
- twenty-front, twenty-shared, and twenty-server typechecks
- changed-file type-aware lint and formatting checks
2026-08-05 14:31:50 +02:00
Marie 1d755983ff Feat/advanced text editor capability presets (#23657)
# Email editor for Compose campaigns

## Short version

Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.

**Product**

- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker

**Technical**

- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria

**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.


---

## Detailed version

### Product requirements

**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.

**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.

#### What a user can now do

| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |

#### Deliberate product decisions

- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.

### Technical strategy

#### 1. Capability presets (the foundation)

Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.

Now:

- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do

The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.

#### 2. Schema / renderer split

The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.

```
twenty-front     TipTap extensions + node views + shared email settings UI
twenty-shared    versioned email-document schema + structural traversal
twenty-emails    react-email renderers (imported by twenty-server)
twenty-server    surface-specific variable resolution, validation, send
```

Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.

#### 3. Section typography cascade

The subtle part, and the one that would have silently shipped broken.

Section typography *looks* like it should cascade via CSS. It does not:

```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```

Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.

So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.

Verified against real rendered output:

| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |

#### 4. Storage

`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.

Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.

#### 5. Image hosting

Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.

This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.

Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.

### Bugs fixed along the way

- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.

### Review notes / known limitations

**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.

**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.

**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.

**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.

**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.

**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?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: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-08-05 10:38:06 +00:00
Abdul Rahman 5effee7754 Fix grouping a view that can no longer be changed or removed (#23619)
Fixes #23529


https://github.com/user-attachments/assets/2dbcf5ac-9b2e-4331-b7e8-703c8c5384b5



Grouping People by Company was a one-way door: once the view was
grouped, the grouping could neither be changed nor removed. Two
independent bugs on the same path caused it, and both had to be fixed.

## 1. The Group by entry was disabled, so the picker was unreachable

`ObjectOptionsDropdownRecordGroupsContent` disabled the `Group by` entry
whenever the object had a single groupable field. People exposes exactly
one (Company), so the entry was always disabled there.

That entry is the only way back to the field picker once a view is
grouped: `ObjectOptionsDropdownCustomView` sends `Group` to the picker
while the view is ungrouped, and to the group management screen once it
is grouped. With the entry disabled, the picker, and with it the `None`
option, became unreachable.

A table view can always drop its grouping through `None`, so the entry
now stays enabled there and is only disabled for layouts that require a
grouping.

## 2. The view groups created by the server were never synced back

The server deletes and recreates the view groups whenever
`mainGroupByFieldMetadataId` changes (`handleFlatViewUpdateSideEffect`),
and returns them in the `updateView` payload. `usePerformViewAPIUpdate`
only wrote the view itself back to the metadata store, so the
`viewGroups` entity kept the pre-change rows. The view create path
already syncs them; the update path did not.

On top of that, `useHandleRecordGroupField` overwrote the groups
returned by the mutation with client-generated ones whose ids matched no
persisted row, and `resetRecordGroupField` bailed out on
`viewGroups.length === 0`. Since a relation grouping legitimately starts
with no groups, clicking `None` was a no-op even when it could be
reached.

- sync the view groups returned by `updateView` into the metadata store
- use those groups instead of regenerating them client-side
- reset the grouping based on `mainGroupByFieldMetadataId`, and reload
the record index states so the table regroups and ungroups without a
refresh

## 3. Drive-by: No Value missing from the widget draft preview

`buildDraftViewGroupsForFieldMetadataItem` mirrors
`computeFlatViewGroupsOnViewCreate` so the page layout widget preview
matches what gets persisted, but it returned early for relation fields
and skipped the empty group. The server keeps creating it for nullable
fields, relations included, so the group appeared out of nowhere once
the widget was saved. It now skips only the option groups and keeps the
empty group.

## Not changed

Grouping by a relation shows no groups until you add them through `New
group`. That is intended, since a relation can have an unbounded number
of groups, and nothing here changes it.
2026-08-05 09:39:18 +00:00
Thomas des Francs 8a856a4bce Standardize dragged element feedback (#23772)
## Summary

- make dragged table rows use a consistent background across sticky
cells
- apply one shared opacity treatment to table rows, Kanban cards, and
other dnd-kit feedback

## Before/After

<img width="1400" height="1980" alt="drag-feedback-before-after"
src="https://github.com/user-attachments/assets/ee1fe81f-a762-4343-8db6-c51cf63dbac8"
/>
2026-08-05 07:35:46 +00:00
Charles Bochet 4b3614b413 fix(front): show relation value chip (Me / record names) in advanced filters (#23718)
## Problem

In advanced filters, a relation filter on a workspace-member field (e.g.
Assignee "is Me") displayed its raw JSON value
`{"isCurrentWorkspaceMemberSelected":true,...}` instead of a readable
chip.

Regular (non-advanced) filters handle this correctly:
`EditableRelationFilterChip` computes the label at runtime via
`useComputeRecordRelationFilterLabelValue`, rendering "Me", the selected
record names, or "N members".

The advanced filter value input instead relied on the deprecated stored
`displayValue` through `getRecordFilterDisplayValue`, which has no
`RELATION` branch and falls back to the raw value. When a saved view
filter carries no `displayValue` (it defaults to the raw stringified
value in `mapViewFiltersToFilters`), the raw JSON leaked into the UI.

## Fix

- Extract the relation value-label computation into a shared hook
`useComputeRecordRelationFilterDisplayValue` (parses the relation value,
resolves "Me" + record names).
- `useComputeRecordRelationFilterLabelValue` now consumes it (regular
chips unchanged).
- The advanced filter clickable select renders a dedicated
`AdvancedFilterRelationValueInputClickableSelect` for `RELATION`
filters, computing the label at runtime just like regular filters.

## Proof

Both filter surfaces render the relation value as **Me**, not the raw
`{"isCurrentWorkspaceMemberSelected":...}` JSON. The advanced-filter
shot loads a **saved view in a fresh session** — the exact bug
condition, where the view filter carries no stored `displayValue`.

**Regular filter chip**

<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/8a867f51-a538-46f2-ba21-a16bb70d85a5"
/>

**Advanced filter**

<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/b09b9d70-5692-4bac-8cec-3cb006961042"
/>

## Test

Verified manually on a local instance: created a saved view with an
advanced filter `Account Owner Is Me`, then reloaded it in a fresh
session — the condition where the view filter carries no stored
`displayValue`. The value renders as "Me" instead of the raw JSON.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23718?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. -->
2026-08-04 11:11:53 +00:00
Thomas Trompette e81fdbcc7a feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary

<img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30
40"
src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f"
/>

Adds workflow variable pickers to the **Search Records** action for
fields that previously only accepted static values:

- **Limit** and **Offset** number inputs now expose the
`WorkflowVariablePicker`, so they can be bound to a variable from a
previous step. The stored value can be a standalone variable string; the
backend coerces the resolved value back to a number.
- **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or
equal` (`IS_AFTER`) operands now expose the variable picker in the
advanced filter side panel (previously disabled for all date filters).

The backend already resolves these inputs via `resolveInput`; the only
backend change is a small numeric coercion of the resolved limit/offset.

## Changes

- `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to
the Limit/Offset inputs; make `onChange` and form state variable-aware
(`number | string`).
- `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date
`VariablePicker` only for `IS_BEFORE` / `IS_AFTER`.
- `useGetRecordFilterDisplayValue.ts` — return the raw variable for a
standalone `{{variable}}` value so date filters don't crash
`Temporal.*.from`.
- `find-records-action-settings-schema.ts` — allow a string (variable)
for `limit` / `offset`.
- `find-records.workflow-action.ts` — coerce resolved `limit` / `offset`
to numbers before querying.

## Testing

Built a workflow locally (Manual trigger → Code step returning `{ limit:
2, offset: 1, sinceDate }` → Search Records) with all three fields bound
to those variables. The run completed successfully; the Search Records
step returned exactly 2 records (limit applied) filtered by `createdAt
>= sinceDate`, confirming the backend resolves each variable.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?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. -->
2026-08-03 12:32:06 +00:00
Félix Malfait f663cd3c68 Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
2026-07-31 18:28:55 +02:00
Thomas Trompette 706d72e53e fix(front): reload record board groups when view groups change (#23637)
## Problem

Fixes #23462

On a Kanban (record board) view grouped by a SELECT field, adding a new
option to that field creates the column, but dragging a record into the
new column silently fails (no move, no error) until a hard page reload.

## Root cause

`RecordIndexLoadBaseOnContextStoreEffect` builds its load key from the
view id and the calendar-week flag only:

```
`${contextStoreCurrentViewId}-${isCalendarWeekViewEnabled}`
```

The effect early-returns when `loadedViewKey === currentViewLoadKey`.
When a new `ViewGroup` is created from the added SELECT option, the view
id does not change, so the key is unchanged and `loadRecordIndexStates`
never re-runs. The record group state (`recordGroupIdsComponentState` /
`recordGroupDefinitionFamilyState`) stays stale, so the drop handler
cannot resolve the new group's field value and the move no-ops. A hard
reload fixes it because the view then loads with the new group present
from the start.

## Fix

Include a signature of `view.viewGroups` (ordered
`id:position:isVisible`) in the load key so the effect re-runs
`loadRecordIndexStates` whenever the view's groups change, not only when
the view id changes. The calendar-week flag is kept in the key.

## Testing

Verified end to end on a local instance against an Opportunities "By
Stage" Kanban, with the record's stage change confirmed in the database:

- **Before the fix:** add a new Stage option in-session, then drag a
record into the new column. Dragging into an existing column persists
the move; dragging into the newly created column does nothing (record's
stage unchanged in DB).
- **After the fix:** same flow, dragging a record into the newly created
column moves it and persists the new stage in DB, with no reload.

`nx typecheck twenty-front` and `oxlint` pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23637?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. -->
2026-07-31 13:32:13 +00:00
avonian d02332834b Fix record title cell reopening on every page refresh (#23551)
## What

`location.state.isNewRecord` is set when navigating to a freshly created
record so the title cell opens for naming. But router state lives in
**browser history state, which survives page refreshes** — so every
refresh of that record's page re-opens the title cell with an empty
draft and a blinking cursor.

Strip the flag after its one intended consumption in `PageChangeEffect`
(react-router keeps user state under `history.state.usr`).

## Repro (on current main, any view set to open records in record page —
or on mobile)

1. Create a record from a table; you land on its record page with the
title focused (intended).
2. Name it, click away, then refresh the page.
3. The title cell re-opens, empty, focused — on every refresh, forever.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23551?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 Fable 5 <noreply@anthropic.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2026-07-31 12:48:23 +00:00
Thomas Trompette 3ed11054a0 Keep leading + when filtering phones by calling code (#23546)
Fixes #23528

Filtering a PHONES field with `CONTAINS` / `DOES_NOT_CONTAIN` stripped
every non-digit character from the filter value, so `+33` became `33`
and the generated `ilike`/`like` predicates could not distinguish an
international calling code from any number containing those digits.

`turnRecordFilterIntoGqlOperationFilter` now preserves a leading `+`
while still removing other formatting characters (spaces, dashes,
parentheses). `+33 6 12` becomes `+33612`; values without a `+` are
unchanged.

Added a regression test in `computeViewRecordGqlOperationFilter.test.ts`
for a `+`-prefixed value.

Lint and typecheck pass on `twenty-shared` and `twenty-front`; the
filter test suites pass in both packages.

---------

Co-authored-by: Thomas Trompette <tom@twenty.com>
2026-07-31 09:14:19 +00:00
Weiko 0d63906a58 Fix calendar field picker state handling (#23595)
## Context

Changing a calendar date field already updated the record-index calendar
state immediately, so the calendar moved to the new field before the
`updateView` mutation completed.

The options dropdown still derived its selected checkmark and field
labels from `currentView`, which remains unchanged while persistence is
pending. On slower environments this left the previous field name
visible even though the calendar was already using the new field.
Locally the same mismatch existed, but was only visible briefly because
the mutation completed faster.

## What changed

- Read the active start and end date field IDs from the record-index
calendar component state in the calendar options dropdown.
- Use that state for the main options label, the two-field submenu, and
both field-picker selections.
- Use the optimistic end-field state when filtering compatible start
fields and deciding whether an incompatible end field must be cleared.
- Keep the existing view mutation and calendar-state writes unchanged.

## Why

The calendar and its configuration UI now share the same source of truth
while persistence is pending. A field selection updates the calendar,
checkmark, and contextual labels together instead of temporarily mixing
optimistic calendar state with stale persisted view metadata.

## Safety and expected impact

This is frontend state synchronization only. It does not change the
metadata schema, API payloads, or persistence flow. Existing date and
datetime compatibility rules remain in place.

Users should see the selected field name update immediately, including
when the metadata mutation is slow.

## Limitations

This does not change mutation error handling or add rollback behavior.
The calendar atoms were already updated optimistically before this
change, this PR only makes the configuration UI reflect those same
values.

## Validation

- Reproduced the stale selection on qacoco and locally.
- Verified locally that the checkmark moves immediately after selecting
another date field, before the mutation closes the dropdown.
- `npx nx typecheck twenty-front`
- Focused type-aware oxlint on the four changed files, 0 warnings and 0
errors.
- `npx oxfmt --check` on the four changed files.
- `git diff --check`
2026-07-30 16:56:34 +00:00
nitin 079e9b8e56 feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649

Extends the Format option (Short/Full) added for the Number widget in
#21521 to bar, line and pie charts.

Format controls the numbers printed on the chart face: data labels and
the pie center metric. Axis ticks stay abbreviated and tooltips always
show the full value. Defaults to Short, so existing charts render
unchanged.

Server: nullable `numberFormat` on the bar/line/pie configuration DTOs,
exposed in the dashboard AI tool schema. No migration, configuration is
jsonb.

Deferred:
- The Format row has no visible effect while data labels are off, since
tooltips are always full.
- Number widget format defaults differ by field type (CURRENCY defaults
to Short, NUMBER to Full). Pre-existing, untouched here.


https://github.com/user-attachments/assets/0778f08a-6681-4e7a-8716-fb3026d1e01f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?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. -->
2026-07-30 09:42:21 +00:00
Weiko e2e2142998 Add calendar date field submenu with grouped field options (#23445)
## Summary

- Add a submenu for selecting calendar date fields as it was not clear
enough that you could pick 2 date fields.
- Group date and datetime fields with non-selectable headers

### Before
<img width="240" height="252" alt="Screenshot 2026-07-29 at 11 32 54"
src="https://github.com/user-attachments/assets/169bd965-676f-4ef9-8ba6-9ecac80547c6"
/>

### After
<img width="260" height="246" alt="Screenshot 2026-07-29 at 11 31 58"
src="https://github.com/user-attachments/assets/a4471bb0-5c1f-4f5e-bddb-165a5c9da4c7"
/>
2026-07-29 13:56:39 +00:00
Thomas Trompette a0281f635b Restore soft-deleted junction record when re-adding a junction relation (#23371)
Fixes #23305.

Removing a junction relation soft-deletes the intermediate junction
record. Re-adding the same relation created a brand new record, which
the composite unique index on the junction object (e.g. `personId` +
`companyId`) rejected with "This record already exists".

`useUpdateJunctionRelationFromCell` now creates the junction record
through `useCreateManyRecords` with `upsert: true`. The server matches
on the unique index with `withDeleted()` and clears `deletedAt` on the
matched row instead of inserting.

## This revives, it does not create

Worth being explicit, because it is a deliberate trade and not obvious
from the diff.

When a soft-deleted row exists for the pair, the user gets that row
back. Same id, same `createdAt`, same `createdBy`, and anything attached
to it (notes, files, and any fields the app added to the junction
object). Verified on a dev instance: after re-adding a link through the
picker, the row still reported a creation date from days earlier.

That is fine for a pure link. It is a lie for a junction that carries
data, for instance a `PersonCompanyRelationship` with a role and dates.
The alternative designs are hard-deleting on detach (needs
`canDestroyObjectRecords` on the junction object, which most roles do
not grant) and partial unique indexes (needs `indexWhereClause` exposed
to app-declared indexes, which the SDK does not support today). Both are
larger changes. This one unblocks affected apps without requiring
anything from them.

Note that upsert conflict detection ignores `indexWhereClause`, so
shipping partial indexes later would not change this behaviour on its
own.

## Why the id handling changed

The hook no longer sends a client-generated id. Under upsert the server
decides which row you get, so the id in the input would be discarded.

The optimistic store entry still uses a local id so the chip appears
immediately, then adopts the persisted id once the mutation resolves.
Without that, a revive left an id in the store that exists nowhere on
the server, and the next removal failed with "This record does not exist
or has been deleted".

Supersedes #23365, which took the same approach but added `$upsert` to
the shared `createOne` mutation. That capability already exists per call
on `createMany`, and widening the shared document broke the
`useCreateOneRecordMutation` and `useCreateOneRecord` tests.

## Testing

Verified end to end on a dev instance with a junction object carrying a
non-partial composite unique index, since the dev seed does not create
one and the bug cannot reproduce without it:

- before: re-adding after a removal fails with "This record already
exists"
- after: the original row is restored, `deletedAt` cleared, one row
throughout, and removing again in the same session works, with no
GraphQL errors

Added an integration test for the path this depends on: a soft-deleted
record matched on its unique fields alone, with no id in the input. The
existing coverage only exercised upsert by explicit id.

## Known gaps, deliberately not addressed here

**Toggling a link off while its creation is still in flight.** The store
id is provisional until the mutation returns, so a removal issued inside
that window deletes an id the server never received. Reproduced by
stalling the create and clicking remove during it:
`CombinedGraphQLErrors: Record not found`, and the link stays active
despite the user removing it. The window is one mutation round trip.
Left for a follow-up; the likely fix is a per-record operation queue so
adds and removes on a field run in click order.

**Junction objects with no unique index.** Remove then re-add still
creates a duplicate row there, on this branch as on main, because
conflict detection is driven by unique indexes.

---------

Co-authored-by: Shinu Cherian <129690295+Shinu-Cherian@users.noreply.github.com>
2026-07-29 12:17:18 +00:00
Félix Malfait b602294f1d Hide the command menu button while the mobile side panel is open (#23471)
On mobile the side panel covers the page, but the page header stays
mounted underneath. Its command menu button (`⌘K`, the `⋮` icon) sits at
the same coordinates as the panel's own close button, so the two icons
render on top of each other.

Measured on a 390x844 viewport with the AI chat open:

- `Command Menu` button at `x=346, y=8, 32x32`
- `Close side panel` button at `x=358, y=14, 24x24`

`SidePanelToggleButton` already hid itself for the command menu and
search pages, but the AI chat pages (`AskAI`, `ViewPreviousAiChats`) are
not in `COMMAND_MENU_SIDE_PANEL_PAGES`, so the button stayed and
overlapped.

## Change

Hide the button on mobile whenever the side panel is open, rather than
enumerating pages — the header is not reachable behind a full-screen
panel either way.

Layout customization mode is the exception and keeps it:
`alignWithSidePanelTopBar` deliberately repositions the button into the
side panel top bar there, so that path is preserved.

Desktop is unaffected.

## Testing

Three cases added to `SidePanelToggleButton.test.tsx` (hidden on mobile
with the panel open, kept on mobile in layout customization mode, kept
on desktop with the AI chat open); the `useIsMobile` mock is now
switchable per test. All 10 tests pass.

Verified in the browser at 390x844: with the AI chat open only `Close
side panel` remains in the top bar, and the button reappears once the
panel is closed.


---
_Generated by [Claude
Code](https://claude.ai/code/session_018gcsCQbuTMsyFWv874p25Q)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23471?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. -->
2026-07-29 09:19:24 +02:00
Félix Malfait 840c6d0129 Take openRecordIn from the view in scope instead of a global atom (#23422)
Stacked on #23424 (mobile chip navigation). Review that one first; the
diff shown here is only the delta.

## Problem

`recordIndexOpenRecordInState` was a global atom mirroring
`view.openRecordIn`. It was written whenever any index view loaded and
never reset, so a record chip behaved according to whichever view had
been browsed last:

- Companies view set to "record page". Open a Company, tap a related
Opportunity chip. The Opportunities view says "side panel", but the chip
reads the leftover Companies setting and opens a full page.
- Visit the Opportunities index first, then the same Company page, and
that same chip now opens a side panel.

The setting is per view in the database, but the frontend kept it in one
slot as though it were a user preference.

## Approach

The value already lives on the view, so the mirror is deleted rather
than scoped:

- `useResolveOpenRecordIn` reads the current view of the surrounding
context store. On a record index that is the view being displayed. On a
record show page `MainContextStoreProvider` resolves a view for the
object in the URL — the last visited view for that object, falling back
to its index view — so chips there follow a view belonging to the object
they sit on, rather than whatever was loaded last.
- Where no context store is mounted at all (a mention inside a note, for
instance) there is no view to take a setting from, so the hook falls
back to `DEFAULT_VIEW_OPEN_RECORD_IN`. The instance lookup is
non-throwing on purpose: `RecordChip` renders in a lot of places, and an
existing test caught this crashing when the read was strict.
- The options dropdown now reads and writes `currentView.openRecordIn`
directly, the same way `isCompact` beside it already works, so
`setAndPersistOpenRecordIn` only has to persist.
- `useGetOpenRecordIn` is gone; every call site had the object name
available at render, so the reactive hook covers all of them.

## Behaviour change

A chip whose behaviour previously came from an unrelated view now
follows the view in scope. That is the point of the change, but it does
mean some chips will open somewhere different from before, always in the
direction of "what this list is configured to do" rather than "what the
last list was configured to do".

## Testing

- New `useResolveOpenRecordIn` tests: falls back to the default with no
context store, follows the context store's view when there is one.
- Full frontend suite: 951 suites, 5598 tests passing. Typecheck and
lint clean.
- Not exercised in a running app: no database in this environment. The
dropdown's optimistic behaviour in particular relies on the same view
store refresh that `isCompact` already depends on, so it is worth a
click-through before merge.
2026-07-28 22:53:05 +02:00
Félix Malfait 8990334cad Make record chips open records natively on touch devices (#23424)
Two mobile problems in the record table: tapping a chip in the first
column takes two taps, and chips in every other column open a side panel
where a full page is wanted. #23422 is stacked on this branch.

## Two taps to open a record

The table's interactive layer lives in a hover portal mounted from
`onMouseMove` on the table wrapper. Touch has no hover, so the browser
fakes one, and the synthesised `mousemove` arrives *before* the
`mousedown`. React commits the portal in the microtask between them, so
the whole tap is hit-tested against a subtree that did not exist when
the user aimed.

Confirmed in Chromium with real touch input (`page.tap`, Pixel 5
emulation), mounting an overlay from the mousemove handler:

```
mousemove target=chip
>>> overlay mounted          <- the hover portal
mousedown target=portalChip  <- a node that did not exist when the finger went down
mouseup   target=portalChip
click     target=portalChip
```

The same test also ruled out `preventDefault` on the compat `mousedown`
as a cause, and showed a `setTimeout`-deferred mount does *not* retarget
— it is specifically React's sync flush timing that does.

So hover state is now only tracked on hover-capable pointers.
`useMoveHoverToCurrentCell` becomes the single writer and absorbs the
deduplication `RecordTableContent` was duplicating inline.

The interaction/layout split matters here: `useIsMobile` is a 768px
width query, which answers "how much room is there to lay out", not "how
does this person point". The new `useIsTouchDevice` uses `(hover: none)
and (pointer: coarse)`. Layout keeps using width; interaction uses
capability.

## Side panel on mobile

"Where does a record open" was computed independently in six places and
only `useOpenRecordFromIndexView` knew about mobile. `RecordChip` —
every chip outside the first column, plus board cards and relation
fields — had its own copy without that check. On mobile the side panel
animates to `fullScreen`, so it is a full-page view with no URL and no
back button.

That decision now lives in one `resolveOpenRecordIn`: the view setting
is an intent, and the side panel is only a real destination when there
is room for it and the object supports it.

Also here: `MOUSE_DOWN` navigation downgrades to `CLICK` on touch. It
only buys a frame on a real pointer, since a tap synthesises its mouse
events after the finger is already gone.

## Hover styling

Separate layer, same root cause. A tap leaves CSS `:hover` applied until
the next tap lands elsewhere, so a row you came back from keeps reading
as selected. Nine `:hover` blocks across the record table, `Chip` and
`Avatar` are now fenced behind `(hover: hover)` — the same media feature
`useIsTouchDevice` branches on, via a new `hover-capable` SCSS mixin on
the twenty-ui side and inline media queries in the Linaria components.

Desktop rendering is unchanged, since Chrome matches `hover: hover`.
Verified the built CSS emits the wrapper correctly, and checked the
nested form through stylis directly for the Linaria side.

## Testing

- New unit tests for `resolveOpenRecordIn` and for hover not being
tracked on touch devices.
- Full frontend suite: 951 suites, 5598 tests passing. Typecheck and
lint clean.
- Not observed end to end in a running app: no database in this
environment, and the `RecordIndexPage` story renders an empty table
under its msw mocks. The browser-level mechanism is verified and the fix
removes the mid-gesture DOM change, but it is worth one pass on a real
device before merge.

## Follow-ups not in this PR

- The whole first cell navigates but only the chip-sized part of it
gives tap feedback, and `isRecordTableRowActive` is only set on the side
panel path — setting it on the navigate path too would keep the row lit
while the page loads.
- Rows are 32px against a 44px minimum touch target.
- Giving the side panel a URL would make "panel vs page" a rendering
decision on the same location, rather than something each call site has
to branch on.


---
_Generated by [Claude
Code](https://claude.ai/code/session_019cDWPgWESbdRUhGxGb66j8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23424?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. -->
2026-07-28 22:44:36 +02:00
Félix Malfait 4f31265927 Fix 1px gap above the record table header (#23441)
## Problem

A transparent 1px slit shows up between the view bar and the table
header row, letting the scrolled records show through above the column
names.

The record table header is `position: sticky; top: 0` inside the table's
scroll container. On fractional device pixel ratios (scaled displays,
browser zoom) the compositor can land the sticky header half a device
pixel below the top edge of the scroll container, so its topmost device
pixel row is painted with the scrolled content behind it instead of the
header background.

## Repro

Reproduced locally on `/objects/companies` with `deviceScaleFactor`
1.25, 1.75 and 2.25 — the slit appears at specific vertical scroll
offsets (e.g. `scrollTop` 47 at DPR 1.75), and never at integer ratios.

Before (DPR 1.75, `scrollTop` 47) — the row underneath bleeds through
above "Name":

<img width="960" alt="before"
src="https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000">

## Fix

Extend the header background 1px upwards with a `box-shadow` on the
sticky container, so whatever half-pixel the compositor exposes is
always covered. The shadow is painted as part of the sticky layer, so it
follows the header wherever it lands.

Nothing changes visually otherwise: when the table is scrolled to the
top the shadow sits above the scroll container's padding box and is
clipped away.

## Verification

Scripted pixel scan of the top device-pixel row of the header, over
scroll offsets 1-60 at DPR 1.25 / 1.5 / 1.75 / 2.25 / 2.5:

| | before | after |
|---|---|---|
| DPR 1.25 | 3 offsets with a visible slit | 0 |
| DPR 1.5 | 0 | 0 |
| DPR 1.75 | 2 | 0 |
| DPR 2.25 | 3 | 0 |
| DPR 2.5 | 0 | 0 |

Also checked at rest (`scrollTop` 0) and while scrolled at DPR 1 and 2
that no extra line appears above the header.

`oxlint`, `oxfmt` and `nx typecheck twenty-front` pass.


---
_Generated by [Claude
Code](https://claude.ai/code/session_014gaDhmeDSNjdBeRRepKPAr)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23441?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. -->
2026-07-28 16:49:12 +02:00
Thomas Trompette 11447bd96f fix: make add-select-option work on record detail pages (#23420)
Fixes #23339

Follow-up to #23410, which fixed the neighbouring issue (#23341) for
users *without* the `DATA_MODEL` permission.

## Problem

In #23339 the reporter added a multiselect field to Person and then
found the inline "create option" prompt unresponsive. They clearly have
`DATA_MODEL` permission, since they just created the field, so the
permission check isn't what's blocking them.

The blocker is the *other* precondition. Both hooks read the object name
from the router:

```ts
const { objectNamePlural } = useParams();
```

`objectNamePlural` only exists on record index routes
(`/objects/:objectNamePlural`). Record **detail** pages are
`/object/:objectNameSingular/:objectRecordId`, so on a record page the
param is `undefined` and:

- `useCanAddSelectOption` returns false via
`isNonEmptyString(objectNamePlural)`
- `useAddSelectOption` bails out at `if (!fieldName ||
!objectNamePlural) return;`

So the action was dead on record pages for **every** user, admins
included, and the navigation target it needed was never reachable from
there.

## Fix

Resolve the field and its object from `fieldMetadataId`, which
`FieldDefinition` already carries, instead of reading the object name
off the URL:

```ts
const { fieldMetadataItem, objectMetadataItem } =
  useFieldMetadataItemById(fieldMetadataId);
```

This drops the route dependency entirely, so the action behaves the same
wherever the field is rendered, and `canAddSelectOption` now reflects
only the real permission check.

It also lets both hooks take a single `fieldMetadataId` argument:
`fieldMetadataItem.name` is the same value the callers were previously
passing as `fieldName`, so that parameter is no longer needed. Resolving
both values from one id means the guard and the action can't disagree
about which field they're describing.

`useFieldMetadataItemById` is used rather than
`useFieldMetadataItemByIdOrThrow` because a lookup miss should disable
the prompt, not crash the field input.

## Reproduction

On the code the reporter was running (immediately before #23410), as an
**admin** with full `DATA_MODEL`, on a company record page, typing a
value matching no option:

- `Add "…" to options` renders
- clicking it does nothing — URL unchanged, no navigation
- pressing <kbd>Enter</kbd> does nothing either

which matches #23339 exactly, including the note about the Enter
keypress.

After #23410 the same root cause shows up differently: the prompt is no
longer rendered at all on record pages, since the guard it's now gated
on is false there. Still broken, just silent.

## Testing

Verified manually on a local instance, swapping only these files between
three states and re-running the identical steps on the same cell.

| code state | user | route | result |
|---|---|---|---|
| before #23410 | Admin | `/object/company/:id` | prompt shown, click
and Enter both do nothing |
| current main | Admin | `/object/company/:id` | prompt not shown,
action unreachable |
| **this PR** | Admin | `/object/company/:id` | prompt shown, navigates
to `/settings/objects/companies/workPolicy?newOption=…` |
| **this PR** | Admin | `/objects/tasks` (`Status`, single select) |
still works, navigates to `/settings/objects/tasks/status?newOption=…` |
| **this PR** | Member (no `DATA_MODEL`) | `/object/company/:id` |
prompt not shown |

The settings form opens with the typed value prefilled alongside the
existing options, so the end-to-end flow works from a record page for
the first time.

The Member row confirms #23341 stays fixed: dropping the route
dependency doesn't weaken the permission gate. The single-select row
covers `SelectFieldInput`, which takes the same change.

`nx lint:diff-with-main twenty-front` and `nx typecheck twenty-front`
both pass.
2026-07-28 12:27:26 +00:00
Thomas Trompette 38e9d231bc fix: hide add-select-option prompt for users without data model permission (#23410)
Fixes #23341

## Problem

A user whose role lacks the `DATA_MODEL` permission flag still saw the
`Add "…" to options` prompt when typing a value that matched no option
in a multiselect. Clicking it did nothing.

`MultiSelectInput` renders `AddSelectOptionMenuItem` based purely on
whether the callback exists:

```tsx
{onAddSelectOption && searchFilter && filteredOptionsInDropDown.length === 0 && (
```

`MultiSelectFieldInput` always passed a callback, and did the permission
check *inside* it:

```tsx
const handleAddSelectOption = (optionName: string) => {
  if (!canAddSelectOption) {
    return;
  }
  addSelectOption(optionName);
};
```

So the guard suppressed the click but not the render, which is exactly
the reported symptom: the prompt is visible and inert.

## Fix

Gate at the prop instead of inside the handler, so the menu item is
never rendered when the action is unavailable:

```tsx
onAddSelectOption={canAddSelectOption ? addSelectOption : undefined}
```

The wrapper is now redundant and removed; `addSelectOption` already has
the matching `(optionName: string) => void` signature.

`SelectFieldInput` had the byte-identical bug (`SelectInput` gates on
`onAddSelectOption &&` the same way), so it gets the same change.

## Note on scope

`useCanAddSelectOption` requires `objectNamePlural` from the route in
addition to the permission flag:

```ts
const canAddSelectOption =
  userHasPermissionToEditDataModel &&
  isNonEmptyString(fieldName) &&
  isNonEmptyString(objectNamePlural);
```

Record *detail* pages (`/object/:objectNameSingular/:recordId`) have no
`objectNamePlural`, so the prompt was dead there for **every** user,
admins included. This change hides it in that case too, which is the
correct behavior since the click could never have worked.

## Testing

Verified manually against a local instance, toggling the patch in and
out on the same cell so before/after is directly comparable.

Company `Work Policy` (multiselect) in the Companies table view, typing
a string that matches no option:

| user | route | before | after |
|---|---|---|---|
| Member (no `DATA_MODEL`) | `/objects/companies` | prompt shown, click
does nothing | prompt hidden |
| Admin | `/objects/companies` | prompt shown, click works | prompt
shown, click works (navigates to
`/settings/objects/companies/workPolicy?newOption=…`) |
| Admin | `/object/company/:id` | prompt shown, click does nothing |
prompt hidden |

`nx lint:diff-with-main twenty-front` and `nx typecheck twenty-front`
both pass.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23410?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. -->
2026-07-28 11:33:12 +00:00
neo773 1e58c3073c Feat/email composer improvements (#23188)
- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?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: Félix Malfait <felix.malfait@gmail.com>
2026-07-28 13:13:00 +02:00
Thomas Trompette 48f4c1661b fix(sse): resync records on reconnect and recover from silent query listener errors (#23357)
## Context

Live updates stop working in prod in a way that only a page refresh
fixes. Two independent holes in the SSE self-healing path, both silent.

## 1. Reconnecting restored the stream but never the data

Events emitted while the stream was down are not replayed, so any record
change during the gap stayed missing from the UI indefinitely. On
reconnect `SSEClientEffect` called `resyncMetadataStore()` and
dispatched `SSE_CLIENT_RECONNECTED_EVENT_NAME`, but the only listeners
were `AgentChatMessagesFetchEffect` and
`AgentChatStreamKeepAliveEffect`. No record surface listened. Metadata
self-healed, records did not.

This fires on every deploy, laptop sleep and network blip, and the
reconnect backoff is a uniform random draw up to 2 minutes, so the gap
is routinely long.

The event was also incomplete. It was dispatched on graphql-sse
transport reconnects only. When the keep-alive watchdog or an error set
`shouldDestroyEventStream` and `SSEEventStreamEffect` built a
replacement stream, nothing was dispatched at all.
`useTriggerEventStreamCreation` now dispatches it from the creation path
too, for every stream that replaces an earlier one in the tab.

Each surface is wired to the resync path it already uses, through an
optional `onSseReconnected` on `useListenToEventsForQuery`. That hook is
the single funnel every SSE subscriber already goes through, so the tab
reloads exactly what it declared an interest in and nothing else:

- Record table: reset virtualization, plus
`useRefetchAggregateQueriesForObjectMetadataItem` for the header count,
which is served by a separate aggregate query that the row reset does
not touch.
- Record board: `triggerRecordBoardInitialQuery({ shouldResetScroll:
false })`. Scroll position preserved.
- Workflow versions: `shouldWorkflowRefetchRequest`.

These are the same resets each component already runs on every record
event, so the only new thing is the trigger. `SSEClientEffect` keeps the
metadata store resync, which is genuinely global; that also fixes the
matching gap on the metadata side, since `resyncMetadataStore()` used to
be called straight from the graphql-sse `connected` callback and so
never ran for a watchdog- or error-driven stream re-creation.

**Known gap:** the record show page and workflow run detail are not
covered. Both read through `useFindOneRecord`, but their subscription
lives in a sibling component with no access to `refetch`. Wiring them
needs either `refetch` exposed from `RecordShowEffect` /
`useWorkflowRun`, or the subscription moved into the data owner — the
latter changes subscription lifetime, and `useListenToEventsForQuery`
unregisters by `queryId` on unmount regardless of other consumers. Left
out pending a decision.

## 2. A network error on `addQueryToEventStream` silently unsubscribed a
view forever

`handleError` in `SSEQuerySubscribeEffect` only reacted to
`CombinedGraphQLErrors`. On Apollo Client v4 a network failure or a 5xx
from a rolling pod surfaces as `ServerError` or a plain `Error`, so the
handler was a complete no-op: no Sentry capture, no stream teardown, and
`syncAdditions` returned before recording the listener as active.

Since neither `requiredQueryListeners` nor `activeQueryListeners`
changed, the driving effect never re-ran. That query stayed unregistered
server-side for the rest of the session while the stream looked healthy
and every other view kept updating live. Recovery required remounting
the component or refreshing.

The recovery now runs for every error type.
`getGraphqlErrorExtensionsFromError` is called unconditionally: it
accepts `unknown` and reads `extensions` off any object-shaped error, so
an error carrying a gracefully-handled `code` is still recognised as one
whether or not it is a `CombinedGraphQLErrors`.

Note: errors without extensions now reach Sentry, since
`isGracefullyHandledEventStreamError` returns false for them. That is
new noise during outages, but this failure class is currently completely
invisible.

## Testing

- `nx typecheck twenty-front` and oxlint `--type-aware` + oxfmt pass.
- Verified on a local instance with an A/B/A run. A row inserted
straight into Postgres emits no SSE event, which is exactly the state
after a disconnect; restarting the server then forces a reconnect. With
the fix the row appears and the count updates with no page reload;
reverted to `main` it stays invisible indefinitely. Redis confirmed the
stream had reconnected and re-registered its queries in the negative
run, so that result is the missing resync rather than a dead stream.
- Fix 2 is **not** exercised at runtime — it needs a network-level
failure on the `addQueryToEventStream` mutation specifically. Reasoned
about only.
- There are no existing tests for the `sse-db-event` module.

## Known gap

A tab's very first stream is not treated as a reconnection, since
`isRecreatedEventStream` is derived from
`lastSseEventReceivedTimestampState` already being set. If that first
stream connects but never receives its first message and is then
replaced, no resync is dispatched. That is the separate issue of
`SSEKeepAliveEffect` being gated on `sseEventStreamReady`, which is
itself only set by the first message: a stream that never becomes ready
is never watched and never torn down. Not addressed here.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23357?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. -->
2026-07-27 16:21:08 +00:00
Raphaël Bosi 590ae069e8 Support workspace member Me filter for relation fields in dashboards (#23282)
<img width="3024" height="1484" alt="CleanShot 2026-07-27 at 15 04
22@2x"
src="https://github.com/user-attachments/assets/a57797b7-dbe9-4748-aeff-18667f1f69bb"
/>


Fixes #20225

Workspace member "Me" filters worked for standard actor fields (Created
by / Updated by) in dashboard widgets but not for relation fields
pointing to a workspace member (e.g. "Account owner"). In the
advanced-filter UI, picking such a relation forced a relation traversal
and never produced a filter you could set to "Me".

For a many-to-one relation targeting workspaceMember, the
relation-target sub-menu now offers a "filter by record" entry that
creates a direct relation filter with the same "Me" multi-select picker
used by view filters. Traversal (e.g. "Account owner -> Name") is
preserved. No backend change is needed: the stored value matches view
filters and is already resolved server-side.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23282?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. -->
2026-07-27 13:51:05 +00:00
Félix Malfait 3a8f086d15 Converge drag and drop on shared dnd-kit primitives, remove @hello-pangea/dnd (#23211)
Follow-ups recorded in #23023, done in one pass.

## Shared primitives

- Folded `PageLayoutWidgetSortableItem` and `PageLayoutWidgetDropLine`
into the shared `DragDropItemSortableCell` / new `DragDropItemDropLine`
(new `data`, `dropLine`, `highlightWhileDragging`, `hasTransition`
props).
- Added generic `DragDropProviderDragStartEvent` (and
DragMove/DragOver/DragEnd/DropTarget) helpers and deleted the 7 copied
`Parameters<...>` extractions across the dnd hooks.
- Replaced the `useMovePageLayoutWidgetUp/Down` implementations (~140
lines) with `moveWidgetWithinTabInDraft`.
- Migrated the remaining page-layout test suites onto
`pageLayoutDraftFixtures`.

## Tab reordering off Pangea

- Tabs are sortable cells on the same provider as widget drags,
segregated by dnd type, so widget drops on tab buttons keep working
while tabs reorder.
- Reordering is ID based (`reorderTabInDraft`: insert before the hovered
tab), which keeps the pinned first tab in place without index
arithmetic.
- Preserved overflow behaviors: the dropdown stays open while a tab drag
is in flight, dropping a tab on the "+N More" button appends it and
opens the dropdown, and both the visible strip and the overflow list
have end drop zones.

## Fields configuration editors off Pangea

- Group reorder, field reorder and cross-group field moves now run on
the shared cells (same drop line and end-zone patterns).

## DraggableList off Pangea

- `DraggableList` / `DraggableItem` keep their consumer-facing API — the
~9 consumers now type their handlers with a local
`DraggableListDropResult` instead of pangea's `DropResult` — but run on
the shared sortable cells; each list's uuid group doubles as its dnd
type so nested lists stay isolated from page-level providers.
- Items register their index in a list-scoped registry so the end drop
zone can resolve the append index at drop time (with insert-before
semantics an item could otherwise never reach the last position).
- Deleted three dead files that only existed for pangea plumbing (the
side panel navigation placeholder, `getCssCompatibleDraggableProps`, the
orphaned `recordGroupPendingDragEndReorderState`).

## Record table row drag off Pangea

- Rows register through `useSortable` directly on the row element — no
wrapper div, so row CSS, sticky cells and virtualization stay untouched
— with the grip cell wired as the drag handle via the shared sortable
handle ref context.
- Both table modes (virtualized flat list and record groups) share a
`DragOverlay` clone that replaces pangea's virtual-mode `renderClone`,
and end drop zones per record group (and after the virtualized list)
allow dropping after the last row or into an empty group.
- The drop handlers keep their pangea-shaped result object, retyped as a
local `RecordDragDropResult`, so the position computation logic is
untouched.

## Pangea removed

`@hello-pangea/dnd` is gone from `package.json` and the lockfile, along
with its orphaned transitive entries (`css-box-model`, `raf-schd`,
`react-redux`, `redux`). Nothing in the repo imports it anymore.

## Dashboards: cross-tab widget drag for grids

react-grid-layout drags never enter dnd-kit, so the bridge hit-tests the
pointer against the tab buttons' `data-page-layout-tab-drop-target-id`
rects during grid drags, highlights the hovered tab through state, and
on drop moves the widget to the destination grid below its existing
content (`moveWidgetToGridTabInDraft`, `buildTabWidgetLayouts`). The
grid's own post-drag layout commit is suppressed once so it does not
overwrite the cross-tab move.

## Fixes found while testing

- With `feedback: 'clone'`, the drag source is its own initial drop
target and its placeholder is a DOM clone taken at drag start, so the
drop line rendered into the source got baked into the placeholder and
stuck there for the whole drag. The line is now hidden on the source
cell, leaving a single indicator at the actual target.
- Reorderable tabs collapsed to text height and sat top-aligned next to
"+ New Tab" because the sortable cell wrapper defaults to `display:
block; height: auto`, breaking the tab height chain — the tab list now
uses the cell's `fill` mode so tabs stretch to the strip height again.

## Testing

Playwright against the dev app:
- Record page: widget reorder up and down in the pinned column (single
blue drop line at the target), drag to another tab via its tab button
(highlight + move), drag back into content at a specific position,
chained cross-tab moves, tab reorder with vertical drop line, new tab
creation.
- Overflow (narrow viewport): drop a tab on "+N More" (appends last,
dropdown opens), reorder inside the dropdown (stays open), drag a tab
from the dropdown back to the visible strip.
- Dashboard: grid drag within a tab, cross-tab drag onto a tab button
(hover highlight, widget lands below destination content, remaining
widgets keep their positions), save and reload persistence in both
directions.
- Fields editor: field reorder, group reorder, field move across groups,
plus the Move Up / Move Down widget actions.

Since the pangea-removal commits:
- Typecheck, oxlint and oxfmt green over the full front source; unit
suites green including the migrated `useStartRecordDrag` test (jest
needed a scoped transform exemption for `@preact/signals-core` once
dnd-kit reached the side-panel suites).
- Storybook visual regression unchanged across ~700 stories — expected,
since the migrated surfaces render identical DOM at rest (drop lines and
drag overlays only exist mid-drag).
- The tab strip fix reverses the exact regression mechanism: the
sortable cell wrapper defaulted to `display: block; height: auto`,
collapsing the tab height chain next to the full-height "+ New Tab"
button; `fill` restores the stretch.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XKRCzzu8oGyocXZtFp7VEG)_


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23211?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-24 14:04:13 +02:00
neo773 97d2b52a71 fix(front): duplicate junction chip after Add New in relation picker (#23185)
Clicking Add New in a junction relation picker rendered the newly
created target twice until a page reload. The handler appended the
created junction to the source record's store field after awaiting the
mutation, but useCreateOneRecord's post-optimistic effect had already
attached it, so the same junction id ended up in the field array twice
(single row in DB). Removes the redundant manual append in both to-many
picker flows.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23185?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. -->
2026-07-24 00:55:30 +05:30
Scarab Systems d1c70ab0bf Fix dashboard record table widget aggregate persistence (#23008)
## Summary

- Update dashboard record-table widget aggregate changes to write into
the widget draft while page layout edit mode is active.
- Include `aggregateOperation` when saving record-table widget view
fields through `upsertViewWidget`.
- Persist aggregate operations server-side for widget view-field create,
update, and clear flows.
- Add frontend utility tests and backend integration coverage for widget
aggregate create/update/clear behavior.

Fixes #22934.

## Why

Dashboard record-table widgets use their own draft view state while a
page layout is being edited. The aggregate footer path was resolving
fields through the normal current-view flow and then trying to persist
immediately, which can miss widget draft fields and fail before the save
flow runs.

This change keeps aggregate edits in the widget draft during page layout
editing, then saves the aggregate operation with the rest of the widget
view configuration.

## Validation

- `npx nx lint twenty-front`
- `npx nx typecheck twenty-front`
- `npx nx test twenty-front --configuration=ci`
- `npx nx build twenty-front`
- `npx nx build twenty-server`
- `npx nx lint twenty-server --configuration=ci`
- `npx nx typecheck twenty-server`
- `npx nx test twenty-server --configuration=ci`
- `npx nx jest --config ./jest-integration.config.ts --logHeapUsage
--runTestsByPath
test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts`
- `git diff --check`

Disclosure: I used AI-assisted coding tools while preparing this PR. I
reviewed the changes myself, tested them, and take responsibility for
the implementation and any follow-up revisions needed.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23008?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. -->
2026-07-23 08:07:50 +02:00
Thomas Trompette f5ab8a236c fix: add aria-label to record table selection checkboxes (#23147)
## Fixes

Closes #23130

## Problem

On record tables, the select-all header checkbox and each row's
selection checkbox render as `role="checkbox"` with no accessible name,
failing WCAG 4.1.2 (aria-toggle-field-name). Screen-reader users cannot
tell what the checkbox selects.

## Fix

The shared `Checkbox` component already accepts and forwards
`aria-label`, but the record-table callers weren't passing one. Added
translated labels:

- Row checkbox: `aria-label={t\`Select row\`}` in
`RecordTableCellCheckbox.tsx`
- Header checkbox: `aria-label={t\`Select all rows\`}` in
`RecordTableHeaderCheckboxColumn.tsx`

Both use `const { t } = useLingui()` from `@lingui/react/macro`,
following the existing i18n convention in this module.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23147?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. -->
2026-07-22 14:29:41 +02:00
Priyanshu Bartwal b4aa323889 Record Board & Record Calendar Drag Drop dnd-kit rewrite. (#23071)
Closes: #23070


Record Board:


https://github.com/user-attachments/assets/26df63e2-fbbd-4339-88b3-eed73af283b4

Record Calendar:


https://github.com/user-attachments/assets/cb209545-695e-44a1-8dfb-a92b3c8650a6

 ### Technical Inputs
- Each column is a `Droppable`. This differs from other implementations
(Record table header and Record board header), where only the gap
between headers is `Droppable`.
 - Individual cards are `Sortable`.
- Uses `DragOverlay` to display a cloned version of the dragged card
along with a `+N` chip when dragging multiple cards.
- Updated `DragDropColumnDropTarget` to handle `vertical` and
`horizontal` orientation of drop target.
 - File name changes:
    - `DragDropColumnDropTarget` → `DragDropItemDropTarget`
    - `DragDropColumnDroppableSlot` → `DragDropItemDroppableSlot`
    - `DragDropColumnSortableCell` → `DragDropItemSortableCell`
    - `DragDropColumnSortableHandle` → `DragDropItemSortableHandle`
    - `DragDropColumnDndContext` → `DragDropItemDndContext`
    - `DragDropColumnData` → `DragDropItemData`


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23071?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: Félix Malfait <felix.malfait@gmail.com>
2026-07-22 14:21:23 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server&#39;s
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view&#39;s layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module&#39;s three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout&#39;s transforms can&#39;t offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it&#39;s enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Thomas Trompette 05132d262b fix(workflow): show account select in Send Email node when no account is connected (#23066)
# Why

In the workflow **Send Email** (and Draft Email) node, when the
workspace has no eligible connected account, the **Account** field
disappears entirely — only the variable picker icon remains. The "Add
account" call-to-action is unreachable, so there is no way to connect an
account from the node.

## Root cause

Regression from #21075. `FormSelectFieldInput` used to always pass a
default empty option ("No Account") to `<Select>`; since #21075 it only
prepends it when `isNullable` is set, and the email account field
doesn't set it. With zero connected accounts, `<Select>` then has no
option to resolve a selected option from and bails out rendering an
empty fragment — even though a `callToActionButton` is configured:

```tsx
// Select.tsx
if (!isDefined(controlSelectedOption)) {
  return <></>;
}
```

# What changed

`FormSelectFieldInput` now prepends the empty option whenever the field
is nullable **or there are no options at all**. A populated non-nullable
select still offers no clearing choice (the #21075 behavior is
preserved); an empty one renders its "No X" state so the control — and
its call-to-action — stay visible and clickable.

# Test plan

- New story `FormSelectFieldInput > NoOptionsWithCallToAction`: zero
options + CTA renders the "No Work Policy" control, the dropdown opens,
and the CTA is clickable.
- New story `WorkflowEditActionEmailBase > NoConnectedAccounts`: with
`MyConnectedAccounts` mocked to `[]`, the Account field renders "No
Account" instead of vanishing. The meta's msw handlers move to the
keyed-object form so the story can override a single query (story-level
handler arrays get concatenated after the meta's, and msw's first match
wins), and the story evicts the module-singleton Apollo client's cached
accounts so it actually hits the empty mock.
- `npx nx typecheck twenty-front` and `npx nx lint:diff-with-main
twenty-front` pass; both story files pass under the storybook vitest
project (13 stories total).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23066?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. -->
2026-07-20 16:53:29 +00:00
Gautam pandit 3e14dcbb05 Fix record board column header action accessibility (#22496)
## Summary
- Keep Record Board column header actions mounted instead of rendering
them only on mouse hover
- Show actions on hover and focus-within so keyboard users can reach
them
- Avoid header layout shifts when actions appear

## Context
This is a small follow-up found while reviewing #22323. It does not
duplicate the Kanban column drag-and-drop implementation.

## Testing
- git diff --check
- Not run: package lint/typecheck because this checkout still has no
node_modules and Yarn is not available on PATH

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22496?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: bosiraphael <raphael.bosi@gmail.com>
2026-07-20 16:00:31 +00:00
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

Follow-up to #22668, addressing @charlesBochet's five post-merge review
comments. They all point the same direction: the recipient fields
rebuilt things the codebase already had. This PR converges on the
existing patterns where that holds up, and answers on the threads where
it deliberately does not.

# What changed, per comment

**Parser duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**:
`parseEmailAddressList` now lives in twenty-shared (addressparser, group
flattening, try/catch). The server's `safeParseEmailAddresses` delegates
to it, the front wrapper keeps only paste normalization (newlines to
commas) and invalid-token preservation for red chips. The
`addressparser` dependency moves from twenty-front to twenty-shared.
Side effect worth knowing: RFC 5322 group members in inbound To/Cc
headers were previously dropped entirely (group entries have no
top-level address, so the filter removed them); flattening now imports
those participants. Covered by a new regression test.

**Formatter duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**:
`formatEmailAddress` (quote only when specials require it) lives in
twenty-shared. The composer chips and the server's
`formatMessageFromHeader` both delegate to it. The Gmail From header
output is byte-identical: the name is mime-encoded first and encoded
words never contain characters that trigger quoting. CodeQL then caught
that the quoting (ported from the original front util) escaped quotes
but not backslashes, letting a crafted name close the quoted string
early; escaping now covers both as RFC 5322 quoted-pairs, with a
containment test proving a hostile name cannot split into extra
recipients on reparse.

**Member search divergence
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**:
suggestions now search WorkspaceMember through the search index in the
same `useObjectRecordSearchRecords` call as Person (one ranked query),
and enrich hits from `currentWorkspaceMembersState`, exactly like
`SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side
`filterBySearchQuery` pass is gone. The hook is now what the comment
described: the merge of context people, searched people, and members
into one ranked list, rendered with the same
`SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed
while in there: searched person ids are sliced to the suggestion limit
before hydration, so top-ranked people can no longer be crowded out of
the hydration page.

**Chip resolution duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**:
the display-name preference is now one rule,
`getEmailIdentityDisplayName`, used by both
`getDisplayNameFromParticipant` (threads) and the composer chip/menu, so
the same address renders identically everywhere. The order is workspace
member, then person, then display name, then handle: when an address
belongs to both a teammate and a Person record, the internal identity
wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed
`maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself
is not used inside the field: it renders a navigating `RecordChip` when
a person is linked, and navigation from the composer destroys the draft
(no draft persistence yet), plus the field chips need
remove/selected/danger/edit affordances it does not have.

**Rebuilding on MultiItemFieldInput
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**:
answered on the thread rather than in code, deliberately.
`MultiItemFieldInput` is a dropdown-panel list editor (vertical rows,
one input at a time, bound to record-field contexts and
`FieldMetadataType`), and its own TODO says the API should be refactored
into a hook before growing. The inline wrapping chip row commits batches
(paste), dedupes with a flash, and keeps a persistent inline input with
suggestions; layering that through `renderItem`/`renderInput` would
strain both components. On the menu overlap: after comparing side by
side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and
the chip menu is three `MenuItem` rows with different copy, order, and
neighbors; `MenuItem` is already the shared primitive, and a
config-driven fragment would be indirection without deduplication. If
deeper convergence is wanted, the honest path is the existing TODO
(extract the multi-item state machine into a hook, rebase both editors
on it); that touches the Links/Phones/Emails/Array/Files cell editors
and deserves its own PR.

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
Weiko 8e3ec5b43d Propagate record card background to inline hover content (#22957)
## Summary
- Introduce a shared `--record-card-background-color` CSS variable on
record cards
- Reuse that variable for hovered inline cell content so the hover
portal matches the card background state
- Preserve selected, focused, and active background transitions without
duplicating background logic

### Before

<img width="196" height="337" alt="Screenshot 2026-07-16 at 16 03 40"
src="https://github.com/user-attachments/assets/b74bfb24-0144-4a8c-b8a4-b56768e84d66"
/>
<img width="219" height="357" alt="Screenshot 2026-07-16 at 16 03 25"
src="https://github.com/user-attachments/assets/19d052cb-8c8c-49c9-b3af-0178c53c0c0a"
/>


### After

<img width="189" height="372" alt="Screenshot 2026-07-16 at 16 03 52"
src="https://github.com/user-attachments/assets/289e4186-d418-44dc-93d4-70fa47e50cf2"
/>
<img width="180" height="333" alt="Screenshot 2026-07-16 at 16 03 00"
src="https://github.com/user-attachments/assets/b874e0c4-5840-4b7e-918c-d441c50fa487"
/>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22957?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. -->
2026-07-16 14:17:54 +00:00
Charles Bochet 5588ddf829 Fix delete/destroy/restore record commands on pages without a record index (#22952)
## Bug

On a standalone page (`/page/:pageLayoutId` — a custom app page or
standalone page layout), opening a record in the side panel and running
**Delete** from the Options menu fails with an error toast:

> Record index ID and object metadata are required to delete records

The record is not deleted. The same guard breaks **Destroy** and
**Restore**.

## Root cause

`buildHeadlessCommandContextApi` only derives `recordIndexId` when the
context store holds a `currentViewId`. On standalone pages there is no
view, and `useOpenRecordInSidePanel` copies that null view id into the
side panel context, so the delete/destroy/restore commands throw at
mount — before executing anything. The throw is caught by
`CommandMenuItemErrorBoundary` and surfaces as the toast (also reported
to Sentry).

The commands only use `recordIndexId` to reset table row selection and
remove records from the record board — cleanup that is meaningless when
no record index is on screen. The mutation itself only needs
`objectMetadataItem` and the graphql filter, which are both available.

## Fix

- Keep throwing when `objectMetadataItem` is missing (genuinely
required).
- Make `recordIndexId` optional: pass the existing
`PLACEHOLDER_RECORD_INDEX_ID` to the selection hooks (they must be
called unconditionally) and skip the selection cleanup at execute time
when there is no record index — same pattern
`useResetRecordIndexSelection` already uses. The constant is extracted
to a shared file.

## Verified

- **Bug path**: on a standalone page, opened a record in the side panel
via search, ran Delete Task from the Options menu → record soft-deleted
(checked `deletedAt` in DB), side panel closed, no error toast, no
console error.
- **Regression**: on the tasks index table, selected a row and ran
Delete Task from the command menu → record deleted, row removed, table
selection reset, no errors.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22952?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. -->
2026-07-16 16:13:15 +02:00
Weiko a7de3ce3a5 Allow non-compact all-day calendar cards to show full content (#22953)
## Summary
- Render all-day calendar items as full `RecordCalendarCard` content in
non-compact views, while keeping compact cards clickable as a whole.
- Rework the all-day time grid layout so the label and day cells align
cleanly in a dedicated grid row.
- Add coverage for the new card behavior and for filtering out
`DATE_TIME` records from the all-day lane.

### Week (compact)
<img width="1308" height="812" alt="Screenshot 2026-07-16 at 15 30 02"
src="https://github.com/user-attachments/assets/24f74f22-86c1-4326-8c65-92ee2c3e8c92"
/>

### Week (non compact)
**NEW**
<img width="1311" height="789" alt="Screenshot 2026-07-16 at 15 29 52"
src="https://github.com/user-attachments/assets/8445c8c5-c952-47e9-ba24-c21d63352e79"
/>

### Month
<img width="1310" height="822" alt="Screenshot 2026-07-16 at 15 29 41"
src="https://github.com/user-attachments/assets/aa33cf48-c602-49e6-bfb1-b9ab1c798bcb"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22953?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. -->
2026-07-16 15:59:12 +02:00
Weiko 8cf462d5f7 Add day view support to record calendar (#22922)
## Summary
- Add a calendar day view and wire it into the record calendar layout
selection
- Update the top bar, time grid, and week/day drag and drop handling to
support the new view
- Extend supported layout logic and public feature flags for calendar
day view access
- Add coverage for calendar view content, calendar container behavior,
top bar behavior, day view rendering, supported layout resolution, and
week event drop handling

<img width="1276" height="852" alt="Screenshot 2026-07-15 at 17 48 33"
src="https://github.com/user-attachments/assets/b1d9d255-2d64-4adb-82b9-3e500cb0d561"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22922?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. -->
2026-07-15 16:50:23 +00:00
Weiko 25bd2897a3 Add weekly layout to record calendar (#22819)
## Summary

- Add a week layout to record calendar views and persist the selected
layout.
- Render `DATE` calendars as an all-day week and `DATE_TIME` calendars
as an hourly week.
- Add an optional end date field across calendar configuration,
metadata, persistence, and complete-view upserts.
- Use configured end values for ranged and multi-day events, with a
one-hour fallback when a `DATE_TIME` end is absent or invalid.
- Keep calendar cards consistent with the existing compact view,
including checkbox selection and whole-card record opening.
- Gate the weekly layout and end-date behavior behind the public Labs
`IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag.

## Week interactions

- Show overlapping timed events side by side and cap the visible records
at two per day.
- Display start and end times on timed cards, enforce a readable
30-minute minimum height, and keep today’s text contrast stronger.
- Drag timed events between days and times with 30-minute snapping while
preserving their duration, including zero-duration events.
- Show a create button when hovering a 30-minute slot; keyboard users
can focus a day, move the slot with the arrow keys, and reach the same
contextual action.
- Initialize new records with the selected slot time and a compatible
writable end value one hour later.
- Show the workspace time zone and current-time indicator in timed
weeks; date-only weeks keep the all-day section without an hourly grid.

## Configuration and data loading

- Only allow end fields that match the start field type, and prevent
selecting the same field for both boundaries.
- Load records whose ranges overlap the visible period so month and week
layouts display the same relevant records.
- Resolve and persist calendar end fields when updating existing views
through `upsert_complete_view`.
- Fall back to Month and ignore the configured end field while the flag
is disabled, without overwriting either persisted setting, so
re-enabling restores the previous configuration.
- Expose the flag in Labs and keep it default-off for workspaces without
a stored value; enable it in the development seeder.

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
2026-07-15 16:30:18 +02:00
Abdul Rahman f4ff234db8 feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary

Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).

An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.

This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.


##  Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).

###  Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?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. -->
2026-07-15 19:15:47 +05:30
Weiko 8e022d3c49 Fix stale relation table in field widget when switching records in side panel (#22829)
The relation table rendered by a FIELD widget in TABLE display mode kept
its jotai component states (loaded rows, virtualization maps, loading
guards, query identifiers) in instances keyed only by widget id and view
id. Since the side panel record pages share those instances across
records, switching to another record kept rendering the previous
record's related rows until an asynchronous catch-up reload landed, and
any race or error in that catch-up left the previous record's data on
screen permanently.

Scope the record-table widget's context store instance and record index
instance by target record id (and side panel surface), the same way
FieldsWidget already scopes its field list instances. Each record now
gets its own table state, so a record's rows can never appear under
another record, and loads that land after a record switch write into
their own instance instead of the visible one.

loadRecordIndexStates and setRecordGroupsFromViewGroups accept an
optional recordIndexId override so the widget view load effect can
populate the record-scoped instance instead of deriving the shared one
from object name and view id.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22829?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. -->
2026-07-13 14:50:42 +02:00
Priyanshu Bartwal 7babc049f5 [Twenty-Front]: Record board column drag and drop functionality (#22323)
Closes #22321 

- Used `@dnd-kit` library for core drag and drop logic.
- Tried to keep as much similar to #21304 as possible.


https://github.com/user-attachments/assets/bff100cf-d727-4281-a5fa-b010a373f189



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22323?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>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2026-07-10 13:47:59 +00:00
Thomas Trompette b682162f31 fix(front): render +N button for right-edge-clipped chips in expandable list (#22748)
## Problem

Fixes #22383. Overflowing relation & multi-select chip cells didn't show
the "+N" overflow button, so hidden records/values were unreachable.
There were several distinct causes behind this, addressed below.

Before
<img width="263" height="34" alt="Capture d’écran 2026-07-10 à 11 43
13"
src="https://github.com/user-attachments/assets/1001d47b-0f54-4fbd-a5cb-83a5c32c35ac"
/>

After
<img width="263" height="34" alt="Capture d’écran 2026-07-10 à 11 41
54"
src="https://github.com/user-attachments/assets/d4f5cf2f-fda4-422e-b72d-2df3fc81a06f"
/>

## Changes

**1. Overflow detection missed right-edge-clipped chips**
`isFirstOverflowingChildElement` used `childElement.offsetLeft >
containerElement.clientWidth` (left edge past the container), which is
false when a chip is only right-edge clipped. Switched to a right-edge
check: `childElement.offsetLeft + childElement.offsetWidth >
containerElement.clientWidth`.

**2. Multi-select never used the overflow list when unfocused**
`MultiSelectFieldDisplay` rendered a plain clipped `MultiSelectDisplay`
when not focused, and only used `ExpandableList` on focus. That
non-focused fallback painted over the focused "+N" and hid it. It now
always renders through `ExpandableList` with
`isChipCountDisplayed={isFocused}`, matching
`RelationFromManyFieldDisplay`, so the count shows on focus only (not
idle).

**3. The hover portal was never actually focused**
`FieldFocusContextProvider` silently ignored its `isFocused` prop (`({
children }: any)` + hard-coded `useState(false)`), so
`RecordInlineCellAnchoredPortal`'s `<FieldFocusContextProvider
isFocused={true}>` had no effect and the hovered cell's display always
saw `isFocused=false`. That's why "+N" never appeared on hover for any
multi-value field. The portal now uses the existing
`FieldFocusStaticFocusedProvider`, fulfilling the intent — so relations,
multi-select, emails and phones all surface their "+N" on hover.

**4. "+N" was hard to read on multi-select**
The "+N" chip has a transparent background (shared component), and the
hover portal let the base layer's colored option chips bleed through it.
Gave the hover portal content an opaque `background.primary` so nothing
bleeds through; the "+N" component itself is untouched, so
relations/emails/phones keep their existing look.

## Test plan

- Hover a relation or multi-select field whose values overflow the cell:
the "+N" button appears (and is readable), and clicking it lists the
hidden records. When not hovered, no "+N" shows.
- Verify fully-overflowing rows still show the correct "+N" count.
2026-07-10 13:22:03 +02:00
Félix Malfait 6897fff632 Rebuild email composer recipient fields as a structured chip input with person resolution and autocomplete (#22668)
# Why

The To/Cc/Bcc fields reused `FormMultiTextFieldInput`, the workflow
Tiptap tag editor, with recipients stored as a comma-separated string.
That caused every reported issue: duplicates were allowed, the field was
locked to one 32px line with a hidden horizontal scrollbar, chips did
nothing on click, `First Last <email>` could not even be typed (space
committed a tag) and was rejected by the backend when pasted, chips
could not be edited, invalid addresses only failed server-side after
pressing Send, and there was no autocomplete at all.

## The model

A recipient is `{ address, displayName? }`. Person and workspace member
are never stored in composer state; they are resolved live from the
address at render time, mirroring how `MatchParticipantService` links
`messageParticipant.handle` to `personId`/`workspaceMemberId` on the
receive side. Entities appear at the edges (autocomplete in, chip
display out); state, dedupe, validation, and send operate on addresses
only. The send path is unchanged: `SendEmailInput.to/cc/bcc` stay
comma-separated bare addresses.

# What changed

New module `activities/emails/recipients/` (the workflow editor is
untouched; its other consumers are unaffected):

- **`EmailRecipientsFieldInput`**: wrapping chip rows (up to ~3 lines,
then scroll), commit on Enter/Tab/comma/semicolon/blur, space commits
only when the buffer is already a valid email, paste parses RFC 5322
lists (names, quoted commas, semicolons, newlines), case-insensitive
dedupe with a flash on the existing chip, invalid addresses become red
chips that disable Send, double-click or keyboard editing in place with
Escape revert, Backspace select-then-delete, arrow-key chip navigation,
Ctrl/Cmd+Enter commits a pending buffer or sends when the buffer is
empty.
- **Person resolution**: chips resolve against People
(`emails.primaryEmail`, case-insensitive) and workspace members,
rendering avatar + name when known and degrading to a plain address chip
otherwise.
- **Chip menu**: person/member header, Copy email, Edit, Remove, and Add
as person for unknown addresses (creates the Person; the chip upgrades
in place).
- **Autocomplete**: blends context people (company you are composing
from, or the company behind a person/opportunity), ranked people search,
workspace members with a Team member badge, and a literal "Use this
email" row ranked first when the typed buffer is a valid address.
Suggestions exclude addresses already present in any field. Enter picks
the highlighted or top row.
- **Prefill**: replies and drafts preserve participant display names
(`getEmailDraftPrefillFromMessage`, `useReplyContext`).
- `useEmailComposerState` holds `EmailRecipient[]` per field and blocks
send on invalid recipients; the recipient-limit warning is surfaced
again in the composer.
- The Send Email engine command passes the record context so context
suggestions work from the record page action.
- `EmailsFilter` was missing from the shared `LeafFilter` union, so
nothing could filter on `emails.primaryEmail`; added (additive).
- New dependency `addressparser@1.0.1` in twenty-front, the same package
and version the server already uses to parse inbound mail headers, so
both sides parse identically. Tiny, dependency-free, browser-safe.

# Decisions and tradeoffs

- Person resolution matches on `emails.primaryEmail` only,
case-insensitively via per-address `ilike` filters (no `%` wildcards,
`%_\` escaped). `additionalEmails` is a JSONB array and not cleanly
filterable through the GraphQL filter API today; the server-side matcher
checks additional emails too, so a chip may show as a plain address even
though the send still links to the person via participant matching.
- Chip flash-on-duplicate replays its CSS animation by remounting the
chip subtree (nonce in the React key), chosen over animation-restart
hacks; the remount is invisible.
- Keyboard chip selection keeps DOM focus on the input and tracks a
virtual `selectedChipIndex` (`aria-activedescendant`) instead of roving
focus across chips: one focus point, no focus juggling, standard
combobox listbox pattern.
- `flushSync` (precedent: `Dropdown.tsx`) focuses and places the caret
after entering chip-edit mode; the alternative was a useEffect on
editing state.
- Suggestion rows `preventDefault` on mousedown so picking a suggestion
never blurs the input (blur would first commit the half-typed buffer as
a junk chip).
- Cmd/Ctrl+Enter inside a recipient field: with a non-empty buffer it
commits the buffer only; with an empty buffer it sends via an `onSubmit`
prop wired to `handleSend`. Not commit+send in one stroke: `handleSend`
holds a same-render closure over composer state, so sending in the same
event would read the pre-commit recipients. E2E also showed the side
panel's own ctrl+Enter hotkey never fires while any form field is
focused (focus-stack scoping, applies to the old composer too), which is
why the field triggers the submit itself.
- Enter with suggestions open picks the highlighted (or top) suggestion,
Gmail-style. When the typed buffer is itself a valid email, the literal
row is ranked first so Enter keeps meaning "add what I typed".
- Suggestions are disabled while editing a chip (the edit buffer holds
`Name <email>` text, a poor search query).
- Dedupe blocks within a field; across fields typed duplicates are
allowed (sometimes intentional), but suggestions exclude addresses
already present in any of To/Cc/Bcc.
- Chip menu actions never navigate: navigating the side panel (or main
view) unmounts the composer and silently destroys the draft, since
composer state is component-local with no draft persistence. "Add as
person" creates the record and shows a snackbar while the chip upgrades
in place; the person header row is informational. "Open person"
navigation should come back once drafts survive navigation.
- The reply composer gets no context record: its widget target record is
the message thread, not a person/company, and replies already prefill
participants.
- If two people share a primary email, the last fetched match wins for
chip display (no ambiguity UI).
- "Add as person" splits the display name on the first space for
firstName/lastName, the same heuristic the contact-creation manager uses
server-side.

# Deferred

- Display names on the wire (`Name <email>` in outbound headers): needs
`SendEmailInput` / `EmailComposerService.validateEmails` changes
server-side.
- Drag chips between To/Cc/Bcc; collapse-on-blur to one line with a "+N
others" summary.
- Frequency/recency ranking of suggestions from `messageParticipant`
aggregates.
- "Open person" from the chip menu, pending draft persistence across
navigation.

# Verification

Unit tests cover the parser, formatter round-trip, merge/dedupe, and the
field state machine (commit, dedupe flash, edit, cancel, keyboard
selection). Typecheck, lint, and the email module suites pass, plus the
shared and side-panel suites.

Every flow was also driven end to end with Playwright against seeded
data: prefill resolution, context and typed suggestions, keyboard
navigation and picks, dedupe flash, RFC 5322 paste, invalid chips gating
Send, wrapping, in-place editing, chip menus, clipboard copy, Add as
person with live chip upgrade, Cc/Bcc exclusions, and the Ctrl+Enter
send path (the mutation reached the server; it failed only on the seeded
account's missing refresh token, expected outside a real provider
connection).

Screenshots of each verified behavior:
https://claude.ai/code/artifact/1743f05d-422e-43d0-bbea-a34a0470c180

---
_Generated by [Claude
Code](https://claude.ai/code/session_0199wDARiw48GqVTpgWzbXWw)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22668?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. -->
2026-07-09 13:11:09 +02:00
Aressand 14e4b5bd31 fix(rls): prefill RLS predicate fields when creating related records (#22620)
## What / Why

Creating a record from a relation section (e.g. adding a child record
from its
parent's record page) fails with **"Record does not satisfy security
constraints"** for any role restricted by row-level permissions.

Root cause: `useAddNewRecordAndOpenSidePanel` builds the create payload
with only
the label field and the parent FK. Fields required by the role's RLS
predicates
(e.g. `owner = current workspace member`) are missing, so the server
rejects the
insert in `validateRLSPredicatesForRecords` with
`RLS_VALIDATION_FAILED`.

`useCreateNewIndexRecord` (the record table "+ New" path) already
handles this via
`buildRecordInputFromRLSPredicates()`. The relation-section creation
path was
simply never updated — same bug class, different entry point.

## How

Spread `buildRecordInputFromRLSPredicates()` into the create payload in
`useAddNewRecordAndOpenSidePanel`, mirroring `useCreateNewIndexRecord`.
The record
is then created with the RLS-required fields prefilled (e.g. owner =
current
member), so it passes server-side validation.

No behavior change for roles without RLS predicates:
`buildRecordInputFromRLSPredicates()`
returns an empty object when there are none.

## Test plan

Requires row-level permissions (Enterprise) enabled.

1. Create a role with an RLS predicate `owner IS current workspace
member`.
2. Assign it to a non-admin user; create a parent record owned by that
user.
3. As that user, open the parent record and add a child record from a
relation
   section (the "+" on a one-to-many / many-to-one relation field).
4. **Before:** "Record does not satisfy security constraints".
**After:** the child record is created, with owner prefilled to the
current member.

Also verified via REST against a self-hosted instance: inserting the
child record
without the owner field is rejected (HTTP 400, RLS_VALIDATION_FAILED);
inserting it
with `ownerId = current member` succeeds (HTTP 201).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22620?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. -->
2026-07-08 13:03:45 +02:00
Paul Rastoin 6c40c7b91a Deterministic system field universal identifier (#22565)
# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
2026-07-06 13:34:33 +00:00
martmull 0706c7c1bc feat(front): upload files directly to storage for files-field, attachments and workflow (#22576)
## Context

Final step of the direct-to-storage upload work (follows #22449
endpoints, #22531 reaper, #22533 content-verify). The server can now
hand the client an upload URL so bytes go straight to storage instead of
being buffered through the Node process (the original OOM problem). This
PR switches the frontend to that flow for the three in-scope surfaces.

## What this does

Adds **`useDirectFileUpload`** — the shared hook that runs the
handshake:

1. `createFileUpload({ filename, size, fileFolder, fieldMetadataId? })`
→ `{ fileId, uploadUrl, contentType, expiresAt }`
2. `PUT` the raw file to `uploadUrl` with `Content-Type: contentType`
3. `completeFileUpload({ fileId })` → `FileWithSignedUrl` (`{ id, path,
size, createdAt, url }`)

Routes the three existing upload hooks through it, **keeping each hook's
public signature and return shape unchanged** so no call sites change:

| Hook | Folder |
|---|---|
| `useUploadFilesFieldFile` (FILES fields) | `FilesField` |
| `useUploadAttachmentFile` (attachments — the Attachment object's
`file` FILES field) | `FilesField` |
| `useUploadWorkflowFile` (workflow send-email attachments) | `Workflow`
|

Adds the `CreateFileUpload` / `CompleteFileUpload` gql documents and
regenerates `generated-metadata` types (+19 lines, scoped to the two new
operations).

## Out of scope

- AI-chat (`AgentChat`) and email-attachment (`EmailAttachment`) uploads
keep the legacy buffered mutations — those folders aren't in the
server's direct-upload allowlist (`[FilesField, Workflow]`).
- Workflow serverless-function code is saved via metadata mutations, not
the file path.

## Notes

- The legacy `uploadFilesFieldFile` / `uploadWorkflowFile` mutations
still exist server-side and remain used by the out-of-scope surfaces, so
this is non-breaking.
- Local storage routes the `PUT` to the token-authenticated streaming
endpoint (`SERVER_URL/file-upload/:id?token=…`); S3 uses a presigned
`PUT`. CORS is already enabled globally on the server and the token
rides in the query string (no cookies), so the browser upload works
cross-origin.

## Verification

`typecheck` and `lint:diff-with-main` green on `twenty-front`; codegen
ran against a live metadata schema so the generated file matches the
drift check. No existing tests/stories cover these hooks.

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

---
_Generated by [Claude
Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22576?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. -->
2026-07-06 15:28:57 +02:00
avonian 26491ecdc6 fix(front): don't blank record title when the title cell is untouched (#22293)
## Problem

On a record show page, the record title (label identifier) can be
silently blanked. Repro:

1. Create a record, set its name, set a relation field (e.g. a
one-to-one/many relation).
2. Reload the page.
3. Click the **edit** affordance on the relation field.

→ The record's name clears to “Untitled”, and it persists (an
`updateOne` fires with `input: { name: "" }`).

## Root cause

`RecordTitleCellTextFieldInput` registers `onClickOutside` / `onEnter` /
`onTab` / `onShiftTab` and always forwards `draftValue ?? ''` to be
persisted. When the title cell is in edit mode but the user never typed
in it, `draftValue` is `undefined`, so the forwarded value is `""`.

Opening another field's input counts as a click-outside on the title
cell, which then persists `{ name: "" }` over the existing label
identifier.

## Fix

Skip persisting when the title draft is untouched (`draftValue ===
undefined`) by passing `skipPersist` on the blur-style events. An
unedited title can no longer overwrite the existing label identifier;
genuine edits set the draft and persist exactly as before.

## Test plan

- Repro above: name no longer clears when editing a relation after
reload.
- Creating a new record and naming it still works.
- Renaming an existing record via its title still works.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22293?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>
2026-07-04 13:09:21 +00:00
Thomas des Francs e609320666 Squirclesssss 🟦🔵 (#22535) 2026-07-04 07:07:29 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?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. -->
2026-07-03 14:15:27 +00:00
martmull 25fe66565c feat(applications): add type and options to application variables (#22157)
## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

<img width="1074" height="452" alt="image"
src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7"
/>

## After

<img width="1421" height="865" alt="image"
src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43"
/>

<img width="1348" height="870" alt="image"
src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d"
/>


## Summary

Adds typed application variables with optional select **options**. This
is the other half of #22059, split out from the custom-settings-tab
removal.

## Changes

- **Shared types**: `ApplicationVariable` / `ServerVariables` gain an
optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`,
`NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`,
`ARRAY`, …) and select `options`. New
`serializeApplicationVariableValue` /
`deserializeApplicationVariableValue` helpers convert typed values
to/from the encrypted string storage.
- **Server**: `type`/`options` columns on `applicationVariable` and
`applicationRegistrationVariable` (entities + DTOs), a fast `2-17`
instance command, manifest processing via the serialization helpers, and
a `QueryDeepPartialEntity` cast where the manifest JSON column is
persisted.
- **Frontend**: a polymorphic `SettingsApplicationVariableInput` that
renders the native `Form*` field component for each type (boolean,
number, date/date-time, select, multi-select, array, raw JSON, rich
text, text); fragment/query updates to fetch `type`/`options`.
- **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT`
variables declare non-empty `options` at build time (since `options` is
kept structurally optional for TypeORM/SDK compatibility).

Variables default to `TEXT` when no type is given, so existing manifests
are unaffected.

## Notes

The generated GraphQL artifacts (`type`/`options` on the variable types)
are regenerated by codegen; that change accompanies this PR.

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

---
_Generated by [Claude
Code](https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?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. -->
2026-07-03 10:52:22 +02:00