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
168 changed files with 2782 additions and 2944 deletions
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Rekordtipe"
msgid "Record-level"
msgstr "Rekordvlak"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Rekords"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "نوع السجل"
msgid "Record-level"
msgstr "مستوى السجل"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "السجلات"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipus de registre"
msgid "Record-level"
msgstr "Nivell de registre"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Registres"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Typ záznamu"
msgid "Record-level"
msgstr "Na úrovni záznamu"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Záznamy"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Posttype"
msgid "Record-level"
msgstr "Postniveau"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Protokoller"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Datensatztyp"
msgid "Record-level"
msgstr "Datensatzebene"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Datensätze"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Τύπος εγγραφής"
msgid "Record-level"
msgstr "Επίπεδο εγγραφής"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Εγγραφές"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12075,17 +12075,17 @@ msgstr "Record Type"
msgid "Record-level"
msgstr "Record-level"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr "Record(s) selected"
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Records"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr "Records selected"
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipo de registro"
msgid "Record-level"
msgstr "Nivel de registro"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Registros"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tietuetyyppi"
msgid "Record-level"
msgstr "Tietuetaso"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Tietueet"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Type d'enregistrement"
msgid "Record-level"
msgstr "Niveau des enregistrements"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Enregistrements"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
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
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "סוג רשומה"
msgid "Record-level"
msgstr "ברמת הרשומה"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "רישומים"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Rekordtípus"
msgid "Record-level"
msgstr "Rekordszintű"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Rekordok"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipo di record"
msgid "Record-level"
msgstr "Livello record"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Registri"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "レコードタイプ"
msgid "Record-level"
msgstr "レコードレベル"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "レコード"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "레코드 유형"
msgid "Record-level"
msgstr "레코드 수준"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "레코드"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Recordtype"
msgid "Record-level"
msgstr "Recordniveau"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Records"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Oppføringstype"
msgid "Record-level"
msgstr "Oppføringsnivå"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Poster"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Typ rekordu"
msgid "Record-level"
msgstr "Poziom rekordu"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Rekordy"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
@@ -12052,17 +12052,17 @@ msgstr ""
msgid "Record-level"
msgstr ""
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr ""
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipo de registro"
msgid "Record-level"
msgstr "Nível de registro"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Registros"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipo de Registo"
msgid "Record-level"
msgstr "Nível de registo"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Registros"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Tipul înregistrării"
msgid "Record-level"
msgstr "Nivel de înregistrare"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Înregistrări"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
Binary file not shown.
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Тип записа"
msgid "Record-level"
msgstr "Ниво записа"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Рекорди"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12059,17 +12059,17 @@ msgstr "Posttyp"
msgid "Record-level"
msgstr "Postnivå"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Poster"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Kayıt Türü"
msgid "Record-level"
msgstr "Kayıt düzeyi"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Kayıtlar"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Тип запису"
msgid "Record-level"
msgstr "Рівень запису"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Записи"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "Loại bản ghi"
msgid "Record-level"
msgstr "Cấp bản ghi"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "Hồ Sơ"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "记录类型"
msgid "Record-level"
msgstr "记录级"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "记录"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
+6 -6
View File
@@ -12057,17 +12057,17 @@ msgstr "記錄類型"
msgid "Record-level"
msgstr "記錄層級"
#. js-lingui-id: X5BjmT
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Record(s) selected"
msgstr ""
#. js-lingui-id: PWlTvh
#: src/modules/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader.tsx
msgid "Records"
msgstr "紀錄"
#. js-lingui-id: J+Qyf2
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
#: src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx
msgid "Records selected"
msgstr ""
#. js-lingui-id: wRTiSD
#: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx
msgid "Red"
@@ -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"
@@ -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;
@@ -1,11 +1,10 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { UpdateMultipleRecordsContainer } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsContainer';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useEffect } from 'react';
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { MockLink } from '@apollo/client/testing';
import {
@@ -14,6 +13,7 @@ import {
type StoryObj,
} from '@storybook/react-vite';
import gql from 'graphql-tag';
import { useEffect } from 'react';
import { expect, userEvent, within } from 'storybook/test';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -80,6 +80,7 @@ const meta: Meta<typeof UpdateMultipleRecordsContainer> = {
containerType: 'index-page-dropdown',
displayType: 'dropdownItem',
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
}}
>
<Story />
@@ -0,0 +1,48 @@
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import Linkify from 'linkify-react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
const StyledTextBody = styled(motion.div)`
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-direction: column;
margin-top: ${themeCssVariables.spacing[4]};
overflow-wrap: break-word;
white-space: pre-line;
a {
color: ${themeCssVariables.color.blue};
text-decoration: underline;
&:hover {
text-decoration-color: ${themeCssVariables.color.blue};
}
}
`;
type LinkifiedTextBodyProps = {
body: string;
isDisplayed: boolean;
};
export const LinkifiedTextBody = ({
body,
isDisplayed,
}: LinkifiedTextBodyProps) => {
return (
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
<StyledTextBody>
<Linkify
options={{
target: '_blank',
rel: 'noopener noreferrer',
}}
>
{body}
</Linkify>
</StyledTextBody>
</AnimatedEaseInOut>
);
};
+1 -1
View File
@@ -11,7 +11,7 @@ const jestConfig = {
testEnvironment: 'node',
setupFilesAfterEnv: ['./setupTests.ts'],
transformIgnorePatterns: [
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line)/)',
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt)/)',
],
testRegex: '.*\\.spec\\.ts$',
transform: {
+11 -9
View File
@@ -15,14 +15,14 @@
"typeorm": "../../node_modules/typeorm/.bin/typeorm"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.83",
"@ai-sdk/anthropic": "^3.0.46",
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/openai-compatible": "^2.0.40",
"@ai-sdk/provider-utils": "^4.0.15",
"@ai-sdk/xai": "^3.0.57",
"@ai-sdk/amazon-bedrock": "^4.0.97",
"@ai-sdk/anthropic": "^3.0.72",
"@ai-sdk/google": "^3.0.65",
"@ai-sdk/mistral": "^3.0.31",
"@ai-sdk/openai": "^3.0.54",
"@ai-sdk/openai-compatible": "^2.0.42",
"@ai-sdk/provider-utils": "^4.0.24",
"@ai-sdk/xai": "^3.0.84",
"@aws-sdk/client-lambda": "3.1001.0",
"@aws-sdk/client-s3": "3.1001.0",
"@aws-sdk/client-sesv2": "3.1001.0",
@@ -44,6 +44,7 @@
"@graphql-tools/utils": "9.2.1",
"@graphql-yoga/nestjs": "2.1.0",
"@jrmdayn/googleapis-batcher": "^0.10.1",
"@lifeomic/axios-fetch": "^3.1.0",
"@lingui/conf": "5.1.2",
"@lingui/core": "^5.1.2",
"@lingui/format-po": "5.1.2",
@@ -103,6 +104,7 @@
"dataloader": "2.2.2",
"date-fns": "2.30.0",
"deep-equal": "2.2.3",
"digest-fetch": "^3.1.1",
"dompurify": "3.3.3",
"dotenv": "16.4.5",
"exa-js": "^2.11.0",
@@ -178,7 +180,7 @@
"temporal-polyfill": "^0.3.0",
"transliteration": "2.3.5",
"tsconfig-paths": "^4.2.0",
"tsdav": "^2.1.5",
"tsdav": "^2.2.0",
"tslib": "2.8.1",
"type-fest": "4.10.1",
"typeorm": "patch:typeorm@0.3.20#./patches/typeorm+0.3.20.patch",
@@ -1,5 +1,5 @@
CREATE TABLE
IF NOT EXISTS usageEvent_v2 (
CREATE TABLE IF NOT EXISTS
usageEvent_v2 (
`timestamp` DateTime64 (3) NOT NULL,
`workspaceId` String NOT NULL,
`periodStart` DateTime64 (3),
@@ -37,8 +37,9 @@ SELECT
FROM
usageEvent;
RENAME TABLE usageEvent TO usageEvent_old,
usageEvent_v2 TO usageEvent;
RENAME TABLE usageEvent TO usageEvent_old;
RENAME TABLE usageEvent_v2 TO usageEvent;
DROP TABLE IF EXISTS usageEvent_old;
@@ -50,4 +51,3 @@ ALTER TABLE usageEvent ADD PROJECTION IF NOT EXISTS billing_by_workspace_period
GROUP BY
workspaceId, periodStart
);
@@ -0,0 +1,27 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.2.0', 1777455269302)
export class AddCacheTokensToAgentChatThreadFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD "totalCacheReadTokens" bigint NOT NULL DEFAULT 0`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD "totalCacheCreationTokens" bigint NOT NULL DEFAULT 0`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalCacheCreationTokens"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalCacheReadTokens"`,
);
}
}
@@ -1,7 +1,18 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SetCalendarEventDescriptionDisplayedMaxRowsCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [],
providers: [],
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [SetCalendarEventDescriptionDisplayedMaxRowsCommand],
})
export class V2_2_UpgradeVersionCommandModule {}
@@ -0,0 +1,120 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const DISPLAYED_MAX_ROWS = 99;
@RegisteredWorkspaceCommand('2.2.0', 1786000000000)
@Command({
name: 'upgrade:2-2:set-calendar-event-description-displayed-max-rows',
description:
'Set displayedMaxRows setting on calendarEvent.description field',
})
export class SetCalendarEventDescriptionDisplayedMaxRowsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const descriptionField =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.calendarEvent.fields.description.universalIdentifier,
});
if (!descriptionField) {
this.logger.log(
`calendarEvent.description field not found for workspace ${workspaceId}, skipping`,
);
return;
}
const textSettings =
descriptionField.settings as FieldMetadataSettings<FieldMetadataType.TEXT>;
if (textSettings?.displayedMaxRows === DISPLAYED_MAX_ROWS) {
this.logger.log(
`calendarEvent.description displayedMaxRows already set for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fieldToUpdate = {
...descriptionField,
settings: {
...textSettings,
displayedMaxRows: DISPLAYED_MAX_ROWS,
},
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [fieldToUpdate],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
}
this.logger.log(
`Set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}`,
);
}
}
@@ -18,6 +18,7 @@ import { AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceComma
import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration';
import { AddProviderExecutedToAgentMessagePartFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1777012800000-add-provider-executed-to-agent-message-part';
import { BackfillPageLayoutWidgetPositionSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-slow-1795000002000-backfill-page-layout-widget-position';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -38,4 +39,5 @@ export const INSTANCE_COMMANDS = [
AddIsPreInstalledToApplicationRegistrationFastInstanceCommand,
AddProviderExecutedToAgentMessagePartFastInstanceCommand,
BackfillPageLayoutWidgetPositionSlowInstanceCommand,
AddCacheTokensToAgentChatThreadFastInstanceCommand,
];
@@ -341,6 +341,10 @@ export class BillingUsageService {
}
async hasAvailableCredits(workspaceId: string): Promise<boolean> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return true;
}
const { billingSubscription: subscription } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
@@ -369,6 +373,17 @@ export class BillingUsageService {
return availableCredits > 0;
}
async hasAvailableCreditsOrThrow(workspaceId: string): Promise<void> {
const hasCredits = await this.hasAvailableCredits(workspaceId);
if (!hasCredits) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
async getCurrentPeriodCreditsUsed(
workspaceId: string,
periodStart: Date,
@@ -16,6 +16,7 @@ import { TimelineCalendarEventService } from './timeline-calendar-event.service'
type MockWorkspaceRepository = Partial<
WorkspaceRepository<CalendarEventWorkspaceEntity>
> & {
count: jest.Mock;
find: jest.Mock;
findAndCount: jest.Mock;
};
@@ -40,6 +41,7 @@ describe('TimelineCalendarEventService', () => {
beforeEach(async () => {
mockCalendarEventRepository = {
count: jest.fn().mockResolvedValue(1),
find: jest.fn(),
findAndCount: jest.fn(),
};
@@ -55,6 +55,16 @@ export class TimelineCalendarEventService {
'calendarEvent',
);
const totalNumberOfCalendarEvents = await calendarEventRepository.count(
{
where: {
calendarEventParticipants: {
personId: Any(personIds),
},
},
},
);
const calendarEventIds = await calendarEventRepository.find({
where: {
calendarEventParticipants: {
@@ -76,12 +86,12 @@ export class TimelineCalendarEventService {
if (ids.length <= 0) {
return {
totalNumberOfCalendarEvents: 0,
totalNumberOfCalendarEvents,
timelineCalendarEvents: [],
};
}
const [events, total] = await calendarEventRepository.findAndCount({
const [events] = await calendarEventRepository.findAndCount({
where: {
id: Any(ids),
},
@@ -241,7 +251,7 @@ export class TimelineCalendarEventService {
});
return {
totalNumberOfCalendarEvents: total,
totalNumberOfCalendarEvents,
timelineCalendarEvents,
};
},
@@ -57,12 +57,24 @@ export class S3Driver implements StorageDriver {
return;
}
this.s3Client = new S3({ ...s3Options, region, endpoint });
this.s3Client = new S3({
...s3Options,
region,
endpoint,
maxAttempts: 5,
retryMode: 'adaptive',
});
this.bucketName = bucketName;
if (presignEnabled) {
this.presignClient = presignEndpoint
? new S3({ ...s3Options, region, endpoint: presignEndpoint })
? new S3({
...s3Options,
region,
endpoint: presignEndpoint,
maxAttempts: 5,
retryMode: 'adaptive',
})
: this.s3Client;
}
}
@@ -109,7 +121,14 @@ export class S3Driver implements StorageDriver {
Bucket: this.bucketName,
});
await this.s3Client.send(command);
try {
await this.s3Client.send(command);
} catch (error) {
this.logger.error(
`S3 writeFile failed for key "${params.filePath}": ${error.name ?? 'UnknownError'} - ${error.message}`,
);
throw error;
}
}
private async createFolder(path: string) {

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