Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code da86e5a147 Fix decryptText crash on pre-encryption secret application variables
https://sonarly.com/issue/26650?type=bug

The `decryptText` function in `auth.util.ts` crashes with "Invalid initialization vector" when attempting to decrypt an application variable value that was stored as plaintext (not encrypted), because the base64-decoded plaintext is shorter than the required 16-byte IV for AES-256-CTR.

Fix: Added input validation to `SecretEncryptionService` to prevent crashes when attempting to decrypt values that are not valid encrypted payloads (e.g., plaintext values stored before encryption was introduced in commit 7e3d9cd85a).

**Changes to `secret-encryption.service.ts`:**

1. Added `isValidEncryptedValue()` private method that checks if a base64-decoded value is at least 17 bytes (16-byte IV + minimum 1 byte ciphertext), which is the minimum for a valid AES-256-CTR encrypted payload.

2. In `decrypt()`: Before calling `decryptText`, validates the value. If invalid, logs a warning and returns the raw value instead of crashing. This protects callers like `build-env-var.ts` and `config-storage.service.ts`.

3. In `decryptAndMask()`: Before attempting decryption, validates the value. If invalid, logs a warning and returns just the mask string. This is the correct UX for the application settings page — showing `******` instead of crashing the entire FindOneApplication query.

The fix handles the root cause scenario where secret application variables were stored as plaintext before the encryption feature was deployed, and are now being read by the post-encryption code.
2026-04-15 15:05:53 +00:00
152 changed files with 1675 additions and 3304 deletions
@@ -3189,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "إغلاق"
@@ -3199,6 +3200,7 @@ 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 "إغلاق اللوحة الجانبية"
@@ -9279,6 +9281,7 @@ 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 "المزيد"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "Κλείσιμο"
@@ -3199,6 +3200,7 @@ 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 "Κλείσιμο πλευρικού πάνελ"
@@ -9279,6 +9281,7 @@ 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 "Περισσότερα"
@@ -10721,6 +10724,7 @@ 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,6 +3184,7 @@ 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"
@@ -3194,6 +3195,7 @@ 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"
@@ -9274,6 +9276,7 @@ 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"
@@ -10716,6 +10719,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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ää"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "סגור"
@@ -3199,6 +3200,7 @@ 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 "סגור את חלונית הצד"
@@ -9279,6 +9281,7 @@ 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 "עוד"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "閉じる"
@@ -3199,6 +3200,7 @@ 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 "サイドパネルを閉じる"
@@ -9279,6 +9281,7 @@ 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 "もっと"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "닫기"
@@ -3199,6 +3200,7 @@ 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 "사이드 패널 닫기"
@@ -9279,6 +9281,7 @@ 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 "더보기"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3184,7 @@ 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 ""
@@ -3194,6 +3195,7 @@ 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 ""
@@ -9274,6 +9276,7 @@ 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 ""
@@ -10716,6 +10719,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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 ""
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "Затвори"
@@ -3199,6 +3200,7 @@ 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 "Затвори бочни панел"
@@ -9279,6 +9281,7 @@ 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 "Још"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9281,6 +9283,7 @@ 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"
@@ -10723,6 +10726,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "Закрити"
@@ -3199,6 +3200,7 @@ 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 "Закрити бічну панель"
@@ -9279,6 +9281,7 @@ 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 "Більше"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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"
@@ -3199,6 +3200,7 @@ 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"
@@ -9279,6 +9281,7 @@ 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"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "关闭"
@@ -3199,6 +3200,7 @@ 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 "关闭侧边栏"
@@ -9279,6 +9281,7 @@ 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 "更多"
@@ -10721,6 +10724,7 @@ 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,6 +3189,7 @@ 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 "關閉"
@@ -3199,6 +3200,7 @@ 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 "關閉側邊面板"
@@ -9279,6 +9281,7 @@ 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 "更多"
@@ -10721,6 +10724,7 @@ 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 "開啟側邊面板"
@@ -29,12 +29,6 @@ export const CommandMenuItemErrorBoundary = ({
const { captureException } = await import('@sentry/react');
captureException(error, (scope) => {
scope.setTag('component', 'CommandMenuItem');
scope.setTag('commandMenuItemId', commandMenuItemId);
scope.setLevel('warning');
scope.setFingerprint([
'command-menu-item-error',
error.message,
]);
return scope;
});
@@ -11,14 +11,13 @@ 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 { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DROPDOWN_ID = 'command-menu-edit-record-selection-dropdown';
@@ -56,7 +55,6 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
isRecordPage = false,
}: CommandMenuItemEditRecordSelectionDropdownProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { closeDropdown } = useCloseDropdown();
const mainContextStoreHasSelectedRecords = useAtomStateValue(
@@ -94,17 +92,9 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
disabled={isRecordPage}
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<TriggerIcon
size={16}
color={theme.font.color.primary}
stroke={theme.icon.stroke.sm}
/>
<TriggerIcon size={16} />
<StyledLabel>{triggerLabel}</StyledLabel>
<IconChevronDown
size={16}
color={theme.font.color.primary}
stroke={theme.icon.stroke.sm}
/>
<IconChevronDown size={16} />
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
@@ -11,7 +11,7 @@ export const SeeVersionWorkflowRunSingleRecordCommand = () => {
!isDefined(selectedRecord) ||
!isDefined(selectedRecord?.workflowVersion?.id)
) {
return null;
throw new Error('Selected record is required to see version workflow run');
}
return (
@@ -1,5 +1,5 @@
import { styled } from '@linaria/react';
import { useDeferredValue, useState, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
@@ -25,15 +25,12 @@ 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={isExpandedForAnimation}
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
@@ -1,48 +1,15 @@
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,
pageLayoutEditingWidgetIdState,
store,
tabId,
widgetCreationTargetTabIdState,
widgetInsertionContextState,
]);
}, [navigatePageLayoutSidePanel]);
return { navigateToMoreWidgets };
};
@@ -7,12 +7,10 @@ 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';
@@ -42,17 +40,8 @@ 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,
@@ -60,8 +49,6 @@ export const usePageLayoutAddTabStrategy = ({
});
}, [
createPageLayoutTab,
isInSidePanel,
navigate,
setPageLayoutTabSettingsOpenTabId,
navigatePageLayoutSidePanel,
]);
@@ -15,7 +15,9 @@ 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';
@@ -74,47 +76,55 @@ export const useRefreshPageLayoutAfterReset = (
pageLayoutId,
);
const refreshPageLayoutAfterReset = useCallback(async () => {
const { data } = await client.query({
query: FindOnePageLayoutDocument,
variables: { id: pageLayoutId },
fetchPolicy: 'network-only',
});
const refreshPageLayoutAfterReset = useCallback(
async (collectAffectedViewIds: (layout: PageLayout) => Set<string>) => {
const { data } = await client.query({
query: FindOnePageLayoutDocument,
variables: { id: pageLayoutId },
fetchPolicy: 'network-only',
});
if (isDefined(data?.getPageLayout)) {
const freshLayout = transformPageLayout(data.getPageLayout);
let affectedViewIds = new Set<string>();
store.set(pageLayoutPersistedState, freshLayout);
store.set(pageLayoutDraftState, toDraftPageLayout(freshLayout));
store.set(
pageLayoutCurrentLayoutsState,
convertPageLayoutToTabLayouts(freshLayout),
);
}
if (isDefined(data?.getPageLayout)) {
const freshLayout = transformPageLayout(data.getPageLayout);
store.set(fieldsWidgetGroupsDraftState, {});
store.set(fieldsWidgetUngroupedFieldsDraftState, {});
store.set(fieldsWidgetEditorModeDraftState, {});
store.set(hasInitializedFieldsWidgetGroupsDraftState, {});
affectedViewIds = collectAffectedViewIds(freshLayout);
setIsPageLayoutInEditMode(false);
exitLayoutCustomizationMode();
invalidateMetadataStore();
}, [
client,
pageLayoutId,
store,
pageLayoutPersistedState,
pageLayoutDraftState,
pageLayoutCurrentLayoutsState,
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
hasInitializedFieldsWidgetGroupsDraftState,
setIsPageLayoutInEditMode,
exitLayoutCustomizationMode,
invalidateMetadataStore,
]);
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,
],
);
return { refreshPageLayoutAfterReset };
};
@@ -7,6 +7,7 @@ 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 = (
@@ -23,7 +24,11 @@ export const useResetPageLayoutTabToDefault = (
async (tabId: string) => {
try {
await resetMutation({ variables: { id: tabId } });
await refreshPageLayoutAfterReset();
await refreshPageLayoutAfterReset((layout) =>
collectViewIdsFromWidgets(
layout.tabs.find((tab) => tab.id === tabId)?.widgets ?? [],
),
);
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
@@ -10,6 +10,9 @@ 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 = () => {
@@ -20,10 +23,22 @@ export const useResetPageLayoutToDefault = () => {
const store = useStore();
const resetPageLayoutToDefault = useCallback(
async ({ pageLayoutId }: { pageLayoutId: string }) => {
async ({
pageLayoutId,
pageLayout,
}: {
pageLayoutId: string;
pageLayout: PageLayout;
}) => {
const preResetViewIds = collectViewIdsFromWidgets(
pageLayout.tabs.flatMap((tab) => tab.widgets),
);
try {
await resetMutation({ variables: { id: pageLayoutId } });
evictViewMetadataForViewIds(store, preResetViewIds);
if (isDefined(pageLayoutId)) {
store.set(
pageLayoutIsInitializedComponentState.atomFamily({
@@ -7,6 +7,7 @@ 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 = (
@@ -23,7 +24,13 @@ export const useResetPageLayoutWidgetToDefault = (
async (widgetId: string) => {
try {
await resetMutation({ variables: { id: widgetId } });
await refreshPageLayoutAfterReset();
await refreshPageLayoutAfterReset((layout) =>
collectViewIdsFromWidgets(
layout.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.id === widgetId),
),
);
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
@@ -1,11 +0,0 @@
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,
});
@@ -1,59 +0,0 @@
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 });
});
});
@@ -1,46 +0,0 @@
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,
});
});
});
@@ -1,52 +0,0 @@
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,
});
});
});
@@ -1,51 +0,0 @@
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,
);
});
});
@@ -0,0 +1,20 @@
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;
};
@@ -0,0 +1,31 @@
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,7 +36,6 @@ 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,6 +77,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
await resetPageLayoutToDefault({
pageLayoutId: pageLayout.id,
pageLayout,
});
};
@@ -1,165 +1,53 @@
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 {
AppTooltip,
IconLayoutSidebarRightExpand,
IconX,
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};
`;
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedIconCrossfade, useIsMobile } from 'twenty-ui/utilities';
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 (
<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>
<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}
/>
<StyledTooltipWrapper>
<AppTooltip
anchorSelect="#toggle-side-panel-button"
@@ -170,6 +58,6 @@ export const SidePanelToggleButton = () => {
noArrow
/>
</StyledTooltipWrapper>
</StyledButtonWrapper>
</div>
);
};
@@ -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,8 +41,11 @@ import {
} from '~/generated-metadata/graphql';
export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const { pageLayoutId, objectNameSingular: targetObjectNameSingular } =
usePageLayoutIdFromContextStore();
const {
pageLayoutId,
recordId,
objectNameSingular: targetObjectNameSingular,
} = usePageLayoutIdFromContextStore();
const { closeSidePanelMenu } = useSidePanelMenu();
@@ -69,9 +72,15 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
pageLayoutId,
);
const widgetCreationTargetTabId = useAtomComponentStateValue(
widgetCreationTargetTabIdComponentState,
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId,
layoutType: pageLayoutDraft.type,
targetRecordIdentifier: { id: recordId, targetObjectNameSingular: '' },
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
tabListInstanceId,
);
const store = useStore();
@@ -87,11 +96,13 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
targetObjectNameSingular,
);
const tabId = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId,
tabs: pageLayoutDraft.tabs,
widgetCreationTargetTabId,
});
const editingWidgetTab = isDefined(pageLayoutEditingWidgetId)
? pageLayoutDraft.tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
)
: undefined;
const tabId = editingWidgetTab?.id ?? activeTabId;
const isReplaceMode =
isDefined(pageLayoutEditingWidgetId) && !isDefined(widgetInsertionContext);
@@ -115,7 +126,11 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
}, [existingWidget]);
const removeExistingWidgetIfReplacing = useCallback(() => {
if (!isReplaceMode || !isDefined(pageLayoutEditingWidgetId)) {
if (
!isReplaceMode ||
!isDefined(pageLayoutEditingWidgetId) ||
!isDefined(tabId)
) {
return;
}
@@ -145,6 +160,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
);
const handleCreateFieldsWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
const viewId = uuidv4();
@@ -191,6 +210,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
]);
const handleCreateFieldWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -264,6 +287,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const handleCreateFrontComponentWidget = useCallback(
(frontComponent: FrontComponent) => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -14,7 +14,6 @@ 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,
@@ -32,8 +31,6 @@ export const SidePanelPageLayoutTabSettingsContent = ({
}: SidePanelPageLayoutTabSettingsContentProps) => {
const { closeSidePanelMenu } = useSidePanelMenu();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const pageLayoutDraft = useAtomComponentStateValue(
@@ -132,10 +129,7 @@ export const SidePanelPageLayoutTabSettingsContent = ({
onMoveLeft={() => moveLeft(tab.id)}
onMoveRight={() => moveRight(tab.id)}
onSetAsPinned={() => setAsPinnedTab(tab.id)}
onDuplicate={() => {
const newTabId = duplicateTab(tab.id);
navigate(`#${newTabId}`);
}}
onDuplicate={() => duplicateTab(tab.id)}
onResetToDefault={handleResetToDefault}
onDelete={handleDelete}
/>
@@ -1,73 +0,0 @@
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',
);
});
});
@@ -1,34 +0,0 @@
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,7 +19,6 @@ 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,
@@ -40,9 +39,9 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
duration?: number;
icon?: ReactNode;
message: string;
buttonLabel?: string;
buttonOnClick?: () => void;
buttonTo?: string;
actionText?: string;
actionOnClick?: () => void;
actionTo?: string;
detailedMessage?: string;
onCancel?: () => void;
onClose?: () => void;
@@ -57,9 +56,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]} ${themeCssVariables.spacing[2]}
${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[2]};
position: relative;
width: 296px;
@@ -71,15 +70,12 @@ 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`
@@ -116,15 +112,24 @@ const StyledDescription = styled.div`
width: 200px;
`;
const StyledBottomActionContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
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 StyledBottomAction = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[1]};
const StyledActionButton = styled.div`
padding-left: ${themeCssVariables.spacing[6]};
`;
const defaultAriaLabelByVariant: Record<
@@ -146,9 +151,9 @@ export const SnackBar = ({
id,
message,
detailedMessage,
buttonLabel,
buttonOnClick,
buttonTo,
actionText,
actionOnClick,
actionTo,
onCancel,
onClose,
role = 'status',
@@ -200,11 +205,15 @@ export const SnackBar = ({
}, [iconComponent, variant, i18n, theme.icon.size.md, theme.snackBar]);
const handleMouseEnter = () => {
progressAnimation?.pause();
if (progressAnimation?.state === 'running') {
progressAnimation.pause();
}
};
const handleMouseLeave = () => {
progressAnimation?.play();
if (progressAnimation?.state === 'paused') {
progressAnimation.play();
}
};
const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message);
@@ -242,21 +251,16 @@ export const SnackBar = ({
{isDefined(sanitizedDetailedMessage) && (
<StyledDescription>{sanitizedDetailedMessage}</StyledDescription>
)}
{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>
)}
{actionText && actionTo && (
<StyledLinkContainer>
<Link to={actionTo}>{actionText}</Link>
</StyledLinkContainer>
)}
{actionText && actionOnClick && !actionTo && (
<StyledActionButton>
<LightButton title={actionText} onClick={actionOnClick} />
</StyledActionButton>
)}
</StyledContainer>
);
};
@@ -58,9 +58,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
buttonLabel,
buttonOnClick,
buttonTo,
actionText,
actionOnClick,
actionTo,
}) => (
<motion.div
key={id}
@@ -78,9 +78,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
buttonLabel,
buttonOnClick,
buttonTo,
actionText,
actionOnClick,
actionTo,
}}
onClose={() => handleSnackBarClose(id)}
/>
@@ -41,33 +41,6 @@ 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(),
@@ -90,22 +63,3 @@ 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, 'buttonLabel' | 'buttonTo'> | null => {
): Pick<SnackBarOptions, 'actionText' | 'actionTo'> | null => {
if (!apolloError) {
return null;
}
@@ -16,8 +16,8 @@ export const buildErrorAction = (
if (isDefined(conflictingRecord)) {
return {
buttonLabel: t`View existing record`,
buttonTo: getAppPath(AppPath.RecordShowPage, {
actionText: t`View existing record`,
actionTo: getAppPath(AppPath.RecordShowPage, {
objectNameSingular: conflictingRecord.conflictingObjectNameSingular,
objectRecordId: conflictingRecord.conflictingRecordId,
}),
@@ -0,0 +1,178 @@
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,10 +4,7 @@ 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';
@@ -45,21 +42,12 @@ 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,9 +62,6 @@ export class RunInstanceCommandsCommand extends CommandRunner {
await this.checkWorkspaceVersionSafety(options);
await this.runLegacyPendingTypeOrmMigrations();
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
for (const {
command,
name,
@@ -82,6 +79,9 @@ 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: activeOrSuspendedWorkspaceIds.length === 0,
skipDataMigration: !hasWorkspaces,
});
if (result.status === 'failed') {
@@ -119,10 +119,10 @@ export class RunInstanceCommandsCommand extends CommandRunner {
return;
}
const activeOrSuspendedWorkspaceIds =
const activeWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
if (activeOrSuspendedWorkspaceIds.length === 0) {
if (activeWorkspaceIds.length === 0) {
return;
}
@@ -141,7 +141,7 @@ export class RunInstanceCommandsCommand extends CommandRunner {
const allAtPreviousVersion =
await this.upgradeMigrationService.areAllWorkspacesAtCommand({
commandName: lastWorkspaceCommand.name,
workspaceIds: activeOrSuspendedWorkspaceIds,
workspaceIds: activeWorkspaceIds,
});
if (!allAtPreviousVersion) {
@@ -36,9 +36,6 @@ 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"',
);
@@ -72,15 +69,9 @@ 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 { BackfillRecordPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command';
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 { 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: [
BackfillRecordPageLayoutsCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
UpdateGlobalObjectContextCommandMenuItemsCommand,
],
})
@@ -0,0 +1,833 @@
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}`,
);
}
}
@@ -1,534 +0,0 @@
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}`,
);
}
}
}
@@ -2202,11 +2202,6 @@ msgstr "Misluk"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Funksiekode is onveranderd."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Te veel rekords om gelyktydig op te dateer."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Te veel versoeke. Probeer asseblief later weer."
@@ -2202,11 +2202,6 @@ msgstr "فشل"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "لم تتغير شيفرة الدالة."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "عدد السجلات المراد تحديثها دفعة واحدة كبير جداً."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "عدد كبير جداً من الطلبات. يُرجى المحاولة لاحقاً."
@@ -2202,11 +2202,6 @@ msgstr "Error"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "El codi de la funció no ha canviat."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Massa registres per actualitzar d'una vegada."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Massa sol·licituds. Torna-ho a provar més tard."
@@ -2202,11 +2202,6 @@ msgstr "Selhalo"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Kód funkce se nezměnil."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Příliš mnoho záznamů k aktualizaci najednou."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Příliš mnoho požadavků. Zkuste to prosím později."
@@ -2202,11 +2202,6 @@ msgstr "Mislykkedes"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Funktionskoden er uændret."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "For mange poster at opdatere på én gang."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "For mange forespørgsler. Prøv igen senere."
@@ -2202,11 +2202,6 @@ msgstr "Fehlgeschlagen"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Funktionscode ist unverändert."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Zu viele Datensätze, um sie auf einmal zu aktualisieren."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
@@ -2202,11 +2202,6 @@ msgstr "Απέτυχε"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Ο κώδικας της συνάρτησης δεν έχει αλλάξει."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Πάρα πολλές εγγραφές για ενημέρωση ταυτόχρονα."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Πάρα πολλά αιτήματα. Δοκιμάστε ξανά αργότερα."
@@ -2197,11 +2197,6 @@ msgstr "Failed"
msgid "Failed Insufficient Permissions"
msgstr "Failed Insufficient Permissions"
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr "Failed to build function dependencies."
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2751,11 +2746,6 @@ msgstr "Function code failed to compile."
msgid "Function code is unchanged."
msgstr "Function code is unchanged."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr "Function execution failed."
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7230,7 +7220,6 @@ msgid "Too many records to update at once."
msgstr "Too many records to update at once."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Too many requests. Please try again later."
@@ -2202,11 +2202,6 @@ msgstr "Fallido"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "El código de la función no ha cambiado."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Demasiados registros para actualizar de una sola vez."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Demasiadas solicitudes. Por favor, inténtelo de nuevo más tarde."
@@ -2202,11 +2202,6 @@ msgstr "Epäonnistui"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Funktion koodia ei ole muutettu."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Liian monta tietuetta päivitettäväksi kerralla."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Liian monta pyyntöä. Yritä myöhemmin uudelleen."
@@ -2202,11 +2202,6 @@ msgstr "Échec"
msgid "Failed Insufficient Permissions"
msgstr ""
#. js-lingui-id: rl11Jd
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Failed to build function dependencies."
msgstr ""
#. js-lingui-id: E7Dsiq
#: src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts
msgid "Failed to create evaluation. Please try again."
@@ -2756,11 +2751,6 @@ msgstr ""
msgid "Function code is unchanged."
msgstr "Le code de la fonction est inchangé."
#. js-lingui-id: rqDhd1
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution failed."
msgstr ""
#. js-lingui-id: Ho3UAo
#: src/engine/metadata-modules/logic-function/logic-function.exception.ts
msgid "Function execution limit reached."
@@ -7235,7 +7225,6 @@ msgid "Too many records to update at once."
msgstr "Trop d'enregistrements à mettre à jour en une seule fois."
#. js-lingui-id: M68C8m
#: src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts
#: src/engine/core-modules/email-verification/email-verification.exception.ts
msgid "Too many requests. Please try again later."
msgstr "Trop de requêtes. Veuillez réessayer plus tard."
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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