Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 22cf30b987 fix(file-storage): add adaptive retry and backoff for S3 rate-limit errors
https://sonarly.com/issue/32646?type=bug

The workflow worker fails to upload built logic function code to S3 when AWS returns a 503 SlowDown rate-limit error, causing workflow runs to fail. The S3 client has no custom retry configuration and the default 3-attempt retry with standard backoff is insufficient during request bursts.

Fix: packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts

Add `maxAttempts: 5` and `retryMode: 'adaptive'` to both S3 client instantiations in the constructor (main client and presign client). The adaptive retry mode uses a client-side token bucket to dynamically reduce request rate when S3 returns 503 SlowDown responses, preventing cascading failures during high-concurrency worker bursts. The increased maxAttempts (from default 3 to 5) gives the adaptive backoff strategy sufficient headroom to succeed on transient rate limits.

Constructor: S3 client now created with `{ ...s3Options, region, endpoint, maxAttempts: 5, retryMode: 'adaptive' }` instead of `{ ...s3Options, region, endpoint }`. Same change applied to the presign client when presignEndpoint is provided.
2026-04-29 19:46:51 +00:00
842e679cc6 fix(billing): gate AI credit-cap at entry points instead of workflow executor (#20096)
## Background

The 2026-04-26 incident saw 716M Sonnet 4.6 tokens consumed in a single
trial workspace. Two causes: failed agent executions weren't billed
(addressed by #20065) and the credit-cap gate had been removed from
`WorkflowExecutorWorkspaceService.executeStep` in #19904, leaving no
enforcement point at all.

## Why not just revert #19904

#19904 was right that gating at the workflow executor is too coarse.
When one user exhausted a workspace's credits via chat, *all* workflows
hard-failed mid-run — including cheap DB/CRUD/branch automations costing
essentially nothing. Reverting would re-introduce that cliff.

## New design: gate at the AI entry points

The chat resolver already gates this way
(`agent-chat.resolver.ts:137-148`). This PR replicates the same pattern
at every other point where the workspace can incur real AI cost:

- `executeAgent` in `agent-async-executor.service.ts`
- the REST handler in `ai-generate-text.controller.ts`
- `generateThreadTitle` in `agent-title-generation.service.ts`

In each, after auth/validation: skip if `IS_BILLING_ENABLED` is false;
otherwise call `BillingService.canBillMeteredProduct(workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION)`; on `false`, throw
`BillingException(BILLING_CREDITS_EXHAUSTED)`. No new method, no new
exception code, no new product key.

This matches industry convention (Lovable/Replit also gate at the
expensive-operation boundary, not at every cheap step).

## Deliberately not gated

- `WorkflowExecutorWorkspaceService.executeStep` — the design choice is
now intentional, so the #19904 TODO is replaced by a one-line
absolute-behavior comment explaining why the gate isn't here. Cheap
workflow steps (DB CRUD, branching, action steps) are not gated, so a
chat-driven cap exhaustion does not block non-AI automations.
- `repair-tool-call.util` — repair is a sub-call inside an already-gated
AI flow. If the parent is gated, repair will naturally not run. Adding a
gate here adds complexity without value.

## Net effect

A workspace that exhausts credits via chat or AI agent stops making AI
calls. Its non-AI workflows continue running normally. A workflow with
both AI and non-AI steps fails at the AI step with
`BILLING_CREDITS_EXHAUSTED`, but downstream non-AI steps that don't
depend on the AI output still run.

## Conflicts

This PR overlaps with three other in-flight PRs in the same files. None
of them touch the gate logic; rebasing on top of any of them is trivial:

- #20065 (agent-async-executor): adds `workspaceId` to `executeAgent`
args and bills in `finally`. The gate at the top of `executeAgent` from
this PR sits naturally above that.
- #20066 (REST controller): adds usage billing to the controller.
- #20067 (title gen): adds usage billing to title generation and
tool-call repair.

Recommend landing #20065/#20066/#20067 first; this PR rebases trivially
on top.

## Tests

Out of scope per the PR series convention. The existing chat-resolver
gate isn't unit-tested either; this PR follows the same precedent.
Follow-up: add integration coverage that exercises a workspace at
`hasReachedCurrentPeriodCap=true` against each of the three new gates
plus the pre-existing chat-resolver gate.

## Future follow-ups

- Per-user soft cap inside a workspace (the Lovable Business-tier
pattern), so one user can't exhaust the workspace's cap.
- Pre-flight cost estimate so the user sees an "approaching cap" warning
before the hard stop.
- Rename `BillingProductKey.WORKFLOW_NODE_EXECUTION` — the name predates
this design choice and is misleading now that it gates AI entry points
rather than workflow nodes.

## Test plan

- [ ] Trigger a workspace into `hasReachedCurrentPeriodCap=true`.
- [ ] Send a chat message — expect failure with
`BILLING_CREDITS_EXHAUSTED`.
- [ ] Run a workflow whose only AI step is an `ai-agent` action — expect
that step to fail with `BILLING_CREDITS_EXHAUSTED`, downstream non-AI
steps still run.
- [ ] POST to `/rest/ai/generate-text` — expect
`BILLING_CREDITS_EXHAUSTED`.
- [ ] Create a new chat thread (which kicks off `generateThreadTitle`) —
expect `BILLING_CREDITS_EXHAUSTED`.
- [ ] Run a workflow with no AI step (only DB CRUD/branching/actions) —
expect it to run unaffected.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:48:06 +02:00
neo773andGitHub a713f8d87f CalDAV: support Digest auth (#20135)
Adds digest auth support for CalDAV, mostly used by legacy servers

/closes https://github.com/twentyhq/twenty/issues/19922
2026-04-29 16:09:21 +00:00
EtienneandGitHub fd6d5f895d Ai Chat - Caching optim (#20126)
EDIT : 
- solving auto-caching from Anthropic by updating ai-sdk/anthropic +
adding providerOption at stream level
- concerning Bedrock, it needs breakpoint


**1. Breakpoints were only on the system prompt**

The code already placed a cache marker on the system prompt (~10K
tokens). But the conversation history — which can grow to hundreds of
thousands of tokens — had no marker, so Anthropic re-read it at full
price on every turn.

The fix adds a prepareStep hook inside streamText that stamps the last
message with a cache breakpoint before every LLM call. Anthropic then
caches the entire conversation prefix, and subsequent turns read it at
$0.30/M instead of $3/M.

prepareStep is used rather than a one-shot pre-processing step because
an agentic turn makes multiple internal LLM calls as tool results
accumulate — the hook refreshes the breakpoint before each one.

**2. Bedrock was using the wrong field**

The system prompt marker for Bedrock was set as cacheControl: { type:
'ephemeral' } — which is the Anthropic wire format. The Bedrock Converse
API expects cachePoint: { type: 'default' }. The system prompt was
silently not being cached on Bedrock at all.

Both the system prompt and the new prepareStep now go through a shared
getCacheProviderOptions helper that returns the correct field per
provider.

**3. Persisted cached token usage to monitor cache strat. efficiency**
2026-04-29 14:42:12 +00:00
11628d19a3 add recurring calendar events for google cal (#19748)
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-29 14:06:57 +00:00
46ba5fd16c i18n - translations (#20138)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-29 15:51:28 +02:00
nitinandGitHub 4649736d49 [Command Menu] Fix record-selection command filtering in edit mode (#20034)
https://github.com/user-attachments/assets/fe1461c7-0d5c-4c6f-8c2e-2cf569e7de90

## What

Fix `RECORD_SELECTION` items leaking into the command menu when nothing
is selected, and unify how the menu renders in normal vs edit mode.

## The bug

`RECORD_SELECTION`-availability items were showing up even when
`numberOfSelectedRecords === 0`. New util
`doesCommandMenuItemMatchSelectionState` gates them, applied
consistently in the runtime provider and the editor.

## The refactor

`PinnedCommandMenuItemButtonsEditMode` was a 140-line near-duplicate of
`PinnedCommandMenuItemButtons` with its own (drifting) filter logic.
Killed it. Edit mode now flows through the same
`CommandMenuContextProvider` with a new `isInPreviewMode` flag — one
filter chain, one rendering path.

## Behavior in edit mode

**Header (pinned buttons in page header):**
- Runs the full filter chain — object metadata, page type, selection
state, page layout, *and the conditional availability expression*
- Buttons render at full styling but are inert via `pointer-events:
none` + `cursor: not-allowed`
- Preview now reflects exactly what users will see on the live page (not
a grayed-out approximation)

**Side panel editor:**
- New `useEditableCommandMenuItems` hook
- Same filters as runtime *minus* the conditional availability
expression and `FALLBACK` items — so it surfaces everything that's
actually configurable for this page context
- Still gates on selection state — if no records selected,
`RECORD_SELECTION` items are hidden from the editor too. Open to
feedback if we'd rather always show them so users can pin them ahead of
time.

## Misc

- `usePinnedCommandMenuItemsInlineLayout` — visible count now waits
until every item is measured before committing. Fixes a flash of wrong
counts on mount/resize
- Renamed `useCommandMenuContextApi` → `useCurrentCommandMenuContextApi`
— name now conveys it reads from the *current* scoped context store
- Copy: "Records selected" → "Record(s) selected"
2026-04-29 13:34:28 +00:00
Abdullah.andGitHub 51384bc085 refactor: optimize website visual runtime (#20120)
Refactors the website visual runtime to make WebGL-heavy sections more
reliable and less expensive.

This adds shared image/model loading caches, safer WebGL context
recovery, staggered visual mounting, and static rendering for decorative
Helped card visuals. It also removes a large bespoke Helped renderer in
favor of the shared halftone model canvas, reduces scroll/layout work in
the Helped section, and cleans up duplicated model-loading code across
several visuals.
2026-04-29 12:44:47 +00:00
neo773andGitHub abfa6200dd ssrf hardening (#19963)
Hardened CalDav with new approach of wrapping axios ssrf http agent to
fetch via `@lifeomic/axios-fetch` because `tsdav` only accept `fetch`
override.

Also Hardened test endpoint
2026-04-29 12:13:19 +00:00
nitinGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>nitin
480e5796ec fix(ai): render record links inside markdown headings in AI chat (#20074)
## Summary

Adds `h1`–`h6` component overrides to `LazyMarkdownRenderer` so that
`[[record:...]]` references placed inside markdown headings in the AI
chat are parsed by `processChildrenForRecordLinks` and rendered as
clickable `RecordLink` chips, matching the behavior already in place for
`p`, `li`, `td`, `th`, and `a`.

Fixes #20072

## Test plan

- [ ] In AI chat, ask a question whose answer places a record reference
inside a markdown heading (e.g. `## Found [[person:uuid:John Doe]]`) and
confirm a clickable `RecordLink` chip renders instead of the raw
`[[...]]` text.
- [ ] Verify heading styling (sizes, weights, margins) is unchanged.

Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nitin <ehconitin@users.noreply.github.com>
2026-04-29 13:50:32 +02:00
Félix MalfaitGitHubClaude Opus 4.7claude[bot] <41898282+claude[bot]@users.noreply.github.com>
d8e2de48e6 Fix stale UI state after stop-impersonation (#20088)
## Summary

A customer reported that after **Stop Impersonating**, the sidebar still
showed the impersonated user's pinned favorites, the AI chat tab toggle,
and the AI chat history — even though the original admin's session was
correctly restored.

## Root cause

The refactor in #19597 replaced the previous `signOut()`-based stop flow
with an in-place token swap, but only cleared Apollo cache + reloaded
the user. Several user-scoped client stores were left untouched:

- **`metadataStoreState`** is localStorage-backed
(`navigationMenuItems`,
  `agentChatThreads`, `views`, `pageLayouts`, etc.) and only refreshed
  by `MinimalMetadataLoadEffect`. That effect is gated by
  `metadataLoadedVersion` + `desiredLoadState`, neither of which flips
  on a same-workspace token swap, so the effect never re-runs.
- **In-memory AI atoms** (`currentAiChatThreadState`,
`agentChatInputState`,
  `hasInitializedAgentChatThreadsState`) keep pointing at the
  impersonated user's selected thread / input.
- **Session localStorage keys** (`agentChatDraftsByThreadIdState`,
`lastVisitedObjectMetadataItemIdState`,
`lastVisitedViewPerObjectMetadataItemState`,
  `playgroundApiKeyState`) carry the impersonated user's drafts and
  navigation state.

`clearSession()` (used by logout) avoids this because it calls
`applyMockedMetadata()` and flips `desiredLoadState` mocked↔real, which
chain-triggers a full metadata reload on next sign-in.

## Fix

Extract a `resetUserScopedClientState` helper inside
`useImpersonationSession` that:

1. Calls `clearSessionLocalStorageKeys()` to drop user-scoped
localStorage
   keys.
2. Resets the in-memory AI session atoms.
3. Marks `metadataStoreState['agentChatThreads']` as `'empty'`.
`useLoadStaleMetadataEntities` does **not** handle this entity key, so
   without an explicit reset to `'empty'` the
   `AgentChatThreadInitializationEffect` (which only fires on `'empty'`)
   would never refetch.
4. Calls `invalidateMetadataStore()` to clear all
`currentCollectionHash`
   values and bump `metadataLoadedVersion`, forcing
   `MinimalMetadataLoadEffect` to re-run and refetch
   `navigationMenuItems`, `views`, `pageLayouts`, etc. against the new
   token.

The helper is applied to both `startImpersonating` and
`stopImpersonating`
— start had the same latent bug; the impersonated user could see the
admin's favorites until the cache happened to refresh.

## Test plan

- [ ] As an admin user, pin some favorites in the sidebar
- [ ] Impersonate a user with different favorites → favorites should
      switch to the impersonated user's
- [ ] Click "Stop Impersonating" → sidebar should immediately show the
      admin's favorites (not the impersonated user's)
- [ ] As an admin **without** AI permission, impersonate a user **with**
AI permission, open AI chat, send a message, then stop impersonating
      → AI chat history should be empty / inaccessible (the AI tab
      visibility itself is fixed in a separate PR)
- [ ] Type a draft in AI chat as the impersonated user → after stop, the
      draft should be gone
- [ ] Verify regular sign-out still works while impersonating
- [ ] Verify the impersonation banner still shows / hides correctly

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-29 13:37:12 +02:00
Paul RastoinandGitHub b49e58dfc6 Fix click house migration (#20127)
`it does not support renaming of multiple tables in single query.`

@etiennejouan manually fix the corrupted clickhouse instance
2026-04-29 10:48:21 +00:00
a6118b7dc3 i18n - translations (#20125)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-29 11:57:14 +02:00
Abdul RahmanandGitHub 19ee9444ed add UpsertViewWidget resolver (#20053) 2026-04-29 09:41:02 +00:00
Paul RastoinandGitHub b92617d46e Copy twenty-shared in twenty-website deploy (#20124) 2026-04-29 08:44:54 +00:00
c476c6c80b chore: sync AI model catalog from models.dev (#20122)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-29 08:45:09 +02:00
Paul RastoinandGitHub d2dda67596 Fix upgrade --start-from-workspace-id (#20116)
# Introduction
Prevent using both `--start-from-workspace-id` and `--workspace`

When any of the two are being passed we prevent passing to the next
instance segment, it would require an upgrade re run even if legit

When `--start-from-workspace-id` is passed we filter from all the
fetched active or suspended workspace ids and apply equivalent filter as
before
2026-04-28 16:46:15 +00:00
Abdullah.andGitHub 6aec449a56 refactor: harden website runtime, routing, and hero visual (#20113)
This centralizes routing/SEO ownership, replaces deprecated middleware
with proxy, adds safer lifecycle/runtime primitives, and introduces
visual error boundaries so broken WebGL/canvas-heavy visuals can fail
gracefully instead of taking down the page. It also hardens animation,
resize, visibility, cleanup, and WebGL fallback paths for a broader
range of browsers and devices.

The hero visual was split from large monolithic files into focused
domain folders for shell, pages, shared primitives, window interactions,
terminal conversation, prompt, editor, and traffic-light behavior.
Legacy unused section code was removed, visual configs were extracted,
state/geometry logic was moved into testable modules, and coverage was
added across routing, SEO, lifecycle, animation, visual runtime,
halftone behavior, and hero interactions.

More changes on the way, but this should make the website a lot more
stable - disabling WebGL on Firefox and loading the website does not
cause crashes on local any longer, will test on dev once this is merged.
2026-04-28 17:15:28 +02:00
7ea1dfdd49 i18n - translations (#20115)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-28 16:48:25 +02:00
3290bf3ab1 fix(rest-api): prevent silent pagination failures and include valid options in enum validation errors (#20092)
# Summary 
(fixes #20044)

This PR implements two fixes for the REST API to enforce stricter
validation and provide better error messages.

Issue 1: Cursor parameter silently ignored
Problem: When users provided common cursor aliases (e.g., cursor, after,
before) instead of the correct parameter names (starting_after,
ending_before), the API silently ignored them and returned page 1 on
every request.
Solution: Added strict validation to detect common cursor aliases and
throw a clear error directing users to use the correct parameter names.
Files modified:
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts
- Test files for both parsers
Example error:
Invalid cursor parameter 'cursor'. Use 'starting_after' for pagination.
---
Issue 2: OpportunityStageEnum not validated on REST
Problem: When creating or updating opportunities via REST with an
invalid stage value, the API either silently dropped the value or
returned a generic error without listing valid options.
Solution: Updated the SELECT field validation to include valid options
in the error message.
Files modified:
-
packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-rating-and-select-field-or-throw.util.ts
- Test file
Example error:
Invalid value "BAD_VALUE" for field "stage". Valid values are: NEW,
SCREENING, MEETING, PROPOSAL, CUSTOMER
---
### Testing
- Added 5 new test cases for `parse-starting-after-rest-request.util.ts`
- Added 6 new test cases for `parse-ending-before-rest-request.util.ts`
- Added 1 new test case for
`validate-rating-and-select-field-or-throw.util.ts`
- All 21 tests passing
---
Breaking Changes
None - correct usage is unaffected.

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-28 14:31:18 +00:00
EtienneandGitHub fbaea0639a Billing - optimize usageEvent CH table (#20019)
- Update usageEvent clickhouse table, partitioning, indexing and
projection (auto materialized view) to optimize credit usage queries
- Add caching for available credits and billing subscription


To do in next PR: deprecate enforceCapUsage cron. Bonus : real-time on
billingSubscription
2026-04-28 14:06:49 +00:00
2a3b8adcfb i18n - translations (#20114)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-28 16:17:27 +02:00
Félix MalfaitGitHubClaude Opus 4.7claude[bot] <claude[bot]@users.noreply.github.com>
8f362186ce Redesign application content tab + logic function settings; add Layout detail pages (#20056)
## Summary

Iterative redesign of two related areas in settings, plus a new
`pages/settings/layout/` folder for read-only entity detail pages.

### Application content tab

- **Grouped into three sections** — Data / Layout / Logic — each with
one H2 + multiple `TableSection`-wrapped sub-tables (mirrors the
role-permissions pattern). Replaces six per-category table/row
components with one uniform `<SettingsApplicationContentSubtable>` +
`ApplicationContentRow` shape (net **−~700 lines** across the refactor).
- **All 10 row categories now clickable** for installed apps:
- Objects / Fields / Logic functions / Front components → existing
detail pages
  - Agents → existing `AiAgentDetail`
- Skills → existing `AiSkillDetail` (looked up by `Skill.applicationId +
name`)
- Roles → existing `RoleDetail` (looked up by
`Role.universalIdentifier`)
- Views / Page layouts / Navigation menu items → **new** detail pages
(see below)
- **Lifecycle hooks visible** — `pre-install` / `post-install` logic
functions are surfaced in the Trigger column instead of appearing as
empty/misconfigured.

### Logic function settings (Triggers + Test tabs)

- Triggers tab is now editable (HTTP / Cron / Database event / AI tool)
with a `<SettingsLogicFunctionTriggerSection>` wrapper that owns the
toggle, header, and read-only short-circuit.
- HTTP section gets a Live URL field with copy-to-clipboard.
- Each section shows a **Sample input** preview (the JSON the function
will receive) using the same payload builders the Test tab uses.
- Test tab: **Simulate trigger** buttons that prefill the JSON input
from the configured trigger's schema. Replaces an unclickable `<Select>`
(which auto-disables when there's only one option — the typical case).
- Read-only behavior for installed-app functions: explicit `<Callout>`
notice when there's no trigger; trigger sections render as disabled
controls when there is one.
- Removed the empty Environment Variables section from the Settings tab
(it just told the user to go elsewhere).

### New `pages/settings/layout/` folder

Three new app-scoped detail pages so users can drill into entities the
GraphQL `Application` type doesn't expose by id (keyed by manifest
`universalIdentifier`):

- `ApplicationViewDetail` — type, object, visibility + Fields / Filters
/ Sorts subsections (field UIDs resolved to readable labels via
`useFieldLabelByUid`)
- `ApplicationPageLayoutDetail` — type, object + per-tab subsections
listing widgets
- `ApplicationNavigationMenuItemDetail` — type, destination (resolved),
icon, color, position

Each page reads from the marketplace manifest the parent app page
already loads (no extra queries). Folder set up so a future "Layout"
settings tab can grow here (analogous to the existing `data-model/`
folder under the Data tab).

### Other consistency fixes

- Breadcrumbs on every app-scoped entity detail page now include a
category crumb so users know what they're looking at: `Workspace /
Applications / Timely / Navigation menu items / Time entry`.
- Title fallback for nav menu items uses the resolved destination
(`"Time entry"`) instead of the raw enum (`"OBJECT"`).
- New shared utils: `getNavigationMenuItemDestination`,
`resolveManifestObjectLabel`, `getLogicFunctionTriggerLabel`,
`<MonoText>`.

## Backend changes

Only one minor schema-shape change (additive): added `applicationId` to
the `SkillFields` GraphQL fragment and `universalIdentifier` to the
`RoleFragment` so the new lookups have what they need. Generated
metadata schema patched in-tree to match — regenerate with `nx run
twenty-front:graphql:generate --configuration=metadata` if it drifts.

## Test plan

- [ ] Application content tab on an installed app shows the 3 grouped
sections; rows in each section are clickable
- [ ] Click an Object → existing object detail page
- [ ] Click a Field → existing field-edit page
- [ ] Click an Agent / Skill / Role → existing detail page
- [ ] Click a View / Page layout / Navigation menu item → new read-only
detail page; subsections (Fields/Filters/Sorts for views, per-tab
widgets for page layouts) populate correctly
- [ ] Breadcrumbs on every entity detail page have 5 crumbs ending in
`<Category> / <Entity name>`
- [ ] Logic function Triggers tab: toggle each trigger type on/off, see
the Sample input preview update; for installed apps, sections render as
read-only
- [ ] Test tab: each "Simulate trigger" button prefills the JSON editor
with the matching payload shape
- [ ] Functions list: a function configured as `post-install` shows
"Post-install" in the Trigger column

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
2026-04-28 16:08:15 +02:00
martmullandGitHub 8998009805 Stop reseting isListed and is featured after each sync (#20111)
isListed and isFeatured are manually updated by the admin, it should not
be updated if the application already exists
2026-04-28 13:23:42 +00:00
Abdul RahmanandGitHub a710c105cf Fix orphan views by deferring record table widget view creation to dashboard save (#20006) 2026-04-28 10:06:26 +00:00
643 changed files with 38634 additions and 30724 deletions
@@ -3193,6 +3193,7 @@ type Mutation {
updateView(id: String!, input: UpdateViewInput!): View!
deleteView(id: String!): Boolean!
destroyView(id: String!): Boolean!
upsertViewWidget(input: UpsertViewWidgetInput!): View!
createViewSort(input: CreateViewSortInput!): ViewSort!
updateViewSort(input: UpdateViewSortInput!): ViewSort!
deleteViewSort(input: DeleteViewSortInput!): Boolean!
@@ -3510,6 +3511,59 @@ input UpdateViewInput {
shouldHideEmptyGroups: Boolean
}
input UpsertViewWidgetInput {
"""The id of the view widget (page layout widget)."""
widgetId: UUID!
"""The view fields to upsert."""
viewFields: [UpsertViewWidgetViewFieldInput!]
"""The view filters to upsert."""
viewFilters: [UpsertViewWidgetViewFilterInput!]
"""The view filter groups to upsert."""
viewFilterGroups: [UpsertViewWidgetViewFilterGroupInput!]
"""The view sorts to upsert."""
viewSorts: [UpsertViewWidgetViewSortInput!]
}
input UpsertViewWidgetViewFieldInput {
"""The id of an existing view field to update."""
viewFieldId: UUID
"""
The field metadata id. Used to create a new view field when viewFieldId is not provided.
"""
fieldMetadataId: UUID
isVisible: Boolean!
position: Float!
size: Float
}
input UpsertViewWidgetViewFilterInput {
id: UUID
fieldMetadataId: UUID!
operand: ViewFilterOperand = CONTAINS
value: JSON!
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
}
input UpsertViewWidgetViewFilterGroupInput {
id: UUID
parentViewFilterGroupId: UUID
logicalOperator: ViewFilterGroupLogicalOperator = AND
positionInViewFilterGroup: Float
}
input UpsertViewWidgetViewSortInput {
id: UUID
fieldMetadataId: UUID!
direction: ViewSortDirection = ASC
}
input CreateViewSortInput {
id: UUID
fieldMetadataId: UUID!
@@ -2675,6 +2675,7 @@ export interface Mutation {
updateView: View
deleteView: Scalars['Boolean']
destroyView: Scalars['Boolean']
upsertViewWidget: View
createViewSort: ViewSort
updateViewSort: ViewSort
deleteViewSort: Scalars['Boolean']
@@ -5725,6 +5726,7 @@ export interface MutationGenqlSelection{
updateView?: (ViewGenqlSelection & { __args: {id: Scalars['String'], input: UpdateViewInput} })
deleteView?: { __args: {id: Scalars['String']} }
destroyView?: { __args: {id: Scalars['String']} }
upsertViewWidget?: (ViewGenqlSelection & { __args: {input: UpsertViewWidgetInput} })
createViewSort?: (ViewSortGenqlSelection & { __args: {input: CreateViewSortInput} })
updateViewSort?: (ViewSortGenqlSelection & { __args: {input: UpdateViewSortInput} })
deleteViewSort?: { __args: {input: DeleteViewSortInput} }
@@ -5944,6 +5946,30 @@ export interface CreateViewInput {id?: (Scalars['UUID'] | null),name: Scalars['S
export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),type?: (ViewType | null),icon?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null)}
export interface UpsertViewWidgetInput {
/** The id of the view widget (page layout widget). */
widgetId: Scalars['UUID'],
/** The view fields to upsert. */
viewFields?: (UpsertViewWidgetViewFieldInput[] | null),
/** The view filters to upsert. */
viewFilters?: (UpsertViewWidgetViewFilterInput[] | null),
/** The view filter groups to upsert. */
viewFilterGroups?: (UpsertViewWidgetViewFilterGroupInput[] | null),
/** The view sorts to upsert. */
viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
export interface UpsertViewWidgetViewFieldInput {
/** The id of an existing view field to update. */
viewFieldId?: (Scalars['UUID'] | null),
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null)}
export interface CreateViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null),viewId: Scalars['UUID']}
export interface UpdateViewSortInput {
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,12 @@ COPY ./nx.json .
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
RUN yarn
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
RUN npx nx build twenty-website-new
+2 -2
View File
@@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 47.9,
lines: 46,
statements: 47.3,
lines: 45.9,
functions: 39.5,
},
},
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -86,7 +86,7 @@ const StyledFields = styled.div`
`;
const StyledPropertyBoxContainer = styled.div`
height: ${themeCssVariables.spacing[6]};
min-height: ${themeCssVariables.spacing[6]};
width: 100%;
`;
@@ -58,16 +58,15 @@ export const useCustomResolver = <
pageSize,
};
const {
data,
loading: firstQueryLoading,
fetchMore,
error,
} = useQuery<CustomResolverQueryResult<T>>(query, {
const { data, loading, fetchMore, error } = useQuery<
CustomResolverQueryResult<T>
>(query, {
client: apolloCoreClient,
variables: queryVariables,
});
const firstQueryLoading = loading && !data;
useSnackBarOnQueryError(error);
const fetchMoreRecords = async () => {
@@ -121,6 +121,24 @@ const MarkdownRenderer = lazy(async () => {
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
h1: ({ children }) => (
<h1>{processChildrenForRecordLinks(children)}</h1>
),
h2: ({ children }) => (
<h2>{processChildrenForRecordLinks(children)}</h2>
),
h3: ({ children }) => (
<h3>{processChildrenForRecordLinks(children)}</h3>
),
h4: ({ children }) => (
<h4>{processChildrenForRecordLinks(children)}</h4>
),
h5: ({ children }) => (
<h5>{processChildrenForRecordLinks(children)}</h5>
),
h6: ({ children }) => (
<h6>{processChildrenForRecordLinks(children)}</h6>
),
a: ({ children, href, title, node: _node }) => (
<a
className="markdown-link"
@@ -180,6 +180,28 @@ const SettingsApplicationDetails = lazy(() =>
),
);
const SettingsApplicationFrontComponentDetail = lazy(() =>
import(
'~/pages/settings/applications/SettingsApplicationFrontComponentDetail'
).then((module) => ({
default: module.SettingsApplicationFrontComponentDetail,
})),
);
const SettingsLayoutViewDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
default: module.SettingsLayoutViewDetail,
})),
);
const SettingsLayoutPageLayoutDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutPageLayoutDetail').then(
(module) => ({
default: module.SettingsLayoutPageLayoutDetail,
}),
),
);
const SettingsAdminApplicationRegistrationDetail = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetail'
@@ -752,6 +774,18 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route
path={SettingsPath.ApplicationFrontComponentDetail}
element={<SettingsApplicationFrontComponentDetail />}
/>
<Route
path={SettingsPath.ApplicationViewDetail}
element={<SettingsLayoutViewDetail />}
/>
<Route
path={SettingsPath.ApplicationPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
@@ -39,6 +39,13 @@ export const APPLICATION_FRAGMENT = gql`
name
description
applicationId
componentName
builtComponentChecksum
universalIdentifier
isHeadless
usesSdkClient
createdAt
updatedAt
}
objects {
...ObjectMetadataFields
@@ -1,11 +1,5 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useApolloClient } from '@apollo/client/react';
import { MockedProvider } from '@apollo/client/testing/react';
import { type ReactNode, act } from 'react';
import { MemoryRouter } from 'react-router-dom';
@@ -17,10 +11,8 @@ import {
results,
token,
} from '@/auth/hooks/__mocks__/useAuth';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { renderHook } from '@testing-library/react';
import { SupportDriver } from '~/generated-metadata/graphql';
const redirectSpy = jest.fn();
@@ -147,55 +139,15 @@ describe('useAuth', () => {
});
it('should handle sign-out', async () => {
const { result } = renderHook(
() => {
const client = useApolloClient();
const workspaceAuthProviders = useAtomStateValue(
workspaceAuthProvidersState,
);
const billing = useAtomStateValue(billingState);
const isDeveloperDefaultSignInPrefilled = useAtomStateValue(
isDeveloperDefaultSignInPrefilledState,
);
const supportChat = useAtomStateValue(supportChatState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
return {
...useAuth(),
client,
state: {
workspaceAuthProviders,
billing,
isDeveloperDefaultSignInPrefilled,
supportChat,
isMultiWorkspaceEnabled,
},
};
},
{
wrapper: Wrapper,
},
);
sessionStorage.setItem('lingering-key', 'should-be-cleared');
const { signOut, client } = result.current;
const { result } = renderHooks();
await act(async () => {
await signOut();
result.current.signOut();
});
expect(sessionStorage.length).toBe(0);
expect(client.cache.extract()).toEqual({});
const { state } = result.current;
expect(state.workspaceAuthProviders).toEqual(null);
expect(state.billing).toBeNull();
expect(state.isDeveloperDefaultSignInPrefilled).toBe(false);
expect(state.supportChat).toEqual({
supportDriver: SupportDriver.NONE,
supportFrontChatId: null,
});
});
it('should handle credential sign-up', async () => {
@@ -1,8 +1,4 @@
import {
useApolloClient,
useLazyQuery,
useMutation,
} from '@apollo/client/react';
import { useLazyQuery, useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { AppPath } from 'twenty-shared/types';
@@ -28,16 +24,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useLoadMockedMetadata } from '@/metadata-store/hooks/useLoadMockedMetadata';
import { preloadMockedMetadata } from '@/metadata-store/utils/preloadMockedMetadata';
import { lastAuthenticatedMethodState } from '@/auth/states/lastAuthenticatedMethodState';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
SignInUpStep,
@@ -49,18 +36,13 @@ import {
countAvailableWorkspaces,
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { i18n } from '@lingui/core';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -78,8 +60,6 @@ export const useAuth = () => {
);
const { origin } = useOrigin();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
@@ -87,9 +67,7 @@ export const useAuth = () => {
isEmailVerificationRequiredState,
);
const { loadCurrentUser } = useLoadCurrentUser();
const { clearSseClient } = useClearSseClient();
const { applyMockedMetadata } = useLoadMockedMetadata();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetAtomState(signInUpStepState);
@@ -121,64 +99,17 @@ export const useAuth = () => {
CheckUserExistsDocument,
);
const client = useApolloClient();
const [, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const clearSession = useCallback(async () => {
clearSseClient();
store.set(isAppEffectRedirectEnabledState.atom, false);
const mockedData = await preloadMockedMetadata();
const authProvidersValue = store.get(workspaceAuthProvidersState.atom);
const domainConfigurationValue = store.get(domainConfigurationState.atom);
const workspacePublicDataValue = store.get(workspacePublicDataState.atom);
const lastAuthenticatedMethod = store.get(
lastAuthenticatedMethodState.atom,
);
const isCaptchaScriptLoadedValue = store.get(
isCaptchaScriptLoadedState.atom,
);
const clearSession = useCallback(() => {
sessionStorage.clear();
clearSessionLocalStorageKeys();
store.set(workspaceAuthProvidersState.atom, authProvidersValue);
store.set(workspacePublicDataState.atom, workspacePublicDataValue);
store.set(domainConfigurationState.atom, domainConfigurationValue);
store.set(isCaptchaScriptLoadedState.atom, isCaptchaScriptLoadedValue);
store.set(lastAuthenticatedMethodState.atom, lastAuthenticatedMethod);
store.set(tokenPairState.atom, null);
store.set(currentUserState.atom, null);
store.set(currentWorkspaceState.atom, null);
store.set(currentUserWorkspaceState.atom, null);
store.set(currentWorkspaceMemberState.atom, null);
store.set(currentWorkspaceMembersState.atom, []);
store.set(availableWorkspacesState.atom, {
availableWorkspacesForSignIn: [],
availableWorkspacesForSignUp: [],
});
store.set(loginTokenState.atom, null);
store.set(signInUpStepState.atom, SignInUpStep.Init);
applyMockedMetadata(mockedData);
await client.clearStore();
setLastAuthenticateWorkspaceDomain(null);
navigate(AppPath.SignInUp);
store.set(isAppEffectRedirectEnabledState.atom, true);
}, [
clearSseClient,
client,
setLastAuthenticateWorkspaceDomain,
applyMockedMetadata,
navigate,
store,
]);
window.location.assign(AppPath.SignInUp);
}, [store, setLastAuthenticateWorkspaceDomain]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
@@ -475,11 +406,10 @@ export const useAuth = () => {
[handleGetLoginTokenFromCredentials, handleGetAuthTokensFromLoginToken],
);
const handleSignOut = useCallback(async () => {
const handleSignOut = useCallback(() => {
broadcastSignOutToOtherTabs();
await clearSession();
if (isCaptchaScriptLoaded) await requestFreshCaptchaToken();
}, [clearSession, isCaptchaScriptLoaded, requestFreshCaptchaToken]);
clearSession();
}, [clearSession]);
const handleCredentialsSignUpInWorkspace = useCallback(
async ({
@@ -1,13 +1,8 @@
import { useApolloClient } from '@apollo/client/react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStore } from 'jotai';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useAuth } from '@/auth/hooks/useAuth';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { type AuthTokenPair } from '~/generated-metadata/graphql';
const IMPERSONATION_SESSION_KEY = 'impersonation_original_session';
@@ -17,22 +12,27 @@ type StoredImpersonationSession = {
returnPath: string;
};
// Token swaps without a full reload would require enumerating every
// user-scoped atom, localStorage entry, and Apollo cache key — brittle and
// silently broken every time a new piece of user state is added. Instead,
// set the cookie-backed token pair and let the browser re-bootstrap the app.
const reloadWithSession = (returnPath: string) => {
window.location.assign(returnPath);
};
export const useImpersonationSession = () => {
const store = useStore();
const client = useApolloClient();
const navigate = useNavigate();
const { getAuthTokensFromLoginToken, signOut } = useAuth();
const { clearSseClient } = useClearSseClient();
const { loadCurrentUser } = useLoadCurrentUser();
const startImpersonating = useCallback(
async (loginToken: string, returnPath?: string) => {
const currentTokenPair = store.get(tokenPairState.atom);
const targetPath = returnPath ?? window.location.pathname;
if (currentTokenPair) {
const session: StoredImpersonationSession = {
tokenPair: currentTokenPair,
returnPath: returnPath ?? window.location.pathname,
returnPath: targetPath,
};
sessionStorage.setItem(
IMPERSONATION_SESSION_KEY,
@@ -40,30 +40,25 @@ export const useImpersonationSession = () => {
);
}
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
await getAuthTokensFromLoginToken(loginToken);
store.set(isAppEffectRedirectEnabledState.atom, true);
reloadWithSession(targetPath);
},
[store, client, clearSseClient, getAuthTokensFromLoginToken],
[store, getAuthTokensFromLoginToken],
);
const stopImpersonating = useCallback(async () => {
const raw = sessionStorage.getItem(IMPERSONATION_SESSION_KEY);
if (!raw) {
// No stored session — likely a cross-workspace tab opened via redirect.
// Try closing the tab (works when opened via window.open or target=_blank).
// Cross-workspace tab opened via redirect — no stored admin session
// to restore. Close the tab; fall back to sign out if the browser
// blocks window.close().
window.close();
// If window.close() was blocked by the browser, fall back to sign out.
await signOut();
return;
}
let session: StoredImpersonationSession;
try {
session = JSON.parse(raw);
} catch {
@@ -73,19 +68,9 @@ export const useImpersonationSession = () => {
}
sessionStorage.removeItem(IMPERSONATION_SESSION_KEY);
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
store.set(tokenPairState.atom, session.tokenPair);
await loadCurrentUser();
store.set(isAppEffectRedirectEnabledState.atom, true);
navigate(session.returnPath);
}, [store, client, clearSseClient, loadCurrentUser, signOut, navigate]);
reloadWithSession(session.returnPath);
}, [store, signOut]);
const hasStoredSession = useCallback(() => {
return sessionStorage.getItem(IMPERSONATION_SESSION_KEY) !== null;
@@ -1,8 +1,7 @@
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
@@ -21,23 +20,18 @@ export const RecordIndexCommandMenu = () => {
isLayoutCustomizationModeEnabledState,
);
const showEditModePinnedButtons = isLayoutCustomizationModeEnabled;
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<>
{!isMobile && showEditModePinnedButtons ? (
<PinnedCommandMenuItemButtonsEditMode />
) : (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
)}
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="dropdownItem"
@@ -4,7 +4,9 @@ import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/C
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowCommandMenu = () => {
@@ -23,6 +25,9 @@ export const RecordShowCommandMenu = () => {
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isMobile = useIsMobile();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
return (
<>
@@ -32,6 +37,7 @@ export const RecordShowCommandMenu = () => {
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
@@ -125,6 +125,7 @@ export const StandalonePageCommandMenu = () => {
containerType: 'standalone-page-header',
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode: false,
}}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
@@ -45,6 +45,7 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
containerType: 'index-page-dropdown',
commandMenuItems: createMockCommandMenuItems(),
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
}}
>
<Story />
@@ -3,13 +3,13 @@ import { Provider as JotaiProvider } from 'jotai';
import { userEvent, within } from 'storybook/test';
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -44,6 +44,7 @@ const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
...EMPTY_COMMAND_MENU_CONTEXT_API,
isInSidePanel: true,
},
isInPreviewMode: false,
}}
>
<Story />
@@ -9,6 +9,7 @@ export type CommandMenuContextType = {
containerType: CommandMenuItemContainerType;
commandMenuItems: CommandMenuItemFieldsFragment[];
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContext = createContext<CommandMenuContextType>({
@@ -16,4 +17,5 @@ export const CommandMenuContext = createContext<CommandMenuContextType>({
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
});
@@ -2,7 +2,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
import { CommandMenuContextProviderWithWorkflowEnrichment } from './CommandMenuContextProviderWithWorkflowEnrichment';
@@ -12,6 +12,7 @@ type CommandMenuContextProviderProps = {
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
isInPreviewMode?: boolean;
};
export const CommandMenuContextProvider = ({
@@ -19,8 +20,9 @@ export const CommandMenuContextProvider = ({
displayType,
containerType,
children,
isInPreviewMode = false,
}: CommandMenuContextProviderProps) => {
const commandMenuContextApiFromHook = useCommandMenuContextApi();
const commandMenuContextApiFromHook = useCurrentCommandMenuContextApi();
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true }
@@ -45,6 +47,7 @@ export const CommandMenuContextProvider = ({
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderWithWorkflowEnrichment>
@@ -56,6 +59,7 @@ export const CommandMenuContextProvider = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -2,10 +2,12 @@ import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
@@ -17,6 +19,7 @@ type CommandMenuContextProviderContentProps = {
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderContent = ({
@@ -24,19 +27,27 @@ export const CommandMenuContextProviderContent = ({
containerType,
children,
commandMenuContextApi,
isInPreviewMode,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
const commandMenuItemsToDisplay = isInPreviewMode
? (commandMenuItemsDraft ?? commandMenuItems)
: commandMenuItems;
return commandMenuItems
return commandMenuItemsToDisplay
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
@@ -47,7 +58,13 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
}, [
commandMenuContextApi,
commandMenuItems,
commandMenuItemsDraft,
currentPageLayoutId,
isInPreviewMode,
]);
return (
<CommandMenuContext.Provider
@@ -56,6 +73,7 @@ export const CommandMenuContextProviderContent = ({
containerType,
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode,
}}
>
{children}
@@ -12,6 +12,7 @@ type CommandMenuContextProviderWithWorkflowEnrichmentProps = {
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderWithWorkflowEnrichment = ({
@@ -20,6 +21,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
children,
commandMenuContextApi,
selectedWorkflowRecordIds,
isInPreviewMode,
}: CommandMenuContextProviderWithWorkflowEnrichmentProps) => {
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
selectedWorkflowRecordIds,
@@ -54,6 +56,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={enrichedCommandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -11,6 +11,7 @@ import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-lis
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -18,6 +19,14 @@ import { Loader } from 'twenty-ui/feedback';
import { MenuItem } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
const StyledPreviewWrapper = styled.div`
cursor: not-allowed;
& * {
pointer-events: none;
}
`;
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
};
@@ -27,7 +36,8 @@ type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi } = useContext(CommandMenuContext);
const { commandMenuContextApi, isInPreviewMode } =
useContext(CommandMenuContext);
const { getIcon } = useIcons();
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
@@ -43,14 +53,19 @@ const CommandMenuItemButtonRenderer = ({
label,
});
const command = { key: item.id, label, shortLabel, Icon };
if (isInPreviewMode) {
return (
<StyledPreviewWrapper>
<CommandMenuButton command={command} />
</StyledPreviewWrapper>
);
}
return (
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
command={command}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
/>
@@ -2,6 +2,7 @@ import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/const
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { isNumber } from '@sniptt/guards';
import { useCallback, useMemo } from 'react';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
@@ -25,18 +26,37 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
[pinnedCommandMenuItems],
);
const hasKnownPinnedInlineLayout = useMemo(
() =>
commandMenuPinnedInlineLayout.containerWidth > 0 &&
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
isNumber(
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
commandMenuItemKey
],
),
),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
);
const visiblePinnedCommandMenuItemCount = useMemo(
() =>
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
}),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
hasKnownPinnedInlineLayout
? getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
})
: 0,
[
commandMenuPinnedInlineLayout,
hasKnownPinnedInlineLayout,
pinnedCommandMenuItemKeysInDisplayOrder,
],
);
const pinnedInlineCommandMenuItems = useMemo(
@@ -83,7 +83,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
const triggerLabel = isNoneSelected
? t`No record selected`
: t`Records selected`;
: t`Record(s) selected`;
return (
<Dropdown
@@ -108,6 +108,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
dropdownOffset={{ y: 4 }}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<StyledDropdownMenuContainer
@@ -122,7 +123,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
/>
<MenuItemSelect
LeftIcon={IconSquareCheck}
text={t`Records selected`}
text={t`Record(s) selected`}
selected={!isNoneSelected}
onClick={() => handleSelectMode('selection')}
/>
@@ -1,140 +0,0 @@
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext, useMemo } from 'react';
import { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledCommandMenuItemContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
`;
const StyledWrapper = styled.div`
flex: 1 1 0;
min-width: 0;
overflow: hidden;
`;
const StyledContainer = styled.div`
display: flex;
justify-content: flex-end;
min-width: 0;
width: 100%;
`;
const StyledItemsContainer = styled.div`
display: flex;
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
max-width: 100%;
overflow: hidden;
`;
export const PinnedCommandMenuItemButtonsEditMode = () => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const allowedAvailabilityTypes = useMemo(
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]),
[mainContextStoreHasSelectedRecords],
);
const pinnedCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned);
const {
pinnedInlineCommandMenuItems,
pinnedOverflowCommandMenuItems,
onContainerDimensionChange,
onCommandMenuItemDimensionChange,
} = usePinnedCommandMenuItemsInlineLayout({
pinnedCommandMenuItems,
});
return (
<>
<PinnedCommandMenuItemsInlineMeasurements
pinnedCommandMenuItems={[
...pinnedInlineCommandMenuItems,
...pinnedOverflowCommandMenuItems,
]}
onPinnedCommandMenuItemDimensionChange={
onCommandMenuItemDimensionChange
}
/>
<StyledWrapper>
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item) => {
const { iconKey, label, shortLabel } =
interpolateCommandMenuItemFields(item, commandMenuContextApi);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
return (
<StyledCommandMenuItemContainer
key={item.id}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
disabled
/>
</StyledCommandMenuItemContainer>
);
})}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
</StyledWrapper>
</>
);
};
@@ -1,15 +1,13 @@
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
import { useEditableCommandMenuItems } from '@/command-menu-item/edit/hooks/useEditableCommandMenuItems';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useResetCommandMenuItemsDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
@@ -36,10 +34,7 @@ import {
import { Button } from 'twenty-ui/input';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledContainer = styled.div`
@@ -66,7 +61,7 @@ const StyledContent = styled.div`
export const SidePanelCommandMenuItemEditPage = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
@@ -76,44 +71,20 @@ export const SidePanelCommandMenuItemEditPage = () => {
const isRecordPage =
commandMenuContextApi.pageType === ContextStorePageType.Record;
const isIndexPage =
commandMenuContextApi.pageType === ContextStorePageType.Index;
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft();
const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft();
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
...(isIndexPage || isRecordPage
? [CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT]
: []),
...(mainContextStoreHasSelectedRecords
? [CommandMenuItemAvailabilityType.RECORD_SELECTION]
: []),
]);
const filteredCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
const editableCommandMenuItems = useEditableCommandMenuItems();
const filteredCommandMenuItemIds = new Set(
filteredCommandMenuItems.map((item) => item.id),
editableCommandMenuItems.map((item) => item.id),
);
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
@@ -123,7 +94,7 @@ export const SidePanelCommandMenuItemEditPage = () => {
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } =
groupCommandMenuItems(filteredCommandMenuItems);
groupCommandMenuItems(editableCommandMenuItems);
const normalizedSearch =
sidePanelSearch.length > 0
@@ -0,0 +1,38 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const useEditableCommandMenuItems = () => {
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
return useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
return (commandMenuItemsDraft ?? [])
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(
(item) =>
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItemsDraft, commandMenuContextApi, currentPageLayoutId]);
};
@@ -70,6 +70,7 @@ const getWrapper =
objectMetadataItem: {},
objectMetadataLabel: '',
},
isInPreviewMode: false,
}}
>
{children}
@@ -30,7 +30,7 @@ import {
} from 'twenty-shared/types';
import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
export const useCurrentCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
@@ -0,0 +1,36 @@
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
const buildCommandMenuItem = (
availabilityType: CommandMenuItemAvailabilityType,
) =>
({
availabilityType,
}) as CommandMenuItemFieldsFragment;
describe('doesCommandMenuItemMatchSelectionState', () => {
it('should keep a non-record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(CommandMenuItemAvailabilityType.GLOBAL);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(true);
});
it('should hide a record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(false);
});
it('should keep a record-selection item when records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(true)(item)).toBe(true);
});
});
@@ -0,0 +1,9 @@
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchSelectionState =
(hasSelectedRecords: boolean) => (item: CommandMenuItemFieldsFragment) =>
item.availabilityType !==
CommandMenuItemAvailabilityType.RECORD_SELECTION || hasSelectedRecords;
@@ -29,12 +29,6 @@ export const SentryInitEffect = () => {
setIsSentryInitializing(true);
try {
// oxlint-disable-next-line no-console
console.debug(
'Initializing Sentry with DSN ending in:',
sentryConfig?.dsn?.slice(-8),
);
const {
init,
browserTracingIntegration,
@@ -60,13 +54,8 @@ export const SentryInitEffect = () => {
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
ignoreErrors: [
/Failed to set the 'value' property on 'HTMLInputElement'/,
],
});
// oxlint-disable-next-line no-console
console.debug('Sentry initialized successfully');
setIsSentryInitialized(true);
} catch (error) {
// oxlint-disable-next-line no-console
@@ -84,12 +73,6 @@ export const SentryInitEffect = () => {
!isSentryUserDefined
) {
try {
// oxlint-disable-next-line no-console
console.debug(
'Setting Sentry user context for user:',
currentUser?.id,
);
const { setUser } = await import('@sentry/react');
setUser({
email: currentUser?.email,
@@ -97,7 +80,6 @@ export const SentryInitEffect = () => {
workspaceId: currentWorkspace?.id,
workspaceMemberId: currentWorkspaceMember?.id,
});
setIsSentryUserDefined(true);
} catch (error) {
// oxlint-disable-next-line no-console
@@ -105,9 +87,6 @@ export const SentryInitEffect = () => {
}
} else if (!isDefined(currentUser) && isSentryInitialized) {
try {
// oxlint-disable-next-line no-console
console.debug('Clearing Sentry user context');
const { setUser } = await import('@sentry/react');
setUser(null);
} catch (error) {
@@ -39,21 +39,11 @@ export const FileUploadProvider = ({
try {
if (!isDefined(files) || files.length === 0) {
// oxlint-disable-next-line no-console
console.debug('File upload cancelled by user');
currentOptions.onCancel?.();
} else {
const filesArray = Array.from(files);
// oxlint-disable-next-line no-console
console.debug(
`File upload started: ${filesArray.length} file(s) selected`,
);
await currentOptions.onUpload(filesArray);
}
} catch (error) {
// oxlint-disable-next-line no-console
console.error('File upload error:', error);
throw error;
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
@@ -72,12 +62,7 @@ export const FileUploadProvider = ({
}
try {
// oxlint-disable-next-line no-console
console.debug('File upload dialog cancelled');
currentOptions.onCancel?.();
} catch (error) {
// oxlint-disable-next-line no-console
console.error('Error handling file upload cancel:', error);
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
@@ -1,5 +1,4 @@
import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetEditorModePersistedComponentState } from '@/page-layout/states/fieldsWidgetEditorModePersistedComponentState';
@@ -10,6 +9,9 @@ import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layou
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -90,6 +92,18 @@ export const useCancelLayoutCustomization = () => {
}),
fieldsWidgetEditorModePersisted,
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewPersisted,
);
}
exitLayoutCustomizationMode();
@@ -1,6 +1,5 @@
import { useCommandMenuItemsDraftState } from '@/command-menu-item/hooks/useCommandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
@@ -8,6 +7,9 @@ import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/st
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { atom, useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -86,6 +88,26 @@ export const useIsLayoutCustomizationDirty = () => {
if (!isDeeplyEqual(ungroupedFieldsDraft, ungroupedFieldsPersisted)) {
return true;
}
const recordTableWidgetViewDraft = get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewPersisted = get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
if (
!isDeeplyEqual(
recordTableWidgetViewDraft,
recordTableWidgetViewPersisted,
)
) {
return true;
}
}
return false;
@@ -7,6 +7,7 @@ import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/state
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useCreatePendingRecordTableWidgetViews } from '@/page-layout/hooks/useCreatePendingRecordTableWidgetViews';
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -42,6 +43,8 @@ export const useSaveLayoutCustomization = () => {
useUpdatePageLayoutWithTabsAndWidgets();
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
@@ -113,6 +116,7 @@ export const useSaveLayoutCustomization = () => {
);
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft, {
@@ -185,6 +189,7 @@ export const useSaveLayoutCustomization = () => {
saveCommandMenuItemsDraft,
isCommandMenuItemsDirty,
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
updatePageLayoutWithTabsAndWidgets,
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,

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