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