Compare commits

...
Author SHA1 Message Date
Charles Bochet 9e02573c41 fix: make LayoutRenderingContext optional in FrontComponentRenderer
The settings custom tab doesn't operate within a page layout, so forcing
a LayoutRenderingProvider wrapper with an arbitrary layout type is wrong.
Instead, make the context optional in useFrontComponentExecutionContext
so consumers without a page layout simply get recordId: null.

Made-with: Cursor
2026-04-15 19:04:50 +02:00
Charles Bochet 386e2af3c5 Merge branch 'main' into fix/settings-custom-tab-layout-context 2026-04-15 18:50:40 +02:00
5eda10760c Fix navbar folder opening lag by deferring navigation until expand animation completes (#19686)
### Before


https://github.com/user-attachments/assets/7d57aa55-8d4c-43e1-8835-f40206e5e453



### After


https://github.com/user-attachments/assets/2457c8ee-fcb9-41ae-a8f7-08f8c7b4a233

---------

Co-authored-by: Etienne <[email protected]>
2026-04-15 16:31:33 +00:00
b9605a3003 Refactor SnackBar duration handling and progress bar visibility logic (#19712)
## Summary

Fixes error snackbars disappearing too quickly by **disabling
auto-dismiss for error variants by default**. Error snackbars now remain
visible until the user closes them.

Closes #19694

## What changed

- Error snackbars no longer default to a 6s timeout (they only
auto-dismiss if an explicit `duration` is provided).
- Non-error snackbars keep the existing default auto-dismiss behavior
(6s).
- Progress bar animation/visibility is tied to auto-dismiss (no progress
bar when there’s no duration).

**Files**
-
packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx

## How to test

1. Trigger a long error snackbar (example: spreadsheet/CSV import error
with a long message).
2. Confirm the snackbar **stays visible** until clicking **Close**.
3. Trigger a success/info snackbar and confirm it **still
auto-dismisses** after ~6s.

## Notes

- Call sites that explicitly pass `options.duration` for error snackbars
will continue to auto-dismiss (intentional).

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-04-15 16:22:28 +00:00
WeikoandGitHub 56d6e13b5d Fix fields widget flash during reset (#19726)
Summary

- Remove the evictViewMetadataForViewIds step from the page-layout reset
flow. It synchronously cleared viewFields/viewFieldGroups rows from the
metadata store, leaving a window where
useFieldsWidgetGroups saw a view with no fields and fell back to
buildDefaultFieldsWidgetGroups, briefly rendering a synthetic "General"
+ "Other" layout before the real reset defaults
arrived.
- invalidateMetadataStore() alone is sufficient: it marks the
collections stale and triggers MinimalMetadataLoadEffect to refetch,
which replaces current atomically. The UI now
transitions old-layout → new-default with no synthetic flash.
- Simplified refreshPageLayoutAfterReset to no longer take a
collectAffectedViewIds callback, and updated both tab/widget reset call
sites plus ObjectLayout.tsx accordingly.
- Deleted the now-unused evictViewMetadataForViewIds and
collectViewIdsFromWidgets utils.
2026-04-15 16:08:24 +00:00
725171bfd3 i18n - translations (#19729)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-04-15 18:11:26 +02:00
Raphaël BosiandGitHub 3b85747d3f Go back to the original command K button (#19727)
## Before


https://github.com/user-attachments/assets/868e5293-8843-44c8-8011-b7130e97fa95


## After


https://github.com/user-attachments/assets/89cf46cc-1fb1-4e78-bfef-53e1d04e8ebf
2026-04-15 15:53:26 +00:00
Paul RastoinandGitHub a4cc7fb9c5 [Upgrade] Fix workspace creation cursor (#19701)
## Summary

### Problem

The upgrade migration system required new workspaces to always start
from a workspace command, which was too rigid. When the system was
mid-upgrade within an instance command (IC) segment, workspace creation
would fail or produce inconsistent state.

### Solution

#### Workspace-scoped instance command rows

Instance commands now write upgrade migration rows for **all
active/suspended workspaces** alongside the global row. This means every
workspace has a complete migration history, including instance command
records.

- `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds`
immediately before writing records (both success and failure paths) to
mitigate race conditions with concurrent workspace creation.
- `recordUpgradeMigration` in `UpgradeMigrationService` accepts a
discriminated union over `status`, handles `error: unknown` formatting
internally, and writes global + workspace rows in batch.

#### Flexible initial cursor for new workspaces

`getInitialCursorForNewWorkspace` now accepts the last **attempted**
(not just completed) instance command with its status:

- If the IC is `completed` and the next step is a workspace segment →
cursor is set to the last WC of that segment (existing behavior).
- If the IC is `failed` or not the last of its segment → cursor is set
to that IC itself, preserving its status.

This allows workspaces to be created at any point during the upgrade
lifecycle, including mid-IC-segment and after IC failure.

#### Relaxed workspace segment validation

`validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose
cursor is:
1. Within the current workspace segment, OR
2. At the immediately preceding instance command with `completed` status
(handles the `-w` single-workspace upgrade scenario).

Workspaces with cursors in a previous segment, ahead of the current
segment, or at a preceding IC with `failed` status are rejected.

### Test plan
created empty workspaces to allow testing upgrade with several active
workspaces
2026-04-15 15:41:10 +00:00
Raphaël BosiandGitHub 0f8152f536 Fix command menu item edit record selection dropdown icons (#19725)
## Before
<img width="816" height="126" alt="CleanShot 2026-04-15 at 17 20 11@2x"
src="https://github.com/user-attachments/assets/647d6aa6-1000-41d9-9351-c1489bc095c6"
/>


## After
<img width="808" height="120" alt="CleanShot 2026-04-15 at 17 19 05@2x"
src="https://github.com/user-attachments/assets/e7685370-b6eb-4720-a5dd-401dfae8dc6b"
/>
2026-04-15 15:31:59 +00:00
Charles BochetandGitHub a328c127f3 Fix standalone page migration failing on navigationMenuItem enum type change (#19724)
## Summary
- The `AddStandalonePageFastInstanceCommand` migration was failing with:
`default for column "type" cannot be cast automatically to type
core."navigationMenuItem_type_enum"`
- The migration was missing `DROP DEFAULT` / `SET DEFAULT` around the
`ALTER COLUMN TYPE` for `navigationMenuItem.type`. PostgreSQL cannot
automatically cast the existing default (`'VIEW'::old_enum`) to the new
enum type.
- The same 3-step pattern (DROP DEFAULT → ALTER TYPE → SET DEFAULT) was
already correctly applied for `pageLayout.type` in the same migration —
this fix brings `navigationMenuItem.type` in line.
2026-04-15 17:25:55 +02:00
Baptiste DevessierandGitHub 8cb803cedf Various bug fixes Record page layouts (#19719)
Fixes:

- Can't add multiple widgets in a row
- Ensure newly created is always focused
2026-04-15 15:12:57 +00:00
WeikoandGitHub 5e83ad43de Update backfill page layout command (#19687) 2026-04-15 14:55:42 +00:00
Lazare-42andClaude Opus 4.6 3b530d0e79 fix: wrap settings custom tab FrontComponentRenderer with LayoutRenderingProvider
FrontComponentRenderer uses useFrontComponentExecutionContext which calls
useLayoutRenderingContext(). Every other render site (side panel, command
menu, record pages) provides this context, but SettingsApplicationCustomTab
was missing it, causing a crash when opening the Custom tab in app settings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-14 12:53:15 +02:00
83 changed files with 2796 additions and 1596 deletions
@@ -3189,7 +3189,6 @@ msgstr "Knipbord vereis 'n veilige verbinding (HTTPS). Gebruik asseblief hierdie
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Maak toe"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Sluit banier"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Maak sypaneel toe"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Meer"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Maak Outlook oop"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Maak sypaneel oop"
@@ -3189,7 +3189,6 @@ msgstr "تتطلب الحافظة اتصالاً آمناً (HTTPS). يُرجى
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "إغلاق"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "إغلاق اللافتة"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "إغلاق اللوحة الجانبية"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "المزيد"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "فتح Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "فتح اللوحة الجانبية"
@@ -3189,7 +3189,6 @@ msgstr "El porta-retalls requereix una connexió segura (HTTPS). Accedeix a aque
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Tanca"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Tanca el bàner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Tanca el panell lateral"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Més"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Obrir en Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Obre el panell lateral"
@@ -3189,7 +3189,6 @@ msgstr "Schránka vyžaduje zabezpečené připojení (HTTPS). Pro povolení kop
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Zavřít"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Zavřít banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Zavřít postranní panel"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Více"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Otevřít Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Otevřít postranní panel"
@@ -3189,7 +3189,6 @@ msgstr "Udklipsholderen kræver en sikker forbindelse (HTTPS). Tilgå venligst d
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Luk"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Luk banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Luk sidepanelet"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mere"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Åbn Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Åbn sidepanelet"
@@ -3189,7 +3189,6 @@ msgstr "Die Zwischenablage erfordert eine sichere Verbindung (HTTPS). Bitte grei
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Schließen"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Banner schließen"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Seitenpanel schließen"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mehr"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Outlook öffnen"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Seitenpanel öffnen"
@@ -3189,7 +3189,6 @@ msgstr "Το Πρόχειρο απαιτεί ασφαλή σύνδεση (HTTPS)
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Κλείσιμο"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Κλείσιμο banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Κλείσιμο πλευρικού πάνελ"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Περισσότερα"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Άνοιγμα με Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Άνοιγμα πλευρικού πάνελ"
-4
View File
@@ -3184,7 +3184,6 @@ msgstr "Clipboard requires a secure connection (HTTPS). Please access this app o
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Close"
@@ -3195,7 +3194,6 @@ msgid "Close banner"
msgstr "Close banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Close side panel"
@@ -9276,7 +9274,6 @@ msgstr "months"
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "More"
@@ -10719,7 +10716,6 @@ msgid "Open Outlook"
msgstr "Open Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Open side panel"
@@ -3189,7 +3189,6 @@ msgstr "El portapapeles requiere una conexión segura (HTTPS). Accede a esta apl
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Cerrar"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Cerrar banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Cerrar panel lateral"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Más"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Abrir Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Abrir panel lateral"
@@ -3189,7 +3189,6 @@ msgstr "Leikepöytä edellyttää suojattua yhteyttä (HTTPS). Avaa tämä sovel
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Sulje"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Sulje banneri"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Sulje sivupaneeli"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Lisää"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Avaa Outlookissa"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Avaa sivupaneeli"
@@ -3189,7 +3189,6 @@ msgstr "Le presse-papiers nécessite une connexion sécurisée (HTTPS). Veuillez
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Fermer"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Fermer la bannière"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Fermer le panneau latéral"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Plus"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Ouvrir Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Ouvrir le panneau latéral"
@@ -3189,7 +3189,6 @@ msgstr "לוח הגזירים דורש חיבור מאובטח (HTTPS). יש ל
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "סגור"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "סגור באנר"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "סגור את חלונית הצד"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "עוד"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "פתח ב-Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "פתח את חלונית הצד"
@@ -3189,7 +3189,6 @@ msgstr ""
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Bezár"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Banner bezárása"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Oldalsó panel bezárása"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Több"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Outlook megnyitása"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Oldalsó panel megnyitása"
@@ -3189,7 +3189,6 @@ msgstr "Gli appunti richiedono una connessione sicura (HTTPS). Accedi a questa a
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Chiudi"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Chiudi banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Chiudi il pannello laterale"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Altro"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Aprire Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Apri il pannello laterale"
@@ -3189,7 +3189,6 @@ msgstr "クリップボードの使用にはセキュアな接続 (HTTPS) が必
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "閉じる"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "バナーを閉じる"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "サイドパネルを閉じる"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "もっと"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Outlookで開く"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "サイドパネルを開く"
@@ -3189,7 +3189,6 @@ msgstr "클립보드는 보안 연결(HTTPS)이 필요합니다. 복사 기능
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "닫기"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "배너 닫기"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "사이드 패널 닫기"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "더보기"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Outlook 열기"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "사이드 패널 열기"
@@ -3189,7 +3189,6 @@ msgstr ""
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Sluiten"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Banner sluiten"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Zijpaneel sluiten"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Meer"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Open Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Zijpaneel openen"
@@ -3189,7 +3189,6 @@ msgstr "Utklippstavlen krever en sikker tilkobling (HTTPS). Åpne denne appen ov
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Lukk"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Lukk banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Lukk sidepanelet"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mer"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Åpne Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Åpne sidepanelet"
@@ -3189,7 +3189,6 @@ msgstr "Schowek wymaga bezpiecznego połączenia (HTTPS). Otwórz tę aplikację
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Zamknij"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Zamknij baner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Zamknij panel boczny"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Więcej"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Otwórz Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Otwórz panel boczny"
@@ -3184,7 +3184,6 @@ msgstr ""
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr ""
@@ -3195,7 +3194,6 @@ msgid "Close banner"
msgstr ""
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr ""
@@ -9276,7 +9274,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr ""
@@ -10719,7 +10716,6 @@ msgid "Open Outlook"
msgstr ""
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr ""
@@ -3189,7 +3189,6 @@ msgstr "A área de transferência requer uma conexão segura (HTTPS). Acesse est
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Fechar"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Fechar banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr ""
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mais"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Abrir Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr ""
@@ -3189,7 +3189,6 @@ msgstr "A área de transferência requer uma conexão segura (HTTPS). Acesse est
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Fechar"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Fechar banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Fechar painel lateral"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mais"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Abrir Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Abrir painel lateral"
@@ -3189,7 +3189,6 @@ msgstr "Clipboardul necesită o conexiune securizată (HTTPS). Accesați aceast
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Închide"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Închide bannerul"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Închide panoul lateral"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mai mult"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Deschide Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Deschide panoul lateral"
Binary file not shown.
@@ -3189,7 +3189,6 @@ msgstr "Клипборд захтева безбедну везу (HTTPS). Пр
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Затвори"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Затвори банер"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Затвори бочни панел"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Још"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Отвори Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Отвори бочни панел"
@@ -3189,7 +3189,6 @@ msgstr "Urklipp kräver en säker anslutning (HTTPS). Öppna den här appen via
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Stäng"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Stäng banner"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Stäng sidopanelen"
@@ -9283,7 +9281,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Mer"
@@ -10726,7 +10723,6 @@ msgid "Open Outlook"
msgstr "Öppna Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Öppna sidopanelen"
@@ -3189,7 +3189,6 @@ msgstr "Pano güvenli bir bağlantı (HTTPS) gerektirir. Kopyalamayı etkinleşt
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Kapat"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Afişi kapat"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Yan paneli kapat"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Daha Fazla"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Outlook'u Aç"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Yan paneli aç"
@@ -3189,7 +3189,6 @@ msgstr "Буфер обміну вимагає захищеного з'єдна
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Закрити"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Закрити банер"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Закрити бічну панель"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Більше"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Відкрити Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Відкрити бічну панель"
@@ -3189,7 +3189,6 @@ msgstr "Bộ nhớ tạm yêu cầu kết nối bảo mật (HTTPS). Vui lòng t
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "Đóng"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "Đóng biểu ngữ"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "Đóng ngăn bên"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "Thêm nữa"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "Mở Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "Mở ngăn bên"
@@ -3189,7 +3189,6 @@ msgstr "剪贴板功能需要安全连接 (HTTPS)。请通过 HTTPS 访问此应
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "关闭"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "关闭横幅"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "关闭侧边栏"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "更多"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "打开Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "打开侧边栏"
@@ -3189,7 +3189,6 @@ msgstr "剪貼簿需要安全連線 (HTTPS)。請透過 HTTPS 存取此應用程
#. js-lingui-id: yz7wBu
#: src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close"
msgstr "關閉"
@@ -3200,7 +3199,6 @@ msgid "Close banner"
msgstr "關閉橫幅"
#. js-lingui-id: saip/v
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Close side panel"
msgstr "關閉側邊面板"
@@ -9281,7 +9279,6 @@ msgstr ""
#. js-lingui-id: 2FYpfJ
#: src/pages/settings/ai/SettingsAI.tsx
#: src/modules/ui/layout/tab-list/components/TabMoreButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "More"
msgstr "更多"
@@ -10724,7 +10721,6 @@ msgid "Open Outlook"
msgstr "打開Outlook"
#. js-lingui-id: bY2Xkv
#: src/modules/ui/layout/page-header/components/PageHeaderToggleSidePanelButton.tsx
#: src/modules/side-panel/components/SidePanelToggleButton.tsx
msgid "Open side panel"
msgstr "開啟側邊面板"
@@ -11,13 +11,14 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import {
IconChevronDown,
IconSquareCheck,
IconSquareX,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const DROPDOWN_ID = 'command-menu-edit-record-selection-dropdown';
@@ -55,6 +56,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
isRecordPage = false,
}: CommandMenuItemEditRecordSelectionDropdownProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { closeDropdown } = useCloseDropdown();
const mainContextStoreHasSelectedRecords = useAtomStateValue(
@@ -92,9 +94,17 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
disabled={isRecordPage}
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<TriggerIcon size={16} />
<TriggerIcon
size={16}
color={theme.font.color.primary}
stroke={theme.icon.stroke.sm}
/>
<StyledLabel>{triggerLabel}</StyledLabel>
<IconChevronDown size={16} />
<IconChevronDown
size={16}
color={theme.font.color.primary}
stroke={theme.icon.stroke.sm}
/>
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
@@ -87,7 +87,7 @@ jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState', () => ({
}));
jest.mock('@/ui/layout/contexts/LayoutRenderingContext', () => ({
useLayoutRenderingContext: () => ({
useOptionalLayoutRenderingContext: () => ({
targetRecordIdentifier: mockTargetRecordIdentifier,
}),
}));
@@ -14,7 +14,7 @@ import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useOptionalLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -123,12 +123,12 @@ export const useFrontComponentExecutionContext = ({
}
};
const { targetRecordIdentifier } = useLayoutRenderingContext();
const layoutRenderingContext = useOptionalLayoutRenderingContext();
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
recordId: targetRecordIdentifier?.id ?? null,
recordId: layoutRenderingContext?.targetRecordIdentifier?.id ?? null,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
@@ -1,5 +1,5 @@
import { styled } from '@linaria/react';
import { useState, type ReactNode } from 'react';
import { useDeferredValue, useState, type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
@@ -25,12 +25,15 @@ export const NavigationMenuItemFolderLayout = ({
}: NavigationMenuItemFolderLayoutProps) => {
const [skipInitialExpandAnimation] = useState(() => isOpen);
const deferredIsOpen = useDeferredValue(isOpen);
const isExpandedForAnimation = isOpen ? deferredIsOpen : false;
return (
<NavigationDrawerItemsCollapsableContainer isGroup={isGroup}>
{header}
<StyledFolderExpandableWrapper>
<AnimatedExpandableContainer
isExpanded={isOpen}
isExpanded={isExpandedForAnimation}
dimension="height"
mode="fit-content"
containAnimation
@@ -1,15 +1,48 @@
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { widgetCreationTargetTabIdComponentState } from '@/page-layout/states/widgetCreationTargetTabIdComponentState';
import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
export const useNavigateToMoreWidgets = () => {
const { tabId } = usePageLayoutContentContext();
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const pageLayoutEditingWidgetIdState = useAtomComponentStateCallbackState(
pageLayoutEditingWidgetIdComponentState,
);
const widgetCreationTargetTabIdState = useAtomComponentStateCallbackState(
widgetCreationTargetTabIdComponentState,
);
const widgetInsertionContextState = useAtomComponentStateCallbackState(
widgetInsertionContextComponentState,
);
const store = useStore();
const navigateToMoreWidgets = useCallback(() => {
store.set(pageLayoutEditingWidgetIdState, null);
store.set(widgetInsertionContextState, null);
store.set(widgetCreationTargetTabIdState, tabId);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect,
});
}, [navigatePageLayoutSidePanel]);
}, [
navigatePageLayoutSidePanel,
pageLayoutEditingWidgetIdState,
store,
tabId,
widgetCreationTargetTabIdState,
widgetInsertionContextState,
]);
return { navigateToMoreWidgets };
};
@@ -7,10 +7,12 @@ import { type PageLayoutAddTabStrategy } from '@/page-layout/types/PageLayoutAdd
import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab';
import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { SidePanelPages } from 'twenty-shared/types';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
@@ -40,8 +42,17 @@ export const usePageLayoutAddTabStrategy = ({
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const { isInSidePanel } = useLayoutRenderingContext();
const navigate = useNavigate();
const onCreate = useCallback(() => {
const newTabId = createPageLayoutTab(t`Untitled`);
if (!isInSidePanel) {
navigate(`#${newTabId}`);
}
setPageLayoutTabSettingsOpenTabId(newTabId);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutTabSettings,
@@ -49,6 +60,8 @@ export const usePageLayoutAddTabStrategy = ({
});
}, [
createPageLayoutTab,
isInSidePanel,
navigate,
setPageLayoutTabSettingsOpenTabId,
navigatePageLayoutSidePanel,
]);
@@ -15,9 +15,7 @@ import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layo
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { evictViewMetadataForViewIds } from '@/page-layout/utils/evictViewMetadataForViewIds';
import { toDraftPageLayout } from '@/page-layout/utils/toDraftPageLayout';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
@@ -76,55 +74,47 @@ export const useRefreshPageLayoutAfterReset = (
pageLayoutId,
);
const refreshPageLayoutAfterReset = useCallback(
async (collectAffectedViewIds: (layout: PageLayout) => Set<string>) => {
const { data } = await client.query({
query: FindOnePageLayoutDocument,
variables: { id: pageLayoutId },
fetchPolicy: 'network-only',
});
const refreshPageLayoutAfterReset = useCallback(async () => {
const { data } = await client.query({
query: FindOnePageLayoutDocument,
variables: { id: pageLayoutId },
fetchPolicy: 'network-only',
});
let affectedViewIds = new Set<string>();
if (isDefined(data?.getPageLayout)) {
const freshLayout = transformPageLayout(data.getPageLayout);
if (isDefined(data?.getPageLayout)) {
const freshLayout = transformPageLayout(data.getPageLayout);
store.set(pageLayoutPersistedState, freshLayout);
store.set(pageLayoutDraftState, toDraftPageLayout(freshLayout));
store.set(
pageLayoutCurrentLayoutsState,
convertPageLayoutToTabLayouts(freshLayout),
);
}
affectedViewIds = collectAffectedViewIds(freshLayout);
store.set(fieldsWidgetGroupsDraftState, {});
store.set(fieldsWidgetUngroupedFieldsDraftState, {});
store.set(fieldsWidgetEditorModeDraftState, {});
store.set(hasInitializedFieldsWidgetGroupsDraftState, {});
store.set(pageLayoutPersistedState, freshLayout);
store.set(pageLayoutDraftState, toDraftPageLayout(freshLayout));
store.set(
pageLayoutCurrentLayoutsState,
convertPageLayoutToTabLayouts(freshLayout),
);
}
store.set(fieldsWidgetGroupsDraftState, {});
store.set(fieldsWidgetUngroupedFieldsDraftState, {});
store.set(fieldsWidgetEditorModeDraftState, {});
store.set(hasInitializedFieldsWidgetGroupsDraftState, {});
setIsPageLayoutInEditMode(false);
exitLayoutCustomizationMode();
evictViewMetadataForViewIds(store, affectedViewIds);
invalidateMetadataStore();
},
[
client,
pageLayoutId,
store,
pageLayoutPersistedState,
pageLayoutDraftState,
pageLayoutCurrentLayoutsState,
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
hasInitializedFieldsWidgetGroupsDraftState,
setIsPageLayoutInEditMode,
exitLayoutCustomizationMode,
invalidateMetadataStore,
],
);
setIsPageLayoutInEditMode(false);
exitLayoutCustomizationMode();
invalidateMetadataStore();
}, [
client,
pageLayoutId,
store,
pageLayoutPersistedState,
pageLayoutDraftState,
pageLayoutCurrentLayoutsState,
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
hasInitializedFieldsWidgetGroupsDraftState,
setIsPageLayoutInEditMode,
exitLayoutCustomizationMode,
invalidateMetadataStore,
]);
return { refreshPageLayoutAfterReset };
};
@@ -7,7 +7,6 @@ import { CrudOperationType } from 'twenty-shared/types';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { RESET_PAGE_LAYOUT_TAB_TO_DEFAULT } from '@/page-layout/graphql/mutations/resetPageLayoutTabToDefault';
import { useRefreshPageLayoutAfterReset } from '@/page-layout/hooks/useRefreshPageLayoutAfterReset';
import { collectViewIdsFromWidgets } from '@/page-layout/utils/collectViewIdsFromWidgets';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useResetPageLayoutTabToDefault = (
@@ -24,11 +23,7 @@ export const useResetPageLayoutTabToDefault = (
async (tabId: string) => {
try {
await resetMutation({ variables: { id: tabId } });
await refreshPageLayoutAfterReset((layout) =>
collectViewIdsFromWidgets(
layout.tabs.find((tab) => tab.id === tabId)?.widgets ?? [],
),
);
await refreshPageLayoutAfterReset();
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
@@ -10,9 +10,6 @@ import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidate
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { RESET_PAGE_LAYOUT_TO_DEFAULT } from '@/page-layout/graphql/mutations/resetPageLayoutToDefault';
import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/pageLayoutIsInitializedComponentState';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { collectViewIdsFromWidgets } from '@/page-layout/utils/collectViewIdsFromWidgets';
import { evictViewMetadataForViewIds } from '@/page-layout/utils/evictViewMetadataForViewIds';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useResetPageLayoutToDefault = () => {
@@ -23,22 +20,10 @@ export const useResetPageLayoutToDefault = () => {
const store = useStore();
const resetPageLayoutToDefault = useCallback(
async ({
pageLayoutId,
pageLayout,
}: {
pageLayoutId: string;
pageLayout: PageLayout;
}) => {
const preResetViewIds = collectViewIdsFromWidgets(
pageLayout.tabs.flatMap((tab) => tab.widgets),
);
async ({ pageLayoutId }: { pageLayoutId: string }) => {
try {
await resetMutation({ variables: { id: pageLayoutId } });
evictViewMetadataForViewIds(store, preResetViewIds);
if (isDefined(pageLayoutId)) {
store.set(
pageLayoutIsInitializedComponentState.atomFamily({
@@ -7,7 +7,6 @@ import { ResetPageLayoutWidgetToDefaultDocument } from '~/generated-metadata/gra
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useRefreshPageLayoutAfterReset } from '@/page-layout/hooks/useRefreshPageLayoutAfterReset';
import { collectViewIdsFromWidgets } from '@/page-layout/utils/collectViewIdsFromWidgets';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useResetPageLayoutWidgetToDefault = (
@@ -24,13 +23,7 @@ export const useResetPageLayoutWidgetToDefault = (
async (widgetId: string) => {
try {
await resetMutation({ variables: { id: widgetId } });
await refreshPageLayoutAfterReset((layout) =>
collectViewIdsFromWidgets(
layout.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.id === widgetId),
),
);
await refreshPageLayoutAfterReset();
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
@@ -0,0 +1,11 @@
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const widgetCreationTargetTabIdComponentState = createAtomComponentState<
string | null
>({
key: 'widgetCreationTargetTabIdComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,59 @@
import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget';
import {
FieldDisplayMode,
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFieldWidget', () => {
it('should return a FIELD widget with CARD display mode by default', () => {
const widget = createDefaultFieldWidget({
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Company Name',
fieldMetadataId: 'field-1',
objectMetadataId: 'object-1',
positionIndex: 0,
});
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Company Name',
isActive: true,
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: 'field-1',
fieldDisplayMode: FieldDisplayMode.CARD,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
objectMetadataId: 'object-1',
deletedAt: null,
});
});
it('should use the provided fieldDisplayMode', () => {
const widget = createDefaultFieldWidget({
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Description',
fieldMetadataId: 'field-2',
fieldDisplayMode: FieldDisplayMode.EDITOR,
objectMetadataId: 'object-1',
positionIndex: 1,
});
expect(widget.configuration).toMatchObject({
fieldDisplayMode: FieldDisplayMode.EDITOR,
});
expect(widget.position).toMatchObject({ index: 1 });
});
});
@@ -0,0 +1,46 @@
import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget';
import {
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFieldsWidget', () => {
it('should return a FIELDS widget with correct properties', () => {
const widget = createDefaultFieldsWidget({
id: 'widget-1',
pageLayoutTabId: 'tab-1',
viewId: 'view-1',
objectMetadataId: 'object-1',
positionIndex: 3,
});
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Fields',
isActive: true,
type: WidgetType.FIELDS,
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: 'view-1',
},
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 3,
},
objectMetadataId: 'object-1',
deletedAt: null,
});
});
});
@@ -0,0 +1,52 @@
import { createDefaultFrontComponentWidget } from '@/page-layout/utils/createDefaultFrontComponentWidget';
import {
type GridPosition,
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFrontComponentWidget', () => {
it('should return a FRONT_COMPONENT widget with grid position mapped to position', () => {
const gridPosition: GridPosition = {
__typename: 'GridPosition',
row: 2,
column: 3,
rowSpan: 4,
columnSpan: 6,
};
const widget = createDefaultFrontComponentWidget(
'widget-1',
'tab-1',
'My Component',
'front-comp-1',
gridPosition,
);
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'My Component',
isActive: true,
type: WidgetType.FRONT_COMPONENT,
configuration: {
__typename: 'FrontComponentConfiguration',
configurationType: WidgetConfigurationType.FRONT_COMPONENT,
frontComponentId: 'front-comp-1',
},
gridPosition,
position: {
__typename: 'PageLayoutWidgetGridPosition',
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 2,
column: 3,
rowSpan: 4,
columnSpan: 6,
},
objectMetadataId: null,
deletedAt: null,
});
});
});
@@ -0,0 +1,51 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab';
const makeTab = (overrides: Partial<PageLayoutTab> = {}): PageLayoutTab =>
({
id: 'tab-1',
applicationId: 'app-1',
title: 'Tab',
isActive: true,
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
...overrides,
}) as unknown as PageLayoutTab;
describe('isReactivatableTab', () => {
it('should return true when tab is inactive and applicationId matches', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-1' })).toBe(
true,
);
});
it('should return false when tab is active', () => {
const tab = makeTab({ isActive: true, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-1' })).toBe(
false,
);
});
it('should return false when applicationId does not match', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-2' })).toBe(
false,
);
});
it('should return false when objectApplicationId is undefined', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: undefined })).toBe(
false,
);
});
});
@@ -1,20 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
export const collectViewIdsFromWidgets = (
widgets: PageLayoutWidget[],
): Set<string> => {
const viewIds = new Set<string>();
for (const widget of widgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (isDefined(viewId)) {
viewIds.add(viewId);
}
}
return viewIds;
};
@@ -1,31 +0,0 @@
import type { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import {
type MetadataEntityKey,
metadataStoreState,
} from '@/metadata-store/states/metadataStoreState';
const VIEW_RELATED_METADATA_KEYS: MetadataEntityKey[] = [
'viewFields',
'viewFieldGroups',
];
export const evictViewMetadataForViewIds = (
store: ReturnType<typeof useStore>,
viewIds: Set<string>,
) => {
if (viewIds.size === 0) {
return;
}
for (const key of VIEW_RELATED_METADATA_KEYS) {
store.set(metadataStoreState.atomFamily(key), (prev) => ({
...prev,
current: (prev.current as { viewId?: string }[]).filter(
(item) => !isDefined(item.viewId) || !viewIds.has(item.viewId),
),
currentCollectionHash: undefined,
}));
}
};
@@ -36,6 +36,7 @@ const StyledHeader = styled.div`
`;
const StyledMenuItemList = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
margin-top: ${themeCssVariables.spacing[2]};
@@ -77,7 +77,6 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
await resetPageLayoutToDefault({
pageLayoutId: pageLayout.id,
pageLayout,
});
};
@@ -1,53 +1,165 @@
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
AppTooltip,
IconLayoutSidebarRightExpand,
IconX,
TooltipDelay,
TooltipPosition,
} from 'twenty-ui/display';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
import { AnimatedButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedIconCrossfade, useIsMobile } from 'twenty-ui/utilities';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonWrapper = styled.div<{ alignToTop: boolean }>`
align-items: ${({ alignToTop }) => (alignToTop ? 'center' : 'initial')};
display: ${({ alignToTop }) => (alignToTop ? 'flex' : 'block')};
height: ${({ alignToTop }) =>
alignToTop ? `${SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE}px` : 'auto'};
position: ${({ alignToTop }) => (alignToTop ? 'fixed' : 'static')};
right: ${({ alignToTop }) =>
alignToTop ? themeCssVariables.spacing[3] : 'auto'};
top: ${({ alignToTop }) => (alignToTop ? '0' : 'auto')};
z-index: ${RootStackingContextZIndices.SidePanelButton};
`;
const StyledTooltipWrapper = styled.div`
font-size: ${themeCssVariables.font.size.md};
`;
const xPaths = {
topLeft: `M12 12 L6 6`,
topRight: `M12 12 L18 6`,
bottomLeft: `M12 12 L6 18`,
bottomRight: `M12 12 L18 18`,
};
const AnimatedIcon = ({
isSidePanelOpened,
}: {
isSidePanelOpened: boolean;
}) => {
const { theme } = useContext(ThemeContext);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={theme.icon.size.sm}
height={theme.icon.size.sm}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={theme.icon.stroke.md}
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block' }}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<motion.circle
cx={12}
cy={12}
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
{Object.values(xPaths).map((path, index) => (
<motion.path
key={index}
d={path}
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: isSidePanelOpened ? 1 : 0,
opacity: isSidePanelOpened ? 1 : 0,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
delay: isSidePanelOpened ? 0.1 : 0,
}}
/>
))}
<motion.circle
cx="12"
cy="5"
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
<motion.circle
cx="12"
cy="19"
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
</svg>
);
};
export const SidePanelToggleButton = () => {
const { toggleSidePanelMenu } = useSidePanelMenu();
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isMobile = useIsMobile();
const alignWithSidePanelTopBar =
isMobile && isLayoutCustomizationModeEnabled && isSidePanelOpened;
const ariaLabel = isSidePanelOpened
? t`Close side panel`
: t`Open side panel`;
const { theme } = useContext(ThemeContext);
return (
<div id="toggle-side-panel-button">
<AnimatedButton
animatedSvg={
<AnimatedIconCrossfade
isActive={isSidePanelOpened}
ActiveIcon={IconX}
InactiveIcon={IconLayoutSidebarRightExpand}
/>
}
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
dataTestId="page-header-side-panel-button"
size={isMobile ? 'medium' : 'small'}
variant="secondary"
accent="default"
title={isSidePanelOpened ? t`Close` : t`More`}
ariaLabel={ariaLabel}
onClick={toggleSidePanelMenu}
/>
<StyledButtonWrapper alignToTop={alignWithSidePanelTopBar}>
<div id="toggle-side-panel-button">
<AnimatedButton
animatedSvg={<AnimatedIcon isSidePanelOpened={isSidePanelOpened} />}
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
dataTestId="page-header-side-panel-button"
size={isMobile ? 'medium' : 'small'}
variant="secondary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
ariaLabel={ariaLabel}
onClick={toggleSidePanelMenu}
animate={{
rotate: isSidePanelOpened ? 90 : 0,
}}
transition={{
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}}
/>
</div>
<StyledTooltipWrapper>
<AppTooltip
anchorSelect="#toggle-side-panel-button"
@@ -58,6 +170,6 @@ export const SidePanelToggleButton = () => {
noArrow
/>
</StyledTooltipWrapper>
</div>
</StyledButtonWrapper>
);
};
@@ -4,12 +4,12 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { widgetCreationTargetTabIdComponentState } from '@/page-layout/states/widgetCreationTargetTabIdComponentState';
import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget';
import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition';
import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
@@ -20,8 +20,8 @@ import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { getFrontComponentWidgetTypeSelectItemId } from '@/side-panel/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId';
import { resolveWidgetTypeSelectTargetTabId } from '@/side-panel/pages/page-layout/utils/resolveWidgetTypeSelectTargetTabId';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -41,11 +41,8 @@ import {
} from '~/generated-metadata/graphql';
export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const {
pageLayoutId,
recordId,
objectNameSingular: targetObjectNameSingular,
} = usePageLayoutIdFromContextStore();
const { pageLayoutId, objectNameSingular: targetObjectNameSingular } =
usePageLayoutIdFromContextStore();
const { closeSidePanelMenu } = useSidePanelMenu();
@@ -72,15 +69,9 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
pageLayoutId,
);
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
const widgetCreationTargetTabId = useAtomComponentStateValue(
widgetCreationTargetTabIdComponentState,
pageLayoutId,
layoutType: pageLayoutDraft.type,
targetRecordIdentifier: { id: recordId, targetObjectNameSingular: '' },
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
tabListInstanceId,
);
const store = useStore();
@@ -96,13 +87,11 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
targetObjectNameSingular,
);
const editingWidgetTab = isDefined(pageLayoutEditingWidgetId)
? pageLayoutDraft.tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
)
: undefined;
const tabId = editingWidgetTab?.id ?? activeTabId;
const tabId = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId,
tabs: pageLayoutDraft.tabs,
widgetCreationTargetTabId,
});
const isReplaceMode =
isDefined(pageLayoutEditingWidgetId) && !isDefined(widgetInsertionContext);
@@ -126,11 +115,7 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
}, [existingWidget]);
const removeExistingWidgetIfReplacing = useCallback(() => {
if (
!isReplaceMode ||
!isDefined(pageLayoutEditingWidgetId) ||
!isDefined(tabId)
) {
if (!isReplaceMode || !isDefined(pageLayoutEditingWidgetId)) {
return;
}
@@ -160,10 +145,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
);
const handleCreateFieldsWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
const viewId = uuidv4();
@@ -210,10 +191,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
]);
const handleCreateFieldWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -287,10 +264,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const handleCreateFrontComponentWidget = useCallback(
(frontComponent: FrontComponent) => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -14,6 +14,7 @@ import { RegularTabSettingsContent } from '@/side-panel/pages/page-layout/compon
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import {
PageLayoutTabLayoutMode,
@@ -31,6 +32,8 @@ export const SidePanelPageLayoutTabSettingsContent = ({
}: SidePanelPageLayoutTabSettingsContentProps) => {
const { closeSidePanelMenu } = useSidePanelMenu();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const pageLayoutDraft = useAtomComponentStateValue(
@@ -129,7 +132,10 @@ export const SidePanelPageLayoutTabSettingsContent = ({
onMoveLeft={() => moveLeft(tab.id)}
onMoveRight={() => moveRight(tab.id)}
onSetAsPinned={() => setAsPinnedTab(tab.id)}
onDuplicate={() => duplicateTab(tab.id)}
onDuplicate={() => {
const newTabId = duplicateTab(tab.id);
navigate(`#${newTabId}`);
}}
onResetToDefault={handleResetToDefault}
onDelete={handleDelete}
/>
@@ -0,0 +1,73 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { resolveWidgetTypeSelectTargetTabId } from '@/side-panel/pages/page-layout/utils/resolveWidgetTypeSelectTargetTabId';
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
const makeTab = (id: string, widgetIds: string[] = []): PageLayoutTab =>
({
id,
applicationId: '',
title: id,
isActive: true,
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: '',
widgets: widgetIds.map((wId) => ({ id: wId })),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
}) as unknown as PageLayoutTab;
describe('resolveWidgetTypeSelectTargetTabId', () => {
it('should return the tab containing the editing widget', () => {
const tabs = [
makeTab('tab-1', ['widget-a']),
makeTab('tab-2', ['widget-b']),
];
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'widget-b',
tabs,
widgetCreationTargetTabId: null,
});
expect(result).toBe('tab-2');
});
it('should throw when the editing widget is not found in any tab', () => {
const tabs = [makeTab('tab-1', ['widget-a'])];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'non-existent',
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow('Cannot find tab containing editing widget non-existent');
});
it('should return widgetCreationTargetTabId when no editing widget is set', () => {
const tabs = [makeTab('tab-1', ['widget-a'])];
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: 'tab-1',
});
expect(result).toBe('tab-1');
});
it('should throw when both pageLayoutEditingWidgetId and widgetCreationTargetTabId are null', () => {
const tabs = [makeTab('tab-1')];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
});
});
@@ -0,0 +1,34 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { isDefined } from 'twenty-shared/utils';
export const resolveWidgetTypeSelectTargetTabId = ({
pageLayoutEditingWidgetId,
tabs,
widgetCreationTargetTabId,
}: {
pageLayoutEditingWidgetId: string | null;
tabs: PageLayoutTab[];
widgetCreationTargetTabId: string | null;
}): string => {
if (isDefined(pageLayoutEditingWidgetId)) {
const editingWidgetTab = tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
);
if (!isDefined(editingWidgetTab)) {
throw new Error(
`Cannot find tab containing editing widget ${pageLayoutEditingWidgetId}`,
);
}
return editingWidgetTab.id;
}
if (!isDefined(widgetCreationTargetTabId)) {
throw new Error(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
}
return widgetCreationTargetTabId;
};
@@ -9,9 +9,9 @@ import {
useContext,
useMemo,
} from 'react';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import {
HorizontalSeparator,
IconAlertTriangle,
IconInfoCircle,
IconSquareRoundedCheck,
@@ -19,6 +19,7 @@ import {
} from 'twenty-ui/display';
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
import { LightButton, LightIconButton } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import {
MOBILE_VIEWPORT,
ThemeContext,
@@ -39,9 +40,9 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
duration?: number;
icon?: ReactNode;
message: string;
actionText?: string;
actionOnClick?: () => void;
actionTo?: string;
buttonLabel?: string;
buttonOnClick?: () => void;
buttonTo?: string;
detailedMessage?: string;
onCancel?: () => void;
onClose?: () => void;
@@ -56,9 +57,9 @@ const StyledContainer = styled.div`
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
box-sizing: border-box;
cursor: pointer;
margin-top: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}
${themeCssVariables.spacing[1]};
position: relative;
width: 296px;
@@ -70,12 +71,15 @@ const StyledContainer = styled.div`
const StyledProgressBarContainer = styled.div`
bottom: 0;
height: auto;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
& > [role='progressbar'] {
height: 100%;
}
`;
const StyledHeader = styled.div`
@@ -112,24 +116,15 @@ const StyledDescription = styled.div`
width: 200px;
`;
const StyledLinkContainer = styled.div`
> a {
color: ${themeCssVariables.font.color.tertiary};
display: block;
font-size: ${themeCssVariables.font.size.sm};
max-width: 200px;
overflow: hidden;
padding-left: ${themeCssVariables.spacing[6]};
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: ${themeCssVariables.font.color.secondary};
}
}
const StyledBottomActionContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledActionButton = styled.div`
padding-left: ${themeCssVariables.spacing[6]};
const StyledBottomAction = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[1]};
`;
const defaultAriaLabelByVariant: Record<
@@ -151,9 +146,9 @@ export const SnackBar = ({
id,
message,
detailedMessage,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
onCancel,
onClose,
role = 'status',
@@ -205,15 +200,11 @@ export const SnackBar = ({
}, [iconComponent, variant, i18n, theme.icon.size.md, theme.snackBar]);
const handleMouseEnter = () => {
if (progressAnimation?.state === 'running') {
progressAnimation.pause();
}
progressAnimation?.pause();
};
const handleMouseLeave = () => {
if (progressAnimation?.state === 'paused') {
progressAnimation.play();
}
progressAnimation?.play();
};
const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message);
@@ -251,16 +242,21 @@ export const SnackBar = ({
{isDefined(sanitizedDetailedMessage) && (
<StyledDescription>{sanitizedDetailedMessage}</StyledDescription>
)}
{actionText && actionTo && (
<StyledLinkContainer>
<Link to={actionTo}>{actionText}</Link>
</StyledLinkContainer>
)}
{actionText && actionOnClick && !actionTo && (
<StyledActionButton>
<LightButton title={actionText} onClick={actionOnClick} />
</StyledActionButton>
)}
{isDefined(buttonLabel) &&
(isDefined(buttonOnClick) || isDefined(buttonTo)) && (
<StyledBottomActionContainer>
<HorizontalSeparator noMargin />
<StyledBottomAction>
{isDefined(buttonTo) ? (
<UndecoratedLink to={buttonTo}>
<LightButton title={buttonLabel} />
</UndecoratedLink>
) : (
<LightButton title={buttonLabel} onClick={buttonOnClick} />
)}
</StyledBottomAction>
</StyledBottomActionContainer>
)}
</StyledContainer>
);
};
@@ -58,9 +58,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
}) => (
<motion.div
key={id}
@@ -78,9 +78,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
}}
onClose={() => handleSnackBarClose(id)}
/>
@@ -41,6 +41,33 @@ export const Default: Story = {
},
};
export const WithBottomButton: Story = {
args: {
variant: SnackBarVariant.Error,
message: 'An error has occurred',
detailedMessage: 'Error during useFindManyRecord...',
buttonLabel: 'Open Record',
buttonOnClick: fn(),
},
decorators: [ComponentDecorator],
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const SuccessWithButton: Story = {
args: {
variant: SnackBarVariant.Success,
message: 'Record created successfully',
buttonLabel: 'View Record',
buttonOnClick: fn(),
},
decorators: [ComponentDecorator],
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const Catalog: CatalogStory<Story, typeof SnackBar> = {
args: {
onCancel: fn(),
@@ -63,3 +90,22 @@ export const Catalog: CatalogStory<Story, typeof SnackBar> = {
},
},
};
export const CatalogWithButton: CatalogStory<Story, typeof SnackBar> = {
args: {
buttonLabel: 'Open Record',
buttonOnClick: fn(),
},
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'variants',
values: Object.values(SnackBarVariant),
props: (variant: SnackBarVariant) => ({ variant }),
},
],
},
},
};
@@ -7,7 +7,7 @@ import { type SnackBarOptions } from '@/ui/feedback/snack-bar-manager/states/sna
export const buildErrorAction = (
apolloError?: ErrorLike,
): Pick<SnackBarOptions, 'actionText' | 'actionTo'> | null => {
): Pick<SnackBarOptions, 'buttonLabel' | 'buttonTo'> | null => {
if (!apolloError) {
return null;
}
@@ -16,8 +16,8 @@ export const buildErrorAction = (
if (isDefined(conflictingRecord)) {
return {
actionText: t`View existing record`,
actionTo: getAppPath(AppPath.RecordShowPage, {
buttonLabel: t`View existing record`,
buttonTo: getAppPath(AppPath.RecordShowPage, {
objectNameSingular: conflictingRecord.conflictingObjectNameSingular,
objectRecordId: conflictingRecord.conflictingRecordId,
}),
@@ -1,3 +1,5 @@
import React, { useContext } from 'react';
import { type PageLayoutType } from '~/generated-metadata/graphql';
import { createRequiredContext } from '~/utils/createRequiredContext';
import { type TargetRecordIdentifier } from './TargetRecordIdentifier';
@@ -13,5 +15,26 @@ export type LayoutRenderingContextType = {
isInSidePanel: boolean;
};
export const [LayoutRenderingProvider, useLayoutRenderingContext] =
createRequiredContext<LayoutRenderingContextType>('LayoutRenderingContext');
const LayoutRenderingContext = React.createContext<
LayoutRenderingContextType | undefined
>(undefined);
export const LayoutRenderingProvider = LayoutRenderingContext.Provider;
export const useLayoutRenderingContext = (): LayoutRenderingContextType => {
const context = useContext(LayoutRenderingContext);
if (context === undefined) {
throw new Error(
'LayoutRenderingContext not found. Please wrap your component tree with <LayoutRenderingProvider>.',
);
}
return context;
};
export const useOptionalLayoutRenderingContext = ():
| LayoutRenderingContextType
| undefined => {
return useContext(LayoutRenderingContext);
};
@@ -1,178 +0,0 @@
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
import { AnimatedButton } from 'twenty-ui/input';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonWrapper = styled.div<{ alignToTop: boolean }>`
align-items: ${({ alignToTop }) => (alignToTop ? 'center' : 'initial')};
display: ${({ alignToTop }) => (alignToTop ? 'flex' : 'block')};
height: ${({ alignToTop }) =>
alignToTop ? `${SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE}px` : 'auto'};
position: ${({ alignToTop }) => (alignToTop ? 'fixed' : 'static')};
right: ${({ alignToTop }) =>
alignToTop ? themeCssVariables.spacing[3] : 'auto'};
top: ${({ alignToTop }) => (alignToTop ? '0' : 'auto')};
z-index: ${RootStackingContextZIndices.SidePanelButton};
`;
const StyledTooltipWrapper = styled.div`
font-size: ${themeCssVariables.font.size.md};
`;
const xPaths = {
topLeft: `M12 12 L6 6`,
topRight: `M12 12 L18 6`,
bottomLeft: `M12 12 L6 18`,
bottomRight: `M12 12 L18 18`,
};
const AnimatedIcon = ({
isSidePanelOpened,
}: {
isSidePanelOpened: boolean;
}) => {
const { theme } = useContext(ThemeContext);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={theme.icon.size.sm}
height={theme.icon.size.sm}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={theme.icon.stroke.md}
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block' }}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
{/* Center dot */}
<motion.circle
cx={12}
cy={12}
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
{/* X lines expanding from center */}
{Object.values(xPaths).map((path, index) => (
<motion.path
key={index}
d={path}
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: isSidePanelOpened ? 1 : 0,
opacity: isSidePanelOpened ? 1 : 0,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
delay: isSidePanelOpened ? 0.1 : 0,
}}
/>
))}
{/* Top dot */}
<motion.circle
cx="12"
cy="5"
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
{/* Bottom dot */}
<motion.circle
cx="12"
cy="19"
r="1"
initial={{ opacity: 0 }}
animate={{
scale: isSidePanelOpened ? 0 : 1,
opacity: isSidePanelOpened ? 0 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
}}
/>
</svg>
);
};
export const PageHeaderToggleSidePanelButton = () => {
const { toggleSidePanelMenu } = useSidePanelMenu();
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isMobile = useIsMobile();
const alignWithSidePanelTopBar =
isMobile && isLayoutCustomizationModeEnabled && isSidePanelOpened;
const ariaLabel = isSidePanelOpened
? t`Close side panel`
: t`Open side panel`;
const { theme } = useContext(ThemeContext);
return (
<StyledButtonWrapper alignToTop={alignWithSidePanelTopBar}>
<div id="toggle-side-panel-button">
<AnimatedButton
animatedSvg={<AnimatedIcon isSidePanelOpened={isSidePanelOpened} />}
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
dataTestId="page-header-side-panel-button"
size={isMobile ? 'medium' : 'small'}
variant="secondary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
ariaLabel={ariaLabel}
onClick={toggleSidePanelMenu}
animate={{
rotate: isSidePanelOpened ? 90 : 0,
}}
transition={{
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}}
/>
</div>
<StyledTooltipWrapper>
<AppTooltip
anchorSelect="#toggle-side-panel-button"
content={ariaLabel}
delay={TooltipDelay.longDelay}
place={TooltipPosition.Bottom}
offset={5}
noArrow
/>
</StyledTooltipWrapper>
</StyledButtonWrapper>
);
};
@@ -4,7 +4,10 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_EMPTY_WORKSPACE_3_ID,
SEED_EMPTY_WORKSPACE_4_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
SeededEmptyWorkspacesIds,
SeededWorkspacesIds,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
@@ -42,12 +45,21 @@ export class DataSeedWorkspaceCommand extends CommandRunner {
? [SEED_APPLE_WORKSPACE_ID]
: [SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID];
const emptyWorkspaceIds: SeededEmptyWorkspacesIds[] = [
SEED_EMPTY_WORKSPACE_3_ID,
SEED_EMPTY_WORKSPACE_4_ID,
];
try {
for (const workspaceId of workspaceIds) {
await this.devSeederService.seedDev(workspaceId, {
light: options.light,
});
}
for (const workspaceId of emptyWorkspaceIds) {
await this.devSeederService.seedEmptyWorkspace(workspaceId);
}
} catch (error) {
this.logger.error(error);
this.logger.error(error.stack);
@@ -62,6 +62,9 @@ export class RunInstanceCommandsCommand extends CommandRunner {
await this.checkWorkspaceVersionSafety(options);
await this.runLegacyPendingTypeOrmMigrations();
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
for (const {
command,
name,
@@ -79,9 +82,6 @@ export class RunInstanceCommandsCommand extends CommandRunner {
}
if (options.includeSlow) {
const hasWorkspaces =
await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces();
for (const {
command,
name,
@@ -90,7 +90,7 @@ export class RunInstanceCommandsCommand extends CommandRunner {
await this.instanceUpgradeService.runSlowInstanceCommand({
command,
name,
skipDataMigration: !hasWorkspaces,
skipDataMigration: activeOrSuspendedWorkspaceIds.length === 0,
});
if (result.status === 'failed') {
@@ -119,10 +119,10 @@ export class RunInstanceCommandsCommand extends CommandRunner {
return;
}
const activeWorkspaceIds =
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
if (activeWorkspaceIds.length === 0) {
if (activeOrSuspendedWorkspaceIds.length === 0) {
return;
}
@@ -141,7 +141,7 @@ export class RunInstanceCommandsCommand extends CommandRunner {
const allAtPreviousVersion =
await this.upgradeMigrationService.areAllWorkspacesAtCommand({
commandName: lastWorkspaceCommand.name,
workspaceIds: activeWorkspaceIds,
workspaceIds: activeOrSuspendedWorkspaceIds,
});
if (!allAtPreviousVersion) {
@@ -36,6 +36,9 @@ export class AddStandalonePageFastInstanceCommand
await queryRunner.query(
"CREATE TYPE \"core\".\"navigationMenuItem_type_enum\" AS ENUM('VIEW', 'FOLDER', 'LINK', 'OBJECT', 'RECORD', 'PAGE_LAYOUT')",
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" TYPE "core"."navigationMenuItem_type_enum" USING "type"::"text"::"core"."navigationMenuItem_type_enum"',
);
@@ -69,9 +72,15 @@ export class AddStandalonePageFastInstanceCommand
await queryRunner.query(
"CREATE TYPE \"core\".\"navigationMenuItem_type_enum_old\" AS ENUM('FOLDER', 'LINK', 'OBJECT', 'RECORD', 'VIEW')",
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" TYPE "core"."navigationMenuItem_type_enum_old" USING "type"::"text"::"core"."navigationMenuItem_type_enum_old"',
);
await queryRunner.query(
'ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" SET DEFAULT \'VIEW\'',
);
await queryRunner.query('DROP TYPE "core"."navigationMenuItem_type_enum"');
await queryRunner.query(
'ALTER TYPE "core"."navigationMenuItem_type_enum_old" RENAME TO "navigationMenuItem_type_enum"',
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillRecordPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command';
import { UpdateGlobalObjectContextCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000005000-update-global-object-context-command-menu-items.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -17,7 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceMigrationModule,
],
providers: [
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillRecordPageLayoutsCommand,
UpdateGlobalObjectContextCommandMenuItemsCommand,
],
})
@@ -1,833 +0,0 @@
import { Command } from 'nest-commander';
import {
CoreObjectNameSingular,
FeatureFlagKey,
FieldMetadataType,
PageLayoutTabLayoutMode,
ViewType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
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 { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util';
import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util';
import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import {
GRID_POSITIONS,
VERTICAL_LIST_LAYOUT_POSITIONS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
const HOME_TAB_POSITION = 10;
const isActivityTargetField = (
fieldName: string,
objectNameSingular: string,
): boolean =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldName === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldName === 'taskTargets');
const isJunctionRelationField = (field: FlatFieldMetadata): boolean => {
const settings = field.settings as
| { junctionTargetFieldId?: string }
| undefined;
return (
isDefined(settings?.junctionTargetFieldId) &&
typeof settings?.junctionTargetFieldId === 'string' &&
settings.junctionTargetFieldId.length > 0
);
};
const isRelationTargetAvailable = (
targetObject: FlatObjectMetadata | undefined,
): boolean => {
if (!isDefined(targetObject)) {
return false;
}
if (targetObject.isRemote) {
return false;
}
if (
targetObject.isSystem &&
targetObject.nameSingular !== CoreObjectNameSingular.WorkspaceMember
) {
return false;
}
return true;
};
@RegisteredWorkspaceCommand('1.23.0', 1780000001000)
@Command({
name: 'upgrade:1-23:backfill-page-layouts-and-fields-widget-view-fields',
description:
'Backfill RECORD_PAGE page layouts, sync FIELDS_WIDGET view fields, create FIELD widgets, and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
})
export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of page layouts and field widgets for workspace ${workspaceId}`,
);
const isAlreadyEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
workspaceId,
);
if (isAlreadyEnabled) {
this.logger.log(
`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create page layouts, sync view fields, create FIELD widgets and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
await this.backfillStandardPageLayoutsAndSyncViews({
workspaceId,
twentyStandardFlatApplication,
});
await this.backfillCustomObjectPageLayouts({
workspaceId,
twentyStandardFlatApplication,
});
await this.backfillFieldWidgets({
workspaceId,
twentyStandardFlatApplication,
});
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED],
workspaceId,
);
this.logger.log(
`Successfully backfilled page layouts and field widgets for workspace ${workspaceId}`,
);
}
private async backfillStandardPageLayoutsAndSyncViews({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const recordPageLayoutUniversalIdentifiers = new Set<string>();
const recordPageLayoutsFromStandard = Object.values(
standardAllFlatEntityMaps.flatPageLayoutMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((pageLayout) => {
if (pageLayout.type !== PageLayoutType.RECORD_PAGE) {
return false;
}
recordPageLayoutUniversalIdentifiers.add(
pageLayout.universalIdentifier,
);
return true;
});
const tabUniversalIdentifiers = new Set<string>();
const tabsFromStandard = Object.values(
standardAllFlatEntityMaps.flatPageLayoutTabMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((tab) => {
if (
!recordPageLayoutUniversalIdentifiers.has(
tab.pageLayoutUniversalIdentifier,
)
) {
return false;
}
tabUniversalIdentifiers.add(tab.universalIdentifier);
return true;
});
const widgetsFromStandard = Object.values(
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((widget) =>
tabUniversalIdentifiers.has(widget.pageLayoutTabUniversalIdentifier),
);
const {
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatPageLayoutMaps: existingFlatPageLayoutMaps,
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
flatViewMaps: existingFlatViewMaps,
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
'flatViewMaps',
'flatViewFieldMaps',
'flatViewFieldGroupMaps',
]);
// Filter out page layouts, tabs, and widgets that already exist in the workspace
const pageLayoutsToCreate = recordPageLayoutsFromStandard.filter(
(pageLayout) =>
!isDefined(
existingFlatPageLayoutMaps.byUniversalIdentifier[
pageLayout.universalIdentifier
],
),
);
const pageLayoutTabsToCreate = tabsFromStandard.filter(
(tab) =>
!isDefined(
existingFlatPageLayoutTabMaps.byUniversalIdentifier[
tab.universalIdentifier
],
),
);
const pageLayoutWidgetsToCreate = widgetsFromStandard.filter(
(widget) =>
!isDefined(
existingFlatPageLayoutWidgetMaps.byUniversalIdentifier[
widget.universalIdentifier
],
),
);
// Collect all standard FIELDS_WIDGET view universal identifiers
// This includes both new views to create AND existing views (created by 1-19)
const allStandardFieldsWidgetViewUniversalIds = new Set<string>();
const viewsToCreate = Object.values(
standardAllFlatEntityMaps.flatViewMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((view) => {
if (view.type !== ViewType.FIELDS_WIDGET) {
return false;
}
allStandardFieldsWidgetViewUniversalIds.add(view.universalIdentifier);
if (
isDefined(
existingFlatViewMaps.byUniversalIdentifier[
view.universalIdentifier
],
)
) {
return false;
}
return true;
});
// Delete all existing viewFields for standard FIELDS_WIDGET views and recreate from current standard.
// This ensures positions, visibility, and groups match the current standard definition.
const viewFieldsToDelete = Object.values(
existingFlatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewField) =>
allStandardFieldsWidgetViewUniversalIds.has(
viewField.viewUniversalIdentifier,
),
);
const viewFieldsToCreate = Object.values(
standardAllFlatEntityMaps.flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewField) =>
allStandardFieldsWidgetViewUniversalIds.has(
viewField.viewUniversalIdentifier,
),
)
// Skip viewFields whose referenced fieldMetadata doesn't exist in the workspace yet
// (e.g. fields added to the standard definition but not yet synced to this workspace)
.filter((viewField) =>
isDefined(
existingFlatFieldMetadataMaps.byUniversalIdentifier[
viewField.fieldMetadataUniversalIdentifier
],
),
);
// Same delete-and-recreate approach for viewFieldGroups
const viewFieldGroupsToDelete = Object.values(
existingFlatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewFieldGroup) =>
allStandardFieldsWidgetViewUniversalIds.has(
viewFieldGroup.viewUniversalIdentifier,
),
);
const viewFieldGroupsToCreate = Object.values(
standardAllFlatEntityMaps.flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewFieldGroup) =>
allStandardFieldsWidgetViewUniversalIds.has(
viewFieldGroup.viewUniversalIdentifier,
),
);
this.logger.log(
`Creating ${pageLayoutsToCreate.length} page layouts, ${viewsToCreate.length} views, deleting ${viewFieldsToDelete.length} and creating ${viewFieldsToCreate.length} view fields, deleting ${viewFieldGroupsToDelete.length} and creating ${viewFieldGroupsToCreate.length} view field groups for workspace ${workspaceId}`,
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: pageLayoutsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: pageLayoutTabsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: pageLayoutWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: viewsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: viewFieldsToDelete,
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: viewFieldGroupsToCreate,
flatEntityToDelete: viewFieldGroupsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create standard page layouts and sync view fields:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create standard page layouts and sync view fields for workspace ${workspaceId}`,
);
}
}
private async backfillCustomObjectPageLayouts({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatPageLayoutMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
]);
const existingPageLayouts = Object.values(
flatPageLayoutMaps.byUniversalIdentifier,
).filter(isDefined);
const objectIdsWithRecordPageLayout = new Set(
existingPageLayouts
.filter(
(layout: FlatPageLayout) =>
layout.type === PageLayoutType.RECORD_PAGE &&
isDefined(layout.objectMetadataId),
)
.map((layout: FlatPageLayout) => layout.objectMetadataId),
);
const customObjectsWithoutPageLayout = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(objectMetadata) =>
objectMetadata.isCustom &&
!objectMetadata.isRemote &&
!objectIdsWithRecordPageLayout.has(objectMetadata.id),
);
if (customObjectsWithoutPageLayout.length === 0) {
this.logger.log(
`No custom objects without page layouts found for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Creating page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`,
);
const allCustomPageLayoutsToCreate: FlatPageLayout[] = [];
const allCustomPageLayoutTabsToCreate: FlatPageLayoutTab[] = [];
const allCustomPageLayoutWidgetsToCreate: FlatPageLayoutWidget[] = [];
const allCustomViewsToCreate: (UniversalFlatView & { id: string })[] = [];
const allCustomViewFieldsToCreate: UniversalFlatViewField[] = [];
for (const customObject of customObjectsWithoutPageLayout) {
const flatRecordPageFieldsView = computeFlatRecordPageFieldsViewToCreate({
objectMetadata: customObject,
flatApplication: twentyStandardFlatApplication,
});
const objectFieldMetadatas = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((field) => field.objectMetadataId === customObject.id);
const viewFields = computeFlatViewFieldsToCreate({
objectFlatFieldMetadatas: objectFieldMetadatas,
viewUniversalIdentifier: flatRecordPageFieldsView.universalIdentifier,
flatApplication: twentyStandardFlatApplication,
labelIdentifierFieldMetadataUniversalIdentifier:
customObject.labelIdentifierFieldMetadataUniversalIdentifier,
excludeLabelIdentifier: true,
});
const { pageLayouts, pageLayoutTabs, pageLayoutWidgets } =
computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: customObject,
flatApplication: twentyStandardFlatApplication,
recordPageFieldsView: flatRecordPageFieldsView,
workspaceId,
});
allCustomPageLayoutsToCreate.push(...pageLayouts);
allCustomPageLayoutTabsToCreate.push(...pageLayoutTabs);
allCustomPageLayoutWidgetsToCreate.push(...pageLayoutWidgets);
allCustomViewsToCreate.push(flatRecordPageFieldsView);
allCustomViewFieldsToCreate.push(...viewFields);
}
const customValidateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: allCustomPageLayoutsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: allCustomPageLayoutTabsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: allCustomPageLayoutWidgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: allCustomViewsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: allCustomViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (customValidateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create custom object page layouts:\n${JSON.stringify(customValidateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create custom object page layouts for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully created page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`,
);
}
private async backfillFieldWidgets({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
]);
// Build a set of fieldMetadataIds that already have a FIELD widget
const existingFieldWidgetFieldIds = new Set<string>();
for (const widget of Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)) {
if (
isDefined(widget) &&
widget.configuration?.configurationType ===
WidgetConfigurationType.FIELD &&
isDefined(widget.configuration.fieldMetadataId)
) {
existingFieldWidgetFieldIds.add(widget.configuration.fieldMetadataId);
}
}
// Build object ID -> objectMetadata map
const objectById = new Map<string, FlatObjectMetadata>();
for (const obj of Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)) {
if (isDefined(obj)) {
objectById.set(obj.id, obj);
}
}
// Build object ID -> RECORD_PAGE layout map
const recordPageLayoutByObjectId = new Map<
string,
{ id: string; universalIdentifier: string }
>();
for (const layout of Object.values(
flatPageLayoutMaps.byUniversalIdentifier,
)) {
if (
isDefined(layout) &&
layout.type === PageLayoutType.RECORD_PAGE &&
isDefined(layout.objectMetadataId)
) {
recordPageLayoutByObjectId.set(layout.objectMetadataId, {
id: layout.id,
universalIdentifier: layout.universalIdentifier,
});
}
}
// Build pageLayoutId -> home tab map
const homeTabByPageLayoutId = new Map<
string,
{
id: string;
universalIdentifier: string;
widgetCount: number;
}
>();
for (const tab of Object.values(
flatPageLayoutTabMaps.byUniversalIdentifier,
)) {
if (
isDefined(tab) &&
tab.position === HOME_TAB_POSITION &&
tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST
) {
homeTabByPageLayoutId.set(tab.pageLayoutId, {
id: tab.id,
universalIdentifier: tab.universalIdentifier,
widgetCount: tab.widgetIds?.length ?? 0,
});
}
}
// Group fields by objectMetadataId
const fieldsByObjectId = new Map<string, FlatFieldMetadata[]>();
// Map morphId -> all field IDs sharing that morphId (for dedup)
const fieldIdsByMorphId = new Map<string, string[]>();
for (const field of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(field) || !isDefined(field.objectMetadataId)) {
continue;
}
const list = fieldsByObjectId.get(field.objectMetadataId) ?? [];
list.push(field);
fieldsByObjectId.set(field.objectMetadataId, list);
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
const morphFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
morphFieldIds.push(field.id);
fieldIdsByMorphId.set(field.morphId, morphFieldIds);
}
}
const now = new Date().toISOString();
const widgetsToCreate: FlatPageLayoutWidget[] = [];
const processedMorphIds = new Set<string>();
for (const object of objectById.values()) {
const pageLayout = recordPageLayoutByObjectId.get(object.id);
if (!isDefined(pageLayout)) {
continue;
}
const homeTab = homeTabByPageLayoutId.get(pageLayout.id);
if (!isDefined(homeTab)) {
continue;
}
const fields = fieldsByObjectId.get(object.id) ?? [];
let nextWidgetIndex = homeTab.widgetCount;
for (const field of fields) {
if (
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION
) {
continue;
}
if (isActivityTargetField(field.name, object.nameSingular)) {
continue;
}
if (isJunctionRelationField(field)) {
continue;
}
if (field.type === FieldMetadataType.RELATION) {
const targetObject = isDefined(field.relationTargetObjectMetadataId)
? objectById.get(field.relationTargetObjectMetadataId)
: undefined;
if (!isRelationTargetAvailable(targetObject)) {
continue;
}
}
// For morph relations, skip if any sibling field (same morphId)
// already has a widget or was already processed in this run
if (
field.type === FieldMetadataType.MORPH_RELATION &&
isDefined(field.morphId)
) {
if (processedMorphIds.has(field.morphId)) {
continue;
}
const siblingFieldIds = fieldIdsByMorphId.get(field.morphId) ?? [];
const alreadyHasWidget = siblingFieldIds.some((id) =>
existingFieldWidgetFieldIds.has(id),
);
if (alreadyHasWidget) {
continue;
}
processedMorphIds.add(field.morphId);
}
if (existingFieldWidgetFieldIds.has(field.id)) {
continue;
}
const widget: FlatPageLayoutWidget = {
id: v4(),
universalIdentifier: v4(),
applicationId: twentyStandardFlatApplication.id,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
pageLayoutTabId: homeTab.id,
pageLayoutTabUniversalIdentifier: homeTab.universalIdentifier,
title: field.label,
type: WidgetType.FIELD,
gridPosition: { ...GRID_POSITIONS.FULL_WIDTH },
position: {
...VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
index: nextWidgetIndex,
},
configuration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.id,
fieldDisplayMode: FieldDisplayMode.CARD,
},
universalConfiguration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.universalIdentifier,
fieldDisplayMode: FieldDisplayMode.CARD,
},
objectMetadataId: object.id,
objectMetadataUniversalIdentifier: object.universalIdentifier,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
conditionalDisplay: null,
conditionalAvailabilityExpression: null,
overrides: null,
universalOverrides: null,
};
widgetsToCreate.push(widget);
existingFieldWidgetFieldIds.add(field.id);
nextWidgetIndex++;
}
}
if (widgetsToCreate.length === 0) {
this.logger.log(
`All FIELD widgets already exist for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`Found ${widgetsToCreate.length} FIELD widget(s) to create for workspace ${workspaceId}`,
);
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: widgetsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create FIELD widgets:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create FIELD widgets for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully created ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,534 @@
import { Command } from 'nest-commander';
import { FeatureFlagKey, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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 { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util';
import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util';
import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
@RegisteredWorkspaceCommand('1.23.0', 1780000001500)
@Command({
name: 'upgrade:1-23:backfill-record-page-layouts',
description:
'Delete and recreate all record page layouts from standard config, backfill custom objects, and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
})
export class BackfillRecordPageLayoutsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isAlreadyEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
workspaceId,
);
if (isAlreadyEnabled) {
this.logger.log(
`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would recreate all record page layouts and enable feature flag for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
await this.deleteAllRecordPageLayoutEntities({
workspaceId,
twentyStandardFlatApplication,
});
await this.createStandardRecordPageLayouts({
workspaceId,
twentyStandardFlatApplication,
});
await this.createCustomObjectPageLayouts({
workspaceId,
twentyStandardFlatApplication,
});
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED],
workspaceId,
);
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED],
workspaceId,
);
this.logger.log(
`Successfully backfilled record page layouts for workspace ${workspaceId}`,
);
}
private async deleteAllRecordPageLayoutEntities({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
flatViewMaps,
flatViewFieldMaps,
flatViewFieldGroupMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
'flatViewMaps',
'flatViewFieldMaps',
'flatViewFieldGroupMaps',
]);
const recordPageLayouts = Object.values(
flatPageLayoutMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((layout) => layout.type === PageLayoutType.RECORD_PAGE);
const recordPageLayoutIds = new Set(
recordPageLayouts.map((layout) => layout.id),
);
const tabs = Object.values(flatPageLayoutTabMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((tab) => recordPageLayoutIds.has(tab.pageLayoutId));
const tabIds = new Set(tabs.map((tab) => tab.id));
const widgets = Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((widget) => tabIds.has(widget.pageLayoutTabId));
const fieldsWidgetViews = Object.values(flatViewMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((view) => view.type === ViewType.FIELDS_WIDGET);
const fieldsWidgetViewUniversalIdentifiers = new Set(
fieldsWidgetViews.map((view) => view.universalIdentifier),
);
const viewFields = Object.values(flatViewFieldMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((viewField) =>
fieldsWidgetViewUniversalIdentifiers.has(
viewField.viewUniversalIdentifier,
),
);
const viewFieldGroups = Object.values(
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewFieldGroup) =>
fieldsWidgetViewUniversalIdentifiers.has(
viewFieldGroup.viewUniversalIdentifier,
),
);
if (recordPageLayouts.length === 0 && fieldsWidgetViews.length === 0) {
return;
}
this.logger.log(
`Deleting ${recordPageLayouts.length} page layouts, ${tabs.length} tabs, ${widgets.length} widgets, ${fieldsWidgetViews.length} views, ${viewFields.length} view fields, ${viewFieldGroups.length} view field groups for workspace ${workspaceId}`,
);
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
viewField: {
flatEntityToCreate: [],
flatEntityToDelete: viewFields,
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: [],
flatEntityToDelete: viewFieldGroups,
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: [],
flatEntityToDelete: fieldsWidgetViews,
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: [],
flatEntityToDelete: widgets,
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: [],
flatEntityToDelete: tabs,
flatEntityToUpdate: [],
},
pageLayout: {
flatEntityToCreate: [],
flatEntityToDelete: recordPageLayouts,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to delete record page layout entities:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to delete record page layout entities for workspace ${workspaceId}`,
);
}
}
private async createStandardRecordPageLayouts({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const { allFlatEntityMaps: standardMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const recordPageLayoutUniversalIdentifiers = new Set<string>();
const pageLayouts = Object.values(
standardMaps.flatPageLayoutMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((pageLayout) => {
if (pageLayout.type !== PageLayoutType.RECORD_PAGE) {
return false;
}
recordPageLayoutUniversalIdentifiers.add(
pageLayout.universalIdentifier,
);
return true;
});
const tabUniversalIdentifiers = new Set<string>();
const tabs = Object.values(
standardMaps.flatPageLayoutTabMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((tab) => {
if (
!recordPageLayoutUniversalIdentifiers.has(
tab.pageLayoutUniversalIdentifier,
)
) {
return false;
}
tabUniversalIdentifiers.add(tab.universalIdentifier);
return true;
});
const widgets = Object.values(
standardMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((widget) =>
tabUniversalIdentifiers.has(widget.pageLayoutTabUniversalIdentifier),
);
const fieldsWidgetViewUniversalIdentifiers = new Set<string>();
const views = Object.values(standardMaps.flatViewMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((view) => {
if (view.type !== ViewType.FIELDS_WIDGET) {
return false;
}
fieldsWidgetViewUniversalIdentifiers.add(view.universalIdentifier);
return true;
});
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const viewFields = Object.values(
standardMaps.flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewField) =>
fieldsWidgetViewUniversalIdentifiers.has(
viewField.viewUniversalIdentifier,
),
)
.filter((viewField) =>
isDefined(
flatFieldMetadataMaps.byUniversalIdentifier[
viewField.fieldMetadataUniversalIdentifier
],
),
);
const viewFieldGroups = Object.values(
standardMaps.flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((viewFieldGroup) =>
fieldsWidgetViewUniversalIdentifiers.has(
viewFieldGroup.viewUniversalIdentifier,
),
);
this.logger.log(
`Creating ${pageLayouts.length} standard page layouts, ${views.length} views, ${viewFields.length} view fields for workspace ${workspaceId}`,
);
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: pageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: tabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: widgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: views,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: viewFields,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: viewFieldGroups,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create standard record page layouts:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create standard record page layouts for workspace ${workspaceId}`,
);
}
}
private async createCustomObjectPageLayouts({
workspaceId,
twentyStandardFlatApplication,
}: {
workspaceId: string;
twentyStandardFlatApplication: FlatApplication;
}): Promise<void> {
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatPageLayoutMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatPageLayoutMaps',
]);
const objectIdsWithRecordPageLayout = new Set(
Object.values(flatPageLayoutMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(layout) =>
layout.type === PageLayoutType.RECORD_PAGE &&
isDefined(layout.objectMetadataId),
)
.map((layout) => layout.objectMetadataId),
);
const customObjectsWithoutPageLayout = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(objectMetadata) =>
objectMetadata.isCustom &&
!objectMetadata.isRemote &&
!objectIdsWithRecordPageLayout.has(objectMetadata.id),
);
if (customObjectsWithoutPageLayout.length === 0) {
this.logger.log(
`No custom objects without page layouts found for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Creating page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`,
);
const allPageLayouts: FlatPageLayout[] = [];
const allTabs: FlatPageLayoutTab[] = [];
const allWidgets: FlatPageLayoutWidget[] = [];
const allViews: (UniversalFlatView & { id: string })[] = [];
const allViewFields: UniversalFlatViewField[] = [];
for (const customObject of customObjectsWithoutPageLayout) {
const fieldsView = computeFlatRecordPageFieldsViewToCreate({
objectMetadata: customObject,
flatApplication: twentyStandardFlatApplication,
});
const objectFieldMetadatas = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((field) => field.objectMetadataId === customObject.id);
const viewFields = computeFlatViewFieldsToCreate({
objectFlatFieldMetadatas: objectFieldMetadatas,
viewUniversalIdentifier: fieldsView.universalIdentifier,
flatApplication: twentyStandardFlatApplication,
labelIdentifierFieldMetadataUniversalIdentifier:
customObject.labelIdentifierFieldMetadataUniversalIdentifier,
excludeLabelIdentifier: true,
});
const { pageLayouts, pageLayoutTabs, pageLayoutWidgets } =
computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: customObject,
flatApplication: twentyStandardFlatApplication,
recordPageFieldsView: fieldsView,
workspaceId,
});
allPageLayouts.push(...pageLayouts);
allTabs.push(...pageLayoutTabs);
allWidgets.push(...pageLayoutWidgets);
allViews.push(fieldsView);
allViewFields.push(...viewFields);
}
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayout: {
flatEntityToCreate: allPageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate: allTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate: allWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: allViews,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: allViewFields,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to create custom object page layouts:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to create custom object page layouts for workspace ${workspaceId}`,
);
}
}
}
@@ -0,0 +1,299 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { DiscoveryService } from '@nestjs/core';
import {
type UpgradeStep,
UpgradeSequenceReaderService,
} from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant';
const VERSION = TWENTY_CURRENT_VERSION;
@RegisteredWorkspaceCommand(VERSION, 1770000000000)
class MinimalWorkspaceCommand {
async runOnWorkspace(): Promise<void> {}
}
const buildProviderWrapper = (instance: object) => ({
instance,
metatype: instance.constructor,
});
const buildServiceWithMockedSequence = async (
mockSequence: UpgradeStep[],
): Promise<UpgradeSequenceReaderService> => {
const module = await Test.createTestingModule({
providers: [
UpgradeSequenceReaderService,
UpgradeCommandRegistryService,
{
provide: DiscoveryService,
useValue: {
getProviders: () =>
[new MinimalWorkspaceCommand()].map(buildProviderWrapper),
},
},
],
}).compile();
const registryService = module.get(UpgradeCommandRegistryService);
registryService.onModuleInit();
const service = module.get(UpgradeSequenceReaderService);
jest.spyOn(service, 'getUpgradeSequence').mockReturnValue(mockSequence);
return service;
};
const noopAsync = async () => {};
const makeStep = (kind: UpgradeStep['kind'], name: string): UpgradeStep => {
const command =
kind === 'workspace'
? { runOnWorkspace: noopAsync }
: kind === 'slow-instance'
? { up: noopAsync, down: noopAsync, runDataMigration: noopAsync }
: { up: noopAsync, down: noopAsync };
return {
kind,
name,
command,
version: VERSION,
timestamp: 0,
} as unknown as UpgradeStep;
};
const makeFastInstance = (name: string) => makeStep('fast-instance', name);
const makeWorkspace = (name: string) => makeStep('workspace', name);
describe('UpgradeSequenceReaderService', () => {
describe('getInitialCursorForNewWorkspace', () => {
it('should return last workspace command of segment following completed instance command', async () => {
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Wc2', status: 'completed' });
});
it('should return the instance command itself when next step is another instance command', async () => {
const sequence = [
makeWorkspace('Wc-1'),
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Ic0', status: 'completed' });
});
it('should return last workspace command when all instance commands in batch are completed', async () => {
const sequence = [
makeWorkspace('Wc-1'),
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic1',
status: 'completed',
});
expect(result).toEqual({ name: 'Wc1', status: 'completed' });
});
it('should stop at next instance command boundary', async () => {
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Wc1', status: 'completed' });
});
it('should return the instance command itself when at end of sequence', async () => {
const sequence = [makeWorkspace('Wc0'), makeFastInstance('Ic0')];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Ic0', status: 'completed' });
});
it('should return the instance command itself when no workspace command exists before it', async () => {
const sequence = [
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Ic0', status: 'completed' });
});
it('should return final segment when last instance command is completed', async () => {
const sequence = [
makeWorkspace('Wc0'),
makeFastInstance('Ic0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
makeWorkspace('Wc3'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic1',
status: 'completed',
});
expect(result).toEqual({ name: 'Wc3', status: 'completed' });
});
it('should handle single workspace command in segment', async () => {
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'completed',
});
expect(result).toEqual({ name: 'Wc0', status: 'completed' });
});
it('should return the instance command itself when sequence ends with instance commands batch', async () => {
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeFastInstance('Ic2'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic2',
status: 'completed',
});
expect(result).toEqual({ name: 'Ic2', status: 'completed' });
});
it('should return the failed instance command when IC failed — not skip forward to WC segment', async () => {
// Sequence: Ic0 → Ic1 → Wc0 → Wc1
// Ic1 failed → cursor stays at Ic1:failed (does NOT skip to Wc1)
const sequence = [
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic1',
status: 'failed',
});
expect(result).toEqual({ name: 'Ic1', status: 'failed' });
});
it('should return the failed instance command even when next step is a workspace command', async () => {
// Sequence: Ic0 → Wc0 → Wc1
// Ic0 failed → cursor stays at Ic0:failed
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic0',
status: 'failed',
});
expect(result).toEqual({ name: 'Ic0', status: 'failed' });
});
it('should return the failed mid-segment instance command', async () => {
// Sequence: Ic0 → Ic1 → Ic2 → Wc0
// Ic1 failed (Ic0 completed but Ic1 is the last attempted) → cursor at Ic1:failed
const sequence = [
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeFastInstance('Ic2'),
makeWorkspace('Wc0'),
];
const service = await buildServiceWithMockedSequence(sequence);
const result = service.getInitialCursorForNewWorkspace({
name: 'Ic1',
status: 'failed',
});
expect(result).toEqual({ name: 'Ic1', status: 'failed' });
});
});
});
@@ -7,6 +7,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
type RunSingleMigrationResult =
| { status: 'success' }
@@ -22,6 +23,7 @@ export class InstanceCommandRunnerService {
private readonly dataSource: DataSource,
private readonly twentyConfigService: TwentyConfigService,
private readonly upgradeMigrationService: UpgradeMigrationService,
private readonly workspaceVersionService: WorkspaceVersionService,
) {}
async runFastInstanceCommand({
@@ -54,9 +56,16 @@ export class InstanceCommandRunnerService {
await command.up(queryRunner);
await this.upgradeMigrationService.markAsCompleted({
const workspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds({
queryRunner,
});
await this.upgradeMigrationService.recordUpgradeMigration({
name,
workspaceId: null,
workspaceIds,
isInstance: true,
status: 'completed',
executedByVersion,
queryRunner,
});
@@ -67,9 +76,14 @@ export class InstanceCommandRunnerService {
await queryRunner.rollbackTransaction();
}
await this.upgradeMigrationService.markAsFailed({
const workspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
await this.upgradeMigrationService.recordUpgradeMigration({
name,
workspaceId: null,
workspaceIds,
isInstance: true,
status: 'failed',
executedByVersion,
error,
});
@@ -117,9 +131,14 @@ export class InstanceCommandRunnerService {
try {
await command.runDataMigration(this.dataSource);
} catch (error) {
await this.upgradeMigrationService.markAsFailed({
const workspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
await this.upgradeMigrationService.recordUpgradeMigration({
name,
workspaceId: null,
workspaceIds,
isInstance: true,
status: 'failed',
executedByVersion,
error,
});
@@ -133,6 +152,9 @@ export class InstanceCommandRunnerService {
}
}
return this.runFastInstanceCommand({ command, name });
return this.runFastInstanceCommand({
command,
name,
});
}
}
@@ -10,6 +10,12 @@ import {
} from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { formatUpgradeErrorForStorage } from 'src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util';
export type WorkspaceCursor = {
name: string;
status: UpgradeMigrationStatus;
isInitial: boolean;
};
@Injectable()
export class UpgradeMigrationService {
constructor(
@@ -35,73 +41,96 @@ export class UpgradeMigrationService {
return isDefined(latestAttempt) && latestAttempt.status === 'completed';
}
async markAsCompleted({
name,
workspaceId,
executedByVersion,
queryRunner,
}: {
name: string;
workspaceId: string | null;
executedByVersion: string;
queryRunner?: QueryRunner;
}): Promise<void> {
const repository = queryRunner
? queryRunner.manager.getRepository(UpgradeMigrationEntity)
async recordUpgradeMigration(
params:
| {
name: string;
workspaceIds: string[];
isInstance: boolean;
status: 'completed';
executedByVersion: string;
queryRunner?: QueryRunner;
}
| {
name: string;
workspaceIds: string[];
isInstance: boolean;
status: 'failed';
executedByVersion: string;
error: unknown;
queryRunner?: QueryRunner;
},
): Promise<void> {
const { name, workspaceIds, isInstance, status, executedByVersion } =
params;
const repository = params.queryRunner
? params.queryRunner.manager.getRepository(UpgradeMigrationEntity)
: this.upgradeMigrationRepository;
const previousAttempts = await repository.count({
where: {
name,
workspaceId: workspaceId === null ? IsNull() : workspaceId,
},
});
await repository.save({
name,
status: 'completed',
attempt: previousAttempts + 1,
executedByVersion,
workspaceId,
});
const errorMessage =
params.status === 'failed'
? formatUpgradeErrorForStorage(params.error)
: null;
if (isInstance) {
const previousAttempts = await repository.count({
where: { name, workspaceId: IsNull() },
});
await repository.save([
{
name,
status,
attempt: previousAttempts + 1,
executedByVersion,
workspaceId: null,
errorMessage,
},
...workspaceIds.map((workspaceId) => ({
name,
status,
attempt: previousAttempts + 1,
executedByVersion,
workspaceId,
errorMessage,
})),
]);
return;
}
const rows = [];
for (const workspaceId of workspaceIds) {
const previousAttempts = await repository.count({
where: { name, workspaceId },
});
rows.push({
name,
status,
attempt: previousAttempts + 1,
executedByVersion,
workspaceId,
errorMessage,
});
}
await repository.save(rows);
}
async markAsFailed({
name,
workspaceId,
executedByVersion,
error,
}: {
name: string;
workspaceId: string | null;
executedByVersion: string;
error: unknown;
}): Promise<void> {
const previousAttempts = await this.upgradeMigrationRepository.count({
where: {
name,
workspaceId: workspaceId === null ? IsNull() : workspaceId,
},
});
await this.upgradeMigrationRepository.save({
name,
status: 'failed',
attempt: previousAttempts + 1,
executedByVersion,
workspaceId,
errorMessage: formatUpgradeErrorForStorage(error),
});
}
async markAsInitial({
async markAsWorkspaceInitial({
name,
workspaceId,
executedByVersion,
status,
queryRunner,
}: {
name: string;
workspaceId: string;
executedByVersion: string;
status: UpgradeMigrationStatus;
queryRunner?: QueryRunner;
}): Promise<void> {
const repository = queryRunner
@@ -110,7 +139,7 @@ export class UpgradeMigrationService {
await repository.save({
name,
status: 'completed',
status,
isInitial: true,
attempt: 1,
executedByVersion,
@@ -120,8 +149,8 @@ export class UpgradeMigrationService {
// Returns the most recently attempted command (by createdAt)
// across instance and active-workspace scopes, with its status.
// Workspace-scoped records from inactive/deleted workspaces are
// excluded so they cannot incorrectly influence the global cursor.
// isInitial records are excluded — they represent activation
// state, not execution progress.
async getLastAttemptedCommandNameOrThrow(
allActiveOrSuspendedWorkspaceIds: string[],
): Promise<{
@@ -131,6 +160,7 @@ export class UpgradeMigrationService {
const queryBuilder = this.upgradeMigrationRepository
.createQueryBuilder('migration')
.select(['migration.name', 'migration.status'])
.andWhere('migration."isInitial" = false')
.andWhere(
`migration.attempt = (
SELECT MAX(sub.attempt)
@@ -167,7 +197,7 @@ export class UpgradeMigrationService {
async getWorkspaceLastAttemptedCommandNameOrThrow(
workspaceIds: string[],
): Promise<Map<string, { name: string; status: UpgradeMigrationStatus }>> {
): Promise<Map<string, WorkspaceCursor>> {
if (workspaceIds.length === 0) {
return new Map();
}
@@ -177,6 +207,7 @@ export class UpgradeMigrationService {
.select('migration.workspaceId', 'workspaceId')
.addSelect('migration.name', 'name')
.addSelect('migration.status', 'status')
.addSelect('migration.isInitial', 'isInitial')
.where({
workspaceId: In(workspaceIds),
})
@@ -195,15 +226,17 @@ export class UpgradeMigrationService {
workspaceId: string;
name: string;
status: UpgradeMigrationStatus;
isInitial: boolean;
}>();
const cursors = new Map<
string,
{ name: string; status: UpgradeMigrationStatus }
>();
const cursors = new Map<string, WorkspaceCursor>();
for (const row of results) {
cursors.set(row.workspaceId, { name: row.name, status: row.status });
cursors.set(row.workspaceId, {
name: row.name,
status: row.status,
isInitial: row.isInitial,
});
}
const missingWorkspaceIds = workspaceIds.filter(
@@ -249,4 +282,33 @@ export class UpgradeMigrationService {
return completedCount === workspaceIds.length;
}
async getLastAttemptedInstanceCommandOrThrow(): Promise<{
name: string;
status: UpgradeMigrationStatus;
}> {
const migration = await this.upgradeMigrationRepository
.createQueryBuilder('migration')
.select(['migration.name', 'migration.status'])
.where('migration."workspaceId" IS NULL')
.andWhere('migration."isInitial" = false')
.andWhere(
`migration.attempt = (
SELECT MAX(sub.attempt)
FROM core."upgradeMigration" sub
WHERE sub.name = migration.name
AND sub."workspaceId" IS NULL
)`,
)
.orderBy('migration.createdAt', 'DESC')
.getOne();
if (!migration) {
throw new Error(
'No instance command found — the database may not have been initialized',
);
}
return { name: migration.name, status: migration.status };
}
}
@@ -7,6 +7,8 @@ import {
type RegisteredWorkspaceCommand,
UpgradeCommandRegistryService,
} from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service';
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { isDefined } from 'twenty-shared/utils';
export type FastInstanceUpgradeStep = {
kind: 'fast-instance';
@@ -78,7 +80,7 @@ export class UpgradeSequenceReaderService {
return cursor;
}
getWorkspaceCommandsSliceBounds({
getWorkspaceSegmentBounds({
sequence,
workspaceCommand,
}: {
@@ -108,7 +110,7 @@ export class UpgradeSequenceReaderService {
return { startCursor, endCursor };
}
collectContiguousWorkspaceSteps({
collectWorkspaceCommandsStartingFrom({
sequence,
fromWorkspaceCommand,
}: {
@@ -160,19 +162,46 @@ export class UpgradeSequenceReaderService {
: workspaceCommands.slice(cursorIndex);
}
getLastWorkspaceCommand(): RegisteredWorkspaceCommand {
getInitialCursorForNewWorkspace(lastAttemptedInstanceCommand: {
name: string;
status: UpgradeMigrationStatus;
}): {
name: string;
status: UpgradeMigrationStatus;
} {
const { name, status } = lastAttemptedInstanceCommand;
const sequence = this.getUpgradeSequence();
for (let index = sequence.length - 1; index >= 0; index--) {
const step = sequence[index];
const instanceCursor = this.locateStepInSequenceOrThrow({
sequence,
stepName: name,
});
if (step.kind === 'workspace') {
return step;
if (status === 'completed') {
const nextStep = sequence[instanceCursor + 1];
if (isDefined(nextStep) && nextStep.kind === 'workspace') {
const lastWc = this.findLastWorkspaceCommandInSegmentStartingAt(
sequence,
nextStep,
);
return { name: lastWc.name, status: 'completed' };
}
}
throw new Error(
'No workspace commands found in upgrade sequence — this should have been caught at startup',
);
return { name, status };
}
private findLastWorkspaceCommandInSegmentStartingAt(
sequence: UpgradeStep[],
firstWorkspaceCommand: WorkspaceUpgradeStep,
): RegisteredWorkspaceCommand {
const segment = this.collectWorkspaceCommandsStartingFrom({
sequence,
fromWorkspaceCommand: firstWorkspaceCommand,
});
return segment[segment.length - 1];
}
}
@@ -6,7 +6,10 @@ import {
} from 'src/database/commands/command-runners/workspace-iterator.service';
import { type ParsedUpgradeCommandOptions } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import {
type WorkspaceCursor,
UpgradeMigrationService,
} from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import {
type InstanceUpgradeStep,
type UpgradeStep,
@@ -57,16 +60,21 @@ export class UpgradeSequenceRunnerService {
let totalSuccesses = 0;
let totalFailures = 0;
let cursor = startCursor;
let workspaceCursors = await this.fetchWorkspaceCursors(
allActiveOrSuspendedWorkspaceIds,
);
while (cursor < sequence.length) {
const step = sequence[cursor];
if (step.kind === 'fast-instance' || step.kind === 'slow-instance') {
const previousStep = cursor > 0 ? sequence[cursor - 1] : undefined;
if (previousStep?.kind === 'workspace') {
await this.enforceWorkspaceSyncBarrier({
this.enforceWorkspacesCompletedPreviousWorkspaceSegment({
sequence,
previousWorkspaceStep: previousStep,
allActiveOrSuspendedWorkspaceIds,
workspaceCursors,
});
}
@@ -74,18 +82,20 @@ export class UpgradeSequenceRunnerService {
instanceStep: step,
skipDataMigration: allActiveOrSuspendedWorkspaceIds.length === 0,
});
cursor++;
continue;
}
const contiguousWorkspaceSteps =
this.upgradeSequenceReaderService.collectContiguousWorkspaceSteps({
const workspaceCommandsSegment =
this.upgradeSequenceReaderService.collectWorkspaceCommandsStartingFrom({
sequence,
fromWorkspaceCommand: step,
});
const report = await this.resumeWorkspaceCommandsFromCursors({
contiguousWorkspaceSteps,
workspaceCommandsSegment,
workspaceCursors,
allActiveOrSuspendedWorkspaceIds,
options,
});
@@ -102,11 +112,16 @@ export class UpgradeSequenceRunnerService {
return { totalSuccesses, totalFailures };
}
cursor += contiguousWorkspaceSteps.length;
cursor += workspaceCommandsSegment.length;
workspaceCursors = await this.fetchWorkspaceCursors(
allActiveOrSuspendedWorkspaceIds,
);
}
return { totalSuccesses, totalFailures };
}
private async resolveStartCursor({
sequence,
allActiveOrSuspendedWorkspaceIds,
@@ -136,12 +151,12 @@ export class UpgradeSequenceRunnerService {
}
case 'workspace': {
const workspaceSliceBounds =
this.upgradeSequenceReaderService.getWorkspaceCommandsSliceBounds({
this.upgradeSequenceReaderService.getWorkspaceSegmentBounds({
sequence,
workspaceCommand: lastAttemptedStep,
});
await this.validateWorkspaceCursorsAreInSameWorkspaceStepsSlice({
await this.validateWorkspaceCursorsAreInWorkspaceSegment({
sequence,
allActiveOrSuspendedWorkspaceIds,
workspaceSliceBounds,
@@ -154,7 +169,7 @@ export class UpgradeSequenceRunnerService {
}
}
private async validateWorkspaceCursorsAreInSameWorkspaceStepsSlice({
private async validateWorkspaceCursorsAreInWorkspaceSegment({
allActiveOrSuspendedWorkspaceIds,
sequence,
workspaceSliceBounds: { startCursor, endCursor },
@@ -167,22 +182,61 @@ export class UpgradeSequenceRunnerService {
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
allActiveOrSuspendedWorkspaceIds,
);
const precedingStep =
startCursor > 0 ? sequence[startCursor - 1] : undefined;
const invalidWorkspaces: Array<{
workspaceId: string;
cursorName: string;
cursorStatus: string;
}> = [];
for (const [workspaceId, workspaceCursor] of workspaceCursors) {
const cursor =
const cursorPosition =
this.upgradeSequenceReaderService.locateStepInSequenceOrThrow({
sequence,
stepName: workspaceCursor.name,
});
if (cursor < startCursor || cursor > endCursor) {
throw new Error(
`Workspace ${workspaceId} cursor "${workspaceCursor.name}" is outside the ` +
`current workspace slice [${startCursor}..${endCursor}] — ` +
'workspaces are not aligned',
);
const isWithinSegment =
cursorPosition >= startCursor && cursorPosition <= endCursor;
const isAtPrecedingInstanceCommandCompleted =
isDefined(precedingStep) &&
precedingStep.kind !== 'workspace' &&
cursorPosition === startCursor - 1 &&
workspaceCursor.status === 'completed';
if (!isWithinSegment && !isAtPrecedingInstanceCommandCompleted) {
invalidWorkspaces.push({
workspaceId,
cursorName: workspaceCursor.name,
cursorStatus: workspaceCursor.status,
});
}
}
if (invalidWorkspaces.length > 0) {
const details = invalidWorkspaces
.map(
({ workspaceId, cursorName, cursorStatus }) =>
`${workspaceId} at "${cursorName}" (${cursorStatus})`,
)
.join(', ');
throw new Error(
`${invalidWorkspaces.length} workspace(s) have invalid cursors for ` +
`workspace segment [${startCursor}..${endCursor}]: ${details}`,
);
}
}
private async fetchWorkspaceCursors(
allActiveOrSuspendedWorkspaceIds: string[],
): Promise<Map<string, WorkspaceCursor>> {
return this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
allActiveOrSuspendedWorkspaceIds,
);
}
private async runInstanceStep({
@@ -226,24 +280,23 @@ export class UpgradeSequenceRunnerService {
}
private async resumeWorkspaceCommandsFromCursors({
contiguousWorkspaceSteps,
workspaceCommandsSegment,
workspaceCursors,
allActiveOrSuspendedWorkspaceIds,
options,
}: {
contiguousWorkspaceSteps: WorkspaceUpgradeStep[];
workspaceCommandsSegment: WorkspaceUpgradeStep[];
workspaceCursors: Map<string, WorkspaceCursor>;
allActiveOrSuspendedWorkspaceIds: string[];
options: ParsedUpgradeCommandOptions;
}): Promise<WorkspaceIteratorReport> {
const workspaceCursors =
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
allActiveOrSuspendedWorkspaceIds,
);
const workspaceIds =
isDefined(options.workspaceIds) && options.workspaceIds.length > 0
? options.workspaceIds
: allActiveOrSuspendedWorkspaceIds;
return this.workspaceIteratorService.iterate({
workspaceIds:
isDefined(options.workspaceIds) && options.workspaceIds.length > 0
? options.workspaceIds
: allActiveOrSuspendedWorkspaceIds,
workspaceIds,
startFromWorkspaceId: options.startFromWorkspaceId,
workspaceCountLimit: options.workspaceCountLimit,
dryRun: options.dryRun,
@@ -258,7 +311,7 @@ export class UpgradeSequenceRunnerService {
const pendingCommands =
this.upgradeSequenceReaderService.getPendingWorkspaceCommands({
workspaceCommands: contiguousWorkspaceSteps,
workspaceCommands: workspaceCommandsSegment,
workspaceCursor,
});
@@ -271,24 +324,39 @@ export class UpgradeSequenceRunnerService {
});
}
private async enforceWorkspaceSyncBarrier({
private enforceWorkspacesCompletedPreviousWorkspaceSegment({
sequence,
previousWorkspaceStep,
allActiveOrSuspendedWorkspaceIds,
workspaceCursors,
}: {
sequence: UpgradeStep[];
previousWorkspaceStep: WorkspaceUpgradeStep;
allActiveOrSuspendedWorkspaceIds: string[];
}): Promise<void> {
const allWorkspacesReady =
await this.upgradeMigrationService.areAllWorkspacesAtCommand({
commandName: previousWorkspaceStep.name,
workspaceIds: allActiveOrSuspendedWorkspaceIds,
workspaceCursors: Map<string, WorkspaceCursor>;
}): void {
const barrierCursor =
this.upgradeSequenceReaderService.locateStepInSequenceOrThrow({
sequence,
stepName: previousWorkspaceStep.name,
});
if (!allWorkspacesReady) {
throw new Error(
'Cannot run instance step: not all workspaces have completed ' +
`"${previousWorkspaceStep.name}"`,
);
for (const [workspaceId, workspaceCursor] of workspaceCursors) {
const cursorPosition =
this.upgradeSequenceReaderService.locateStepInSequenceOrThrow({
sequence,
stepName: workspaceCursor.name,
});
const isAtBarrierAndCompleted =
cursorPosition === barrierCursor &&
workspaceCursor.status === 'completed';
if (!isAtBarrierAndCompleted) {
throw new Error(
`Cannot run instance step: workspace ${workspaceId} ` +
`has not completed "${previousWorkspaceStep.name}" ` +
`(cursor: "${workspaceCursor.name}", status: "${workspaceCursor.status}")`,
);
}
}
}
}
@@ -78,17 +78,21 @@ export class WorkspaceCommandRunnerService {
});
if (!options.dryRun) {
await this.upgradeMigrationService.markAsCompleted({
await this.upgradeMigrationService.recordUpgradeMigration({
name,
workspaceId,
workspaceIds: [workspaceId],
isInstance: false,
status: 'completed',
executedByVersion,
});
}
} catch (error) {
if (!options.dryRun) {
await this.upgradeMigrationService.markAsFailed({
await this.upgradeMigrationService.recordUpgradeMigration({
name,
workspaceId,
workspaceIds: [workspaceId],
isInstance: false,
status: 'failed',
executedByVersion,
error,
});
@@ -383,8 +383,13 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
workspaceId: string;
displayName: string;
}): Promise<void> {
const lastWorkspaceCommand =
this.upgradeSequenceReaderService.getLastWorkspaceCommand();
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
const initialCursor =
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
lastAttemptedInstanceCommand,
);
const executedByVersion =
this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
@@ -400,10 +405,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
await this.upgradeMigrationService.markAsInitial({
name: lastWorkspaceCommand.name,
await this.upgradeMigrationService.markAsWorkspaceInitial({
name: initialCursor.name,
workspaceId,
executedByVersion,
status: initialCursor.status,
queryRunner,
});
@@ -21,11 +21,17 @@ export type CreateWorkspaceInput = Pick<
export const SEED_APPLE_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
export const SEED_YCOMBINATOR_WORKSPACE_ID =
'3b8e6458-5fc1-4e63-8563-008ccddaa6db';
export const SEED_EMPTY_WORKSPACE_3_ID = '506915ec-21ca-431b-a04a-257eb216865e';
export const SEED_EMPTY_WORKSPACE_4_ID = 'aa8fdcb1-8ee1-4012-98af-44a97caa7411';
export type SeededWorkspacesIds =
| typeof SEED_APPLE_WORKSPACE_ID
| typeof SEED_YCOMBINATOR_WORKSPACE_ID;
export type SeededEmptyWorkspacesIds =
| typeof SEED_EMPTY_WORKSPACE_3_ID
| typeof SEED_EMPTY_WORKSPACE_4_ID;
export const SEEDER_CREATE_WORKSPACE_INPUT = {
[SEED_APPLE_WORKSPACE_ID]: {
id: SEED_APPLE_WORKSPACE_ID,
@@ -49,3 +55,29 @@ export const SEEDER_CREATE_WORKSPACE_INPUT = {
SeededWorkspacesIds,
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
>;
// Empty workspaces with no users, metadata, or data — used by integration tests
// that need more than 2 workspaces (e.g. upgrade sequence runner tests).
export const SEEDER_CREATE_EMPTY_WORKSPACE_INPUT = {
[SEED_EMPTY_WORKSPACE_3_ID]: {
id: SEED_EMPTY_WORKSPACE_3_ID,
displayName: 'Empty3',
subdomain: 'empty3',
inviteHash: 'empty3.dev-invite-hash',
logo: '',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
isTwoFactorAuthenticationEnforced: false,
},
[SEED_EMPTY_WORKSPACE_4_ID]: {
id: SEED_EMPTY_WORKSPACE_4_ID,
displayName: 'Empty4',
subdomain: 'empty4',
inviteHash: 'empty4.dev-invite-hash',
logo: '',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
isTwoFactorAuthenticationEnforced: false,
},
} as const satisfies Record<
SeededEmptyWorkspacesIds,
Omit<CreateWorkspaceInput, 'workspaceCustomApplicationId'>
>;
@@ -10,6 +10,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -146,6 +147,27 @@ export class DevSeederPermissionsService {
roleId: adminRole.id,
});
const memberRole = await this.initMinimalPermissionsAndActivateWorkspace({
workspaceId,
workspaceCustomFlatApplication,
});
if (memberUserWorkspaceIds.length > 0) {
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId,
userWorkspaceIds: memberUserWorkspaceIds,
roleId: memberRole.id,
});
}
}
public async initMinimalPermissionsAndActivateWorkspace({
workspaceId,
workspaceCustomFlatApplication,
}: {
workspaceId: string;
workspaceCustomFlatApplication: FlatApplication;
}): Promise<RoleDTO> {
const memberRole = await this.roleService.createMemberRole({
workspaceId,
ownerFlatApplication: workspaceCustomFlatApplication,
@@ -158,13 +180,7 @@ export class DevSeederPermissionsService {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
if (memberUserWorkspaceIds.length > 0) {
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId,
userWorkspaceIds: memberUserWorkspaceIds,
roleId: memberRole.id,
});
}
return memberRole;
}
private async createLimitedRoleForSeedWorkspace({
@@ -10,6 +10,7 @@ import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/s
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -18,7 +19,9 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work
import { seedBillingCustomers } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util';
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
import {
type SeededEmptyWorkspacesIds,
type SeededWorkspacesIds,
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT,
SEEDER_CREATE_WORKSPACE_INPUT,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service';
@@ -73,14 +76,18 @@ export class DevSeederService {
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
const lastWorkspaceCommand =
this.upgradeSequenceReaderService.getLastWorkspaceCommand();
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
const initialCursor =
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
lastAttemptedInstanceCommand,
);
await this.seedCoreSchema({
workspaceId,
seedBilling: isBillingEnabled,
appVersion,
lastUpgradeStepName: lastWorkspaceCommand.name,
initialCursor,
});
await this.applicationRegistrationService.createCliRegistrationIfNotExists();
@@ -181,15 +188,93 @@ export class DevSeederService {
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
}
public async seedEmptyWorkspace(
workspaceId: SeededEmptyWorkspacesIds,
): Promise<void> {
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
const initialCursor =
this.upgradeSequenceReaderService.getInitialCursorForNewWorkspace(
lastAttemptedInstanceCommand,
);
const createWorkspaceStaticInput =
SEEDER_CREATE_EMPTY_WORKSPACE_INPUT[workspaceId];
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const workspaceCustomApplicationId = v4();
await createWorkspace({
queryRunner,
schemaName: 'core',
createWorkspaceInput: {
...createWorkspaceStaticInput,
workspaceCustomApplicationId,
},
});
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
await this.applicationService.createTwentyStandardApplication(
{
workspaceId,
skipCacheInvalidation: true,
},
queryRunner,
);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.devSeederPermissionsService.initMinimalPermissionsAndActivateWorkspace(
{
workspaceId,
workspaceCustomFlatApplication,
},
);
await this.upgradeMigrationService.markAsWorkspaceInitial({
name: initialCursor.name,
workspaceId,
executedByVersion: appVersion,
status: initialCursor.status,
});
await this.workspaceCacheStorageService.flush(workspaceId, undefined);
}
private async seedCoreSchema({
workspaceId,
appVersion,
lastUpgradeStepName,
initialCursor,
seedBilling = true,
}: {
workspaceId: SeededWorkspacesIds;
appVersion: string;
lastUpgradeStepName: string;
initialCursor: { name: string; status: UpgradeMigrationStatus };
seedBilling?: boolean;
}): Promise<void> {
const schemaName = 'core';
@@ -247,10 +332,11 @@ export class DevSeederService {
await seedMetadataEntities({ queryRunner, schemaName, workspaceId });
await this.upgradeMigrationService.markAsInitial({
name: lastUpgradeStepName,
await this.upgradeMigrationService.markAsWorkspaceInitial({
name: initialCursor.name,
workspaceId,
executedByVersion: appVersion,
status: initialCursor.status,
queryRunner,
});
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { In, MoreThanOrEqual, Repository } from 'typeorm';
import { In, MoreThanOrEqual, QueryRunner, Repository } from 'typeorm';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -27,11 +27,17 @@ export class WorkspaceVersionService {
async getActiveOrSuspendedWorkspaceIds({
startFromWorkspaceId,
workspaceCountLimit,
queryRunner,
}: {
startFromWorkspaceId?: string;
workspaceCountLimit?: number;
queryRunner?: QueryRunner;
} = {}): Promise<string[]> {
const workspaces = await this.workspaceRepository.find({
const repository = queryRunner
? queryRunner.manager.getRepository(WorkspaceEntity)
: this.workspaceRepository;
const workspaces = await repository.find({
select: ['id'],
where: {
activationStatus: In([
@@ -11,12 +11,13 @@ import {
makeStep,
makeWorkspace,
resetSeedSequenceCounter,
seedMigration,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
testGetLatestMigrationForCommand,
WS_1,
WS_2,
} from './utils/upgrade-sequence-runner-integration-test.util';
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
const makeFailingFastInstance = (name: string, error: Error): UpgradeStep =>
({
@@ -94,7 +95,7 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
it('should throw when cursor command is not found in the sequence', async () => {
const sequence = [makeFastInstance('Ic1'), makeFastInstance('Ic2')];
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'RemovedCommand',
status: 'completed',
});
@@ -118,31 +119,32 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
});
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc3',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc4',
status: 'completed',
workspaceId: WS_1,
@@ -153,7 +155,9 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow('workspaces are not aligned');
).rejects.toThrow(
'workspace(s) have invalid cursors for workspace segment',
);
});
it('should throw when an active workspace has no migration history', async () => {
@@ -161,7 +165,7 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
@@ -182,7 +186,7 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
makeFailingFastInstance('Ic2', error),
];
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
@@ -221,9 +225,10 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await expect(
@@ -251,7 +256,7 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_1,
@@ -295,26 +300,27 @@ describe('UpgradeSequenceRunnerService — failing sequence (integration)', () =
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
@@ -8,12 +8,13 @@ import {
makeSlowInstance,
makeWorkspace,
resetSeedSequenceCounter,
seedMigration,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
testGetLatestMigrationForCommand,
WS_1,
WS_2,
} from './utils/upgrade-sequence-runner-integration-test.util';
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
describe('UpgradeSequenceRunnerService — execution (integration)', () => {
let context: IntegrationTestContext;
@@ -51,11 +52,11 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
makeSlowInstance('Ic3'),
];
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic2',
status: 'completed',
});
@@ -87,11 +88,11 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
it('should retry a failed instance command', async () => {
const sequence = [makeFastInstance('Ic1'), makeFastInstance('Ic2')];
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic2',
status: 'failed',
});
@@ -119,11 +120,12 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
@@ -149,7 +151,7 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
@@ -172,7 +174,7 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
it('should skip data migration for slow instance commands when no workspaces exist', async () => {
const sequence = [makeFastInstance('Ic1'), makeSlowInstance('Ic2')];
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
@@ -208,9 +210,10 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
const instanceCommandRunnerService = context.module.get(
@@ -248,16 +251,17 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
@@ -300,14 +304,15 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await context.runner.run({
@@ -340,11 +345,12 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_1,
@@ -388,11 +394,12 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
@@ -430,18 +437,19 @@ describe('UpgradeSequenceRunnerService — execution (integration)', () => {
setMockActiveWorkspaceIds([WS_1]);
await seedMigration(context.dataSource, {
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
// WS_2 is inactive — its record is more recent (seeded later)
// but should not influence the global cursor
await seedMigration(context.dataSource, {
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
@@ -0,0 +1,587 @@
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeFastInstance,
makeSlowInstance,
makeWorkspace,
migrationRecordToKey,
resetSeedSequenceCounter,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
testGetExecutedMigrationsInOrder,
WS_1,
WS_2,
WS_3,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
describe('UpgradeSequenceRunnerService — workspace segment alignment (integration)', () => {
let context: IntegrationTestContext;
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
}, 30000);
afterAll(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should upgrade all workspaces when they are in the same segment at different positions', async () => {
// Sequence: Ic0 → Wc0 → Wc1 → Wc2 → Ic1 → Ic2 → Wc3 → Wc4
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic1'),
makeSlowInstance('Ic2'),
makeWorkspace('Wc3'),
makeWorkspace('Wc4'),
];
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2, WS_3],
});
// WS_1: at Wc0, completed
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc1, failed (needs retry)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_2,
});
// WS_3: at Wc2, completed (most advanced in segment)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_3,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_3,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_3,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2, WS_3],
},
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds (Ic0 global + workspace rows inserted at same timestamp)
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
`Ic0:${WS_3}:completed:1`,
`Wc0:${WS_1}:completed:1`,
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:failed:1`,
`Wc0:${WS_3}:completed:1`,
`Wc1:${WS_3}:completed:1`,
`Wc2:${WS_3}:completed:1`,
// Segment A: WS_1 runs Wc1, Wc2; WS_2 retries Wc1, runs Wc2; WS_3 already done
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_1}:completed:1`,
`Wc1:${WS_2}:completed:2`,
`Wc2:${WS_2}:completed:1`,
// Sync barrier → instance steps (with workspace-level rows)
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Ic1:${WS_3}:completed:1`,
'Ic2:instance:completed:1',
`Ic2:${WS_1}:completed:1`,
`Ic2:${WS_2}:completed:1`,
`Ic2:${WS_3}:completed:1`,
// Segment B: all workspaces run Wc3, Wc4
`Wc3:${WS_1}:completed:1`,
`Wc4:${WS_1}:completed:1`,
`Wc3:${WS_2}:completed:1`,
`Wc4:${WS_2}:completed:1`,
`Wc3:${WS_3}:completed:1`,
`Wc4:${WS_3}:completed:1`,
]);
});
it('should reject workspaces with cursors ahead of the current segment', async () => {
// Sequence: Ic0 → Wc0 → Wc1 → Ic1 → Wc2 → Wc3
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
// WS_1 existed when Ic0 ran; WS_2 was created later (initial at Wc2)
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_1: at Wc0 (in segment A) - correct
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc2 (in segment B) - WRONG! Ahead of current segment
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
isInitial: true,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should reject workspaces with cursors behind the current segment', async () => {
// Sequence: Wc-1 → Ic0 → Wc0 → Wc1 → Ic1 → Wc2
const sequence = [
makeWorkspace('Wc-1'),
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// WS_1: at Wc0 (in segment after Ic0) - correct
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc-1 (in segment before Ic0) - WRONG! Behind current segment
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc-1',
status: 'completed',
workspaceId: WS_2,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should handle workspaces created at correct segment via isInitial', async () => {
// Sequence: Wc0 → Ic0 → Ic1 → Wc1 → Wc2
// WS_1 existed from the start, WS_2 was created after Ic1
const sequence = [
makeWorkspace('Wc0'),
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_1: at Wc1 (in correct segment after Ic1)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: newly created with isInitial at Wc2 (correct segment after Ic1)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
isInitial: true,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_2}:completed:1:initial`,
// WS_1 runs Wc2, WS_2 already at Wc2 (no new commands needed)
`Wc2:${WS_1}:completed:1`,
]);
});
it('should accept workspaces at the preceding completed IC when others are in the WC segment (-w scenario)', async () => {
// Sequence: Ic0 → Ic1 → Wc0 → Wc1
// WS_1 was upgraded via -w and is at Wc1:completed
// WS_2 is still at Ic1:completed (preceding IC)
const sequence = [
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// WS_1 was upgraded ahead via -w
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Wc0:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
// WS_2 runs full segment, WS_1 already done
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:completed:1`,
]);
});
it('should reject workspace at preceding IC with failed status', async () => {
// Sequence: Ic0 → Wc0 → Wc1
// WS_1 is in the WC segment, WS_2 is at Ic0:failed
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_2 at Ic0:failed — should be rejected
await seedWorkspaceMigration(context.dataSource, {
name: 'Ic0',
status: 'failed',
workspaceId: WS_2,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should reject workspace stuck in a previous WC segment (corrupted state)', async () => {
// Sequence: Wc0 → Wc1 → Ic0 → Wc2 → Wc3
// WS_1 is in segment [Wc2..Wc3], WS_2 is stuck at Wc0 (previous WC segment)
const sequence = [
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic0'),
makeWorkspace('Wc2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_1,
});
// WS_2 stuck at Wc0 in previous WC segment — corrupted
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should write workspace rows for IC failure, accept workspace created mid-failure, and succeed on restart', async () => {
// Sequence: Ic0 → Ic1 → Wc0 → Wc1
// First run: Ic1 fails → global + workspace rows written as failed
// WS_3 is created while Ic1 is failed → initial cursor at Ic1:failed
// Second run: Ic1 retries and succeeds → WC segment runs for all 3 workspaces
const failOnce = { shouldFail: true };
const ic1Command = {
up: async () => {
if (failOnce.shouldFail) {
failOnce.shouldFail = false;
throw new Error('Ic1 temporary failure');
}
},
down: async () => {},
};
const sequence = [
makeFastInstance('Ic0'),
{
...makeFastInstance('Ic1'),
command: ic1Command,
} as unknown as ReturnType<typeof makeFastInstance>,
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// First run — Ic1 fails
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow('Ic1 temporary failure');
const afterFailure = await testGetExecutedMigrationsInOrder(
context.dataSource,
);
expect(afterFailure.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
// Ic1 failed globally + workspace rows
'Ic1:instance:failed:1',
`Ic1:${WS_1}:failed:1`,
`Ic1:${WS_2}:failed:1`,
]);
// WS_3 created while Ic1 is failed —
// getInitialCursorForNewWorkspace returns { name: 'Ic1', status: 'failed' }
await seedWorkspaceMigration(context.dataSource, {
name: 'Ic1',
status: 'failed',
workspaceId: WS_3,
isInitial: true,
useCurrentTimestamp: true,
});
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
// Second run — Ic1 retries and succeeds, then WC segment runs for all 3
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2, WS_3],
},
});
expect(report.totalFailures).toBe(0);
const afterRetry = await testGetExecutedMigrationsInOrder(
context.dataSource,
);
expect(afterRetry.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
// First run — Ic1 failed
'Ic1:instance:failed:1',
`Ic1:${WS_1}:failed:1`,
`Ic1:${WS_2}:failed:1`,
// WS_3 created after Ic1 failure, initial cursor at Ic1:failed
`Ic1:${WS_3}:failed:1:initial`,
// Second run — Ic1 retry succeeds
'Ic1:instance:completed:2',
`Ic1:${WS_1}:completed:2`,
`Ic1:${WS_2}:completed:2`,
`Ic1:${WS_3}:completed:2`,
// WC segment runs for all 3 workspaces
`Wc0:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:completed:1`,
`Wc0:${WS_3}:completed:1`,
`Wc1:${WS_3}:completed:1`,
]);
});
});
@@ -18,6 +18,8 @@ import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/s
import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_EMPTY_WORKSPACE_3_ID,
SEED_EMPTY_WORKSPACE_4_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
@@ -31,6 +33,8 @@ config({
export const WS_1 = SEED_APPLE_WORKSPACE_ID;
export const WS_2 = SEED_YCOMBINATOR_WORKSPACE_ID;
export const WS_3 = SEED_EMPTY_WORKSPACE_3_ID;
export const WS_4 = SEED_EMPTY_WORKSPACE_4_ID;
const EXECUTED_BY_VERSION = '42.42.42';
@@ -208,31 +212,88 @@ export const resetSeedSequenceCounter = () => {
seedSequenceCounter = 0;
};
export const seedMigration = async (
export const seedInstanceMigration = async (
dataSource: DataSource,
{
name,
status,
workspaceId = null,
workspaceIds = [],
attempt = 1,
}: {
name: string;
status: 'completed' | 'failed';
workspaceId?: string | null;
workspaceIds?: string[];
attempt?: number;
},
) => {
// Seeds must have past timestamps so the runner's NOW()-based records
// always sort after them in createdAt order.
const createdAt = new Date(
Date.now() + seedSequenceCounter * 1000,
Date.now() - (1000000 - seedSequenceCounter * 1000),
).toISOString();
seedSequenceCounter++;
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6)`,
[name, status, attempt, EXECUTED_BY_VERSION, workspaceId, createdAt],
const values: string[] = [];
const args: unknown[] = [];
let paramIndex = 1;
values.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, NULL, $${paramIndex++}, false)`,
);
args.push(name, status, attempt, EXECUTED_BY_VERSION, createdAt);
for (const workspaceId of workspaceIds) {
values.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, false)`,
);
args.push(name, status, attempt, EXECUTED_BY_VERSION, workspaceId, createdAt);
}
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
VALUES ${values.join(', ')}`,
args,
);
};
export const seedWorkspaceMigration = async (
dataSource: DataSource,
{
name,
status,
workspaceId,
attempt = 1,
isInitial = false,
useCurrentTimestamp = false,
}: {
name: string;
status: 'completed' | 'failed';
workspaceId: string;
attempt?: number;
isInitial?: boolean;
useCurrentTimestamp?: boolean;
},
) => {
if (useCurrentTimestamp) {
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
VALUES ($1, $2, $3, $4, $5, $6)`,
[name, status, attempt, EXECUTED_BY_VERSION, workspaceId, isInitial],
);
} else {
const createdAt = new Date(
Date.now() - (1000000 - seedSequenceCounter * 1000),
).toISOString();
seedSequenceCounter++;
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[name, status, attempt, EXECUTED_BY_VERSION, workspaceId, createdAt, isInitial],
);
}
};
export const testCountMigrationsForCommand = async (
@@ -273,3 +334,34 @@ export const testGetLatestMigrationForCommand = async (
return rows.length > 0 ? rows[0] : null;
};
export type ExecutedMigrationRecord = {
name: string;
status: string;
attempt: number;
workspaceId: string | null;
isInitial: boolean;
};
export const testGetExecutedMigrationsInOrder = async (
dataSource: DataSource,
): Promise<ExecutedMigrationRecord[]> => {
return dataSource.query(
`SELECT name, status, attempt, "workspaceId", "isInitial"
FROM core."upgradeMigration"
ORDER BY "createdAt" ASC, "workspaceId" ASC NULLS FIRST, attempt ASC`,
);
};
export const migrationRecordToKey = ({
name,
workspaceId,
status,
attempt,
isInitial,
}: ExecutedMigrationRecord): string => {
const scope = workspaceId ?? 'instance';
const initial = isInitial ? ':initial' : '';
return `${name}:${scope}:${status}:${attempt}${initial}`;
};