Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d90b07cb | ||
|
|
b1838b3090 | ||
|
|
fa3d0cd4a6 | ||
|
|
652930e0ac | ||
|
|
d887fdc532 | ||
|
|
992a7ca12f | ||
|
|
a445f4a6fa | ||
|
|
20f7ba82d7 | ||
|
|
aa4aea0f9b |
+25
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000010',
|
||||
pageLayoutUniversalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000020',
|
||||
title: 'Extra Tab',
|
||||
position: 1000,
|
||||
icon: 'IconLayout',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000011',
|
||||
title: 'Extra Widget',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
'370ae182-743f-4ecb-b625-7ac48e21f0e5',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -13,6 +13,7 @@ Layout entities control how your app surfaces inside Twenty's UI — what lives
|
||||
| **View** | A saved list configuration for an object — visible fields, order, filters, groups | `defineView` |
|
||||
| **Navigation Menu Item** | An entry in the left sidebar that links to a view or an external URL | `defineNavigationMenuItem` |
|
||||
| **Page Layout** | The tabs and widgets that make up a record's detail page | `definePageLayout` |
|
||||
| **Page Layout Tab** | A standalone tab attached to an existing page layout (standard or your own app's) | `definePageLayoutTab` |
|
||||
|
||||
Views, navigation items, and page layouts reference each other by `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ Key points:
|
||||
- Each `widget` inside a tab can render a front component, a relation list, or other built-in widget types.
|
||||
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Add a tab to an existing page layout">
|
||||
|
||||
`definePageLayoutTab` lets your app attach a single tab — with optional widgets — to an **existing** page layout. The most common use case is adding a custom tab (for example, an analytics or AI summary tab) to one of Twenty's built-in record pages, or to a page layout your own app already ships.
|
||||
|
||||
The targeted page layout must be either a **standard** Twenty page layout or one defined by **your own app**; cross-app references to page layouts owned by another installed app are not supported today.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `pageLayoutUniversalIdentifier` is **required** when using `definePageLayoutTab` and must point to a page layout that already exists at install time (standard or your app's). When the parent page layout is missing, installation fails with a clear validation error.
|
||||
- `widgets` are scoped to this tab only — they reference front components, views, etc. exactly like widgets defined inline in `definePageLayout`.
|
||||
- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
|
||||
- Use this instead of `definePageLayout` when you only want to **add** to an existing layout. Use `definePageLayout` when you own the entire layout (typically a `RECORD_PAGE` for an object you ship in your app, or a `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ icon: table-columns
|
||||
|
||||
## مفاهيم التخطيط
|
||||
|
||||
| المفهوم | ما الذي يتحكّم فيه | كيان |
|
||||
| ---------------------- | ------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **عرض** | تكوين قائمة محفوظة لكائن — الحقول المرئية، والترتيب، وعوامل التصفية، والمجموعات | `defineView` |
|
||||
| **عنصر قائمة التنقّل** | عنصر في الشريط الجانبي الأيسر يرتبط بعرض أو بعنوان URL خارجي | `defineNavigationMenuItem` |
|
||||
| **تخطيط الصفحة** | علامات التبويب وعناصر الواجهة التي تشكّل صفحة تفاصيل السجل | `definePageLayout` |
|
||||
| المفهوم | ما الذي يتحكّم فيه | كيان |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **عرض** | تكوين قائمة محفوظة لكائن — الحقول المرئية، والترتيب، وعوامل التصفية، والمجموعات | `defineView` |
|
||||
| **عنصر قائمة التنقّل** | عنصر في الشريط الجانبي الأيسر يرتبط بعرض أو بعنوان URL خارجي | `defineNavigationMenuItem` |
|
||||
| **تخطيط الصفحة** | علامات التبويب وعناصر الواجهة التي تشكّل صفحة تفاصيل السجل | `definePageLayout` |
|
||||
| **علامة تبويب تخطيط الصفحة** | علامة تبويب مستقلة مرفقة بتخطيط صفحة موجودة (قياسي أو خاص بتطبيقك) | `definePageLayoutTab` |
|
||||
|
||||
تشير العروض، وعناصر التنقّل، وتخطيطات الصفحات إلى بعضها البعض عبر `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ export default definePageLayout({
|
||||
* يمكن لكل `widget` داخل لسان أن يعرض مكوّنًا أماميًا أو قائمة علاقات أو أنواع ويدجت مدمجة أخرى.
|
||||
* `position` على الألسنة يتحكّم في ترتيبها. استخدم قيمًا أعلى (مثل 50) لوضع الألسنة المخصّصة بعد الألسنة المدمجة.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="إضافة علامة تبويب إلى تخطيط صفحة موجود">
|
||||
|
||||
`definePageLayoutTab` يتيح لتطبيقك إرفاق علامة تبويب واحدة — مع عناصر واجهة اختيارية — إلى تخطيط صفحة **موجود**. أشيع حالات الاستخدام هي إضافة علامة تبويب مخصصة (على سبيل المثال، علامة تبويب للتحليلات أو ملخص الذكاء الاصطناعي) إلى إحدى صفحات السجل المضمنة في Twenty، أو إلى تخطيط صفحة يوفره تطبيقك بالفعل.
|
||||
|
||||
يجب أن يكون تخطيط الصفحة المستهدف إما تخطيط صفحة Twenty **قياسيًا** أو تخطيطًا مُعرَّفًا بواسطة **تطبيقك أنت**؛ المراجع عبر التطبيقات إلى تخطيطات صفحات مملوكة لتطبيق آخر مُثبّت غير مدعومة حاليًا.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
النقاط الرئيسية:
|
||||
* `pageLayoutUniversalIdentifier` **مطلوب** عند استخدام `definePageLayoutTab` ويجب أن يشير إلى تخطيط صفحة موجود بالفعل وقت التثبيت (سواء قياسيًا أو تابعًا لتطبيقك). عند فقدان تخطيط الصفحة الأب، يفشل التثبيت برسالة خطأ تحقق واضحة.
|
||||
* `widgets` نطاقها مقتصر على علامة التبويب هذه فقط — فهي تُشير إلى مكونات الواجهة الأمامية، والعروض، وما إلى ذلك تمامًا مثل عناصر الواجهة المعرّفة مضمّنة داخل `definePageLayout`.
|
||||
* `position` يتحكّم في الترتيب مقارنةً بعلامات التبويب الموجودة على التخطيط المستهدف. اختر قيمة تضع علامة التبويب الخاصة بك في الموضع الذي تريده بالنسبة إلى علامات التبويب المضمنة.
|
||||
* استخدم هذا بدلًا من `definePageLayout` عندما تريد فقط **الإضافة** إلى تخطيط موجود. استخدم `definePageLayout` عندما تملك التخطيط بأكمله (عادةً ما يكون `RECORD_PAGE` لكائن توفّره في تطبيقك، أو `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ Prvky rozvržení řídí, jak se vaše aplikace zobrazuje v uživatelském rozh
|
||||
|
||||
## Pojmy rozvržení
|
||||
|
||||
| Pojem | Co řídí | Entita |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------ | -------------------------- |
|
||||
| **Pohled** | Uložené nastavení seznamu pro objekt — viditelná pole, pořadí, filtry, skupiny | `defineView` |
|
||||
| **Položka navigační nabídky** | Položka v levém postranním panelu, která odkazuje na pohled nebo externí URL | `defineNavigationMenuItem` |
|
||||
| **Rozvržení stránky** | Karty a widgety, které tvoří stránku s podrobnostmi záznamu | `definePageLayout` |
|
||||
| Pojem | Co řídí | Entita |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **Pohled** | Uložené nastavení seznamu pro objekt — viditelná pole, pořadí, filtry, skupiny | `defineView` |
|
||||
| **Položka navigační nabídky** | Položka v levém postranním panelu, která odkazuje na pohled nebo externí URL | `defineNavigationMenuItem` |
|
||||
| **Rozvržení stránky** | Karty a widgety, které tvoří stránku s podrobnostmi záznamu | `definePageLayout` |
|
||||
| **Karta Rozložení stránky** | Samostatná karta připojená k existujícímu rozložení stránky (standardnímu nebo rozložení vaší vlastní aplikace) | `definePageLayoutTab` |
|
||||
|
||||
Pohledy, položky navigační nabídky a rozvržení stránek se na sebe odkazují pomocí `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ Hlavní body:
|
||||
* Každý `widget` uvnitř karty může vykreslit frontendovou komponentu, seznam relací nebo jiné vestavěné typy widgetů.
|
||||
* `position` na kartách určuje jejich pořadí. Použijte vyšší hodnoty (např. 50) pro umístění vlastních karet za vestavěné.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Přidat kartu do existujícího rozvržení stránky">
|
||||
|
||||
`definePageLayoutTab` umožňuje vaší aplikaci připojit jednu kartu — s volitelnými widgety — k **existujícímu** rozvržení stránky. Nejčastějším případem použití je přidání vlastní karty (například karty s analytikou nebo souhrnem AI) na jednu z vestavěných stránek záznamů Twenty nebo do rozvržení stránky, které vaše vlastní aplikace již dodává.
|
||||
|
||||
Cílové rozvržení stránky musí být buď **standardní** rozvržení stránky Twenty, nebo takové, které je definované **vaší vlastní aplikací**; křížové odkazy na rozvržení stránek, která vlastní jiná nainstalovaná aplikace, dnes nejsou podporovány.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Hlavní body:
|
||||
* `pageLayoutUniversalIdentifier` je při použití `definePageLayoutTab` **povinný** a musí odkazovat na rozvržení stránky, které již existuje v době instalace (standardní nebo vaší aplikace). Pokud nadřazené rozvržení stránky chybí, instalace selže s jasnou validační chybou.
|
||||
* `widgets` mají rozsah pouze pro tuto kartu — odkazují na frontendové komponenty, zobrazení apod. úplně stejně jako widgety definované přímo v `definePageLayout`.
|
||||
* `position` určuje pořadí vzhledem ke stávajícím kartám v cílovém rozvržení. Zvolte hodnotu, která umístí vaši kartu tam, kde ji chcete mít, relativně k vestavěným kartám.
|
||||
* Použijte to místo `definePageLayout`, když chcete pouze **přidat** do existujícího rozvržení. Použijte `definePageLayout`, když vlastníte celé rozvržení (typicky `RECORD_PAGE` pro objekt, který ve své aplikaci dodáváte, nebo `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ Layout-Entitäten steuern, wie Ihre App innerhalb der Benutzeroberfläche von Tw
|
||||
|
||||
## Layout-Konzepte
|
||||
|
||||
| Konzept | Was es steuert | Entität |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
|
||||
| **Ansicht** | Eine gespeicherte Listen-Konfiguration für ein Objekt — sichtbare Felder, Reihenfolge, Filter, Gruppen | `defineView` |
|
||||
| **Navigationsmenüeintrag** | Ein Eintrag in der linken Seitenleiste, der auf eine Ansicht oder eine externe URL verweist | `defineNavigationMenuItem` |
|
||||
| **Seitenlayout** | Die Tabs und Widgets, aus denen die Detailseite eines Datensatzes besteht | `definePageLayout` |
|
||||
| Konzept | Was es steuert | Entität |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **Ansicht** | Eine gespeicherte Listen-Konfiguration für ein Objekt — sichtbare Felder, Reihenfolge, Filter, Gruppen | `defineView` |
|
||||
| **Navigationsmenüeintrag** | Ein Eintrag in der linken Seitenleiste, der auf eine Ansicht oder eine externe URL verweist | `defineNavigationMenuItem` |
|
||||
| **Seitenlayout** | Die Tabs und Widgets, aus denen die Detailseite eines Datensatzes besteht | `definePageLayout` |
|
||||
| **Seitenlayout-Registerkarte** | Eine eigenständige Registerkarte, die an ein vorhandenes Seitenlayout angehängt ist (Standard oder das Ihrer eigenen App) | `definePageLayoutTab` |
|
||||
|
||||
Ansichten, Navigationsmenüeinträge und Seitenlayouts verweisen über `universalIdentifier` aufeinander:
|
||||
|
||||
@@ -127,5 +128,51 @@ Hauptpunkte:
|
||||
* Jedes `widget` innerhalb eines Tabs kann eine Frontend-Komponente, eine Relationenliste oder andere eingebaute Widget-Typen rendern.
|
||||
* `position` auf Tabs steuert deren Reihenfolge. Verwenden Sie höhere Werte (z. B. 50), um benutzerdefinierte Tabs hinter den integrierten zu platzieren.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Eine Registerkarte zu einem bestehenden Seitenlayout hinzufügen">
|
||||
|
||||
`definePageLayoutTab` ermöglicht es Ihrer App, eine einzelne Registerkarte — mit optionalen Widgets — an ein **bestehendes** Seitenlayout anzuhängen. Der häufigste Anwendungsfall ist das Hinzufügen einer benutzerdefinierten Registerkarte (z. B. einer Analytics- oder KI-Zusammenfassungs-Registerkarte) zu einer der in Twenty integrierten Datensatzseiten oder zu einem Seitenlayout, das Ihre eigene App bereits mitliefert.
|
||||
|
||||
Das Zielseitenlayout muss entweder ein **Standard**-Seitenlayout von Twenty sein oder eines, das von **Ihrer eigenen App** definiert wird; appübergreifende Verweise auf Seitenlayouts, die einer anderen installierten App gehören, werden derzeit nicht unterstützt.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
* `pageLayoutUniversalIdentifier` ist **erforderlich** bei der Verwendung von `definePageLayoutTab` und muss auf ein Seitenlayout verweisen, das zum Installationszeitpunkt bereits existiert (Standard oder das Ihrer App). Wenn das übergeordnete Seitenlayout fehlt, schlägt die Installation mit einem eindeutigen Validierungsfehler fehl.
|
||||
* `widgets` sind ausschließlich auf diese Registerkarte beschränkt — sie verweisen auf Frontend-Komponenten, Ansichten usw., genau wie Widgets, die inline in `definePageLayout` definiert sind.
|
||||
* `position` steuert die Reihenfolge im Zielseitenlayout relativ zu den vorhandenen Registerkarten. Wählen Sie einen Wert, der Ihre Registerkarte relativ zu integrierten Registerkarten an die gewünschte Position bringt.
|
||||
* Verwenden Sie dies anstelle von `definePageLayout`, wenn Sie einem vorhandenen Layout nur etwas **hinzufügen** möchten. Verwenden Sie `definePageLayout`, wenn Sie das gesamte Layout besitzen (typischerweise eine `RECORD_PAGE` für ein Objekt, das Sie in Ihrer App ausliefern, oder eine `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -13,6 +13,7 @@ Le entità di layout controllano come la tua app si presenta all'interno dell'in
|
||||
| **Vista** | Una configurazione di elenco salvata per un oggetto — campi visibili, ordine, filtri, gruppi | `defineView` |
|
||||
| **Voce del menu di navigazione** | Una voce nella barra laterale sinistra che collega a una vista o a un URL esterno | `defineNavigationMenuItem` |
|
||||
| **Layout di pagina** | Le schede e i widget che compongono la pagina dei dettagli di un record | `definePageLayout` |
|
||||
| **Scheda layout di pagina** | Una scheda indipendente associata a un layout di pagina esistente (standard o della tua app) | `definePageLayoutTab` |
|
||||
|
||||
Le viste, le voci del menu di navigazione e i layout di pagina fanno riferimento tra loro tramite `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ Punti chiave:
|
||||
* Ogni `widget` all'interno di una scheda può renderizzare un componente front-end, un elenco di relazioni o altri tipi di widget integrati.
|
||||
* `position` sulle schede controlla il loro ordine. Usa valori più alti (ad es., 50) per posizionare le schede personalizzate dopo quelle integrate.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Aggiungi una scheda a un layout di pagina esistente">
|
||||
|
||||
`definePageLayoutTab` consente alla tua app di aggiungere una singola scheda — con widget opzionali — a un layout di pagina **esistente**. Il caso d'uso più comune è aggiungere una scheda personalizzata (ad esempio, una scheda di analisi o di riepilogo IA) a una delle pagine record integrate di Twenty, oppure a un layout di pagina che la tua app fornisce già.
|
||||
|
||||
Il layout di pagina di destinazione deve essere o un layout di pagina Twenty **standard** oppure uno definito dalla **tua app**; i riferimenti tra app a layout di pagina di proprietà di un'altra app installata non sono attualmente supportati.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Punti chiave:
|
||||
* `pageLayoutUniversalIdentifier` è **obbligatorio** quando si utilizza `definePageLayoutTab` e deve puntare a un layout di pagina già esistente al momento dell'installazione (standard o della tua app). Quando il layout di pagina padre manca, l'installazione non va a buon fine e restituisce un chiaro errore di validazione.
|
||||
* `widgets` sono limitati solo a questa scheda — fanno riferimento a componenti front-end, viste, ecc. esattamente come i widget definiti inline in `definePageLayout`.
|
||||
* `position` controlla l'ordinamento rispetto alle schede esistenti nel layout di destinazione. Scegli un valore che collochi la tua scheda dove desideri rispetto alle schede integrate.
|
||||
* Usa questo invece di `definePageLayout` quando vuoi solo **aggiungere** a un layout esistente. Usa `definePageLayout` quando possiedi l'intero layout (in genere una `RECORD_PAGE` per un oggetto che distribuisci nella tua app, oppure una `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ As entidades de layout controlam como seu app aparece na UI do Twenty — o que
|
||||
|
||||
## Conceitos de layout
|
||||
|
||||
| Conceito | O que controla | Entidade |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **Vista** | Uma configuração de lista salva para um objeto — campos visíveis, ordem, filtros, grupos | `defineView` |
|
||||
| **Item do menu de navegação** | Uma entrada na barra lateral esquerda que aponta para uma vista ou uma URL externa | `defineNavigationMenuItem` |
|
||||
| **Layout da Página** | As abas e widgets que compõem a página de detalhes de um registro | `definePageLayout` |
|
||||
| Conceito | O que controla | Entidade |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **Vista** | Uma configuração de lista salva para um objeto — campos visíveis, ordem, filtros, grupos | `defineView` |
|
||||
| **Item do menu de navegação** | Uma entrada na barra lateral esquerda que aponta para uma vista ou uma URL externa | `defineNavigationMenuItem` |
|
||||
| **Layout da Página** | As abas e widgets que compõem a página de detalhes de um registro | `definePageLayout` |
|
||||
| **Aba Layout da Página** | Uma aba independente vinculada a um layout de página existente (padrão ou do seu próprio aplicativo) | `definePageLayoutTab` |
|
||||
|
||||
Vistas, itens de navegação e layouts de página referenciam-se mutuamente por `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ Pontos-chave:
|
||||
* Cada `widget` dentro de uma aba pode renderizar um componente de front-end, uma lista de relações ou outros tipos de widget incorporados.
|
||||
* `position` nas abas controla sua ordem. Use valores mais altos (por exemplo, 50) para colocar abas personalizadas após as nativas.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Adicionar uma aba a um layout de página existente">
|
||||
|
||||
`definePageLayoutTab` permite que seu app adicione uma única aba — com widgets opcionais — a um layout de página **existente**. O caso de uso mais comum é adicionar uma aba personalizada (por exemplo, uma aba de análises ou de resumo por IA) a uma das páginas de registro nativas do Twenty, ou a um layout de página que o seu próprio app já fornece.
|
||||
|
||||
O layout de página de destino deve ser um layout de página **padrão** do Twenty ou um definido pelo **seu próprio app**; referências entre apps a layouts de página pertencentes a outro app instalado não são compatíveis no momento.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Pontos-chave:
|
||||
* `pageLayoutUniversalIdentifier` é **obrigatório** ao usar `definePageLayoutTab` e deve apontar para um layout de página que já exista no momento da instalação (padrão ou do seu app). Quando o layout de página pai está ausente, a instalação falha com um erro de validação claro.
|
||||
* `widgets` têm escopo apenas para esta aba — eles referenciam componentes de interface, visualizações etc., exatamente como widgets definidos inline em `definePageLayout`.
|
||||
* `position` controla a ordenação em relação às abas existentes no layout de destino. Escolha um valor que posicione sua aba onde você deseja em relação às abas nativas.
|
||||
* Use isto em vez de `definePageLayout` quando você quiser apenas **adicionar** a um layout existente. Use `definePageLayout` quando você for o proprietário de todo o layout (normalmente uma `RECORD_PAGE` para um objeto que você fornece no seu app, ou uma `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ icon: table-columns
|
||||
|
||||
## Концепции макета
|
||||
|
||||
| Понятие | Что определяет | Сущность |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------ | -------------------------- |
|
||||
| **Представление** | Сохранённая конфигурация списка для объекта — видимые поля, порядок, фильтры, группы | `defineView` |
|
||||
| **Пункт меню навигации** | Элемент в левой боковой панели, который ссылается на представление или внешний URL | `defineNavigationMenuItem` |
|
||||
| **Макет страницы** | Вкладки и виджеты, из которых состоит страница сведений о записи | `definePageLayout` |
|
||||
| Понятие | Что определяет | Сущность |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------- |
|
||||
| **Представление** | Сохранённая конфигурация списка для объекта — видимые поля, порядок, фильтры, группы | `defineView` |
|
||||
| **Пункт меню навигации** | Элемент в левой боковой панели, который ссылается на представление или внешний URL | `defineNavigationMenuItem` |
|
||||
| **Макет страницы** | Вкладки и виджеты, из которых состоит страница сведений о записи | `definePageLayout` |
|
||||
| **Вкладка компоновки страницы** | Отдельная вкладка, прикреплённая к существующей компоновке страницы (стандартной или созданной в вашем приложении) | `definePageLayoutTab` |
|
||||
|
||||
Представления, пункты меню навигации и макеты страниц ссылаются друг на друга по `universalIdentifier`:
|
||||
|
||||
@@ -127,5 +128,51 @@ export default definePageLayout({
|
||||
* Каждый `widget` внутри вкладки может отображать компонент фронтенда, список связей или другие встроенные типы виджетов.
|
||||
* `position` у вкладок управляет их порядком. Используйте большие значения (например, 50), чтобы разместить пользовательские вкладки после встроенных.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Добавьте вкладку к существующему макету страницы.">
|
||||
|
||||
`definePageLayoutTab` позволяет вашему приложению прикрепить одну вкладку — с необязательными виджетами — к **существующему** макету страницы. Самый распространённый сценарий — добавление пользовательской вкладки (например, вкладки с аналитикой или сводкой ИИ) к одной из встроенных страниц записей Twenty или к макету страницы, который уже поставляется вашим приложением.
|
||||
|
||||
Целевой макет страницы должен быть либо **стандартным** макетом страницы Twenty, либо определённым **вашим собственным приложением**; ссылки между приложениями на макеты страниц, принадлежащие другому установленному приложению, пока не поддерживаются.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Основные моменты:
|
||||
* `pageLayoutUniversalIdentifier` является **обязательным** при использовании `definePageLayoutTab` и должен указывать на макет страницы, который уже существует на момент установки (стандартный или вашего приложения). Если родительский макет страницы отсутствует, установка завершается с понятной ошибкой проверки.
|
||||
* `widgets` ограничены только этой вкладкой — они ссылаются на компоненты фронтенда, представления и т. п. точно так же, как виджеты, определённые непосредственно в `definePageLayout`.
|
||||
* `position` управляет порядком относительно существующих вкладок в целевом макете. Выберите значение, которое поместит вашу вкладку в нужное место относительно встроенных вкладок.
|
||||
* Используйте это вместо `definePageLayout`, когда вы хотите только **добавить** к существующему макету. Используйте `definePageLayout`, когда вы владеете всем макетом (обычно это `RECORD_PAGE` для объекта, который вы поставляете в своём приложении, или `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -13,6 +13,7 @@ Düzen varlıkları, uygulamanızın Twenty arayüzünde nasıl göründüğün
|
||||
| **Görünüm** | Bir nesne için kaydedilmiş liste yapılandırması — görünür alanlar, sıralama, filtreler, gruplar | `defineView` |
|
||||
| **Gezinme Menüsü Öğesi** | Sol kenar çubuğunda, bir görünüme veya harici bir URL'ye bağlanan bir öğe | `defineNavigationMenuItem` |
|
||||
| **Sayfa Düzeni** | Bir kaydın ayrıntı sayfasını oluşturan sekmeler ve widget'lar | `definePageLayout` |
|
||||
| **Sayfa düzeni sekmesi** | Mevcut bir sayfa düzenine (standart veya kendi uygulamanıza ait) eklenen bağımsız bir sekme | `definePageLayoutTab` |
|
||||
|
||||
Görünümler, gezinme menüsü öğeleri ve sayfa düzenleri birbirlerine `universalIdentifier` ile başvurur:
|
||||
|
||||
@@ -127,5 +128,51 @@ export default definePageLayout({
|
||||
* Bir sekmenin içindeki her `widget`, bir ön uç bileşeni, bir ilişki listesi veya diğer yerleşik widget türlerini oluşturabilir.
|
||||
* Sekmelerdeki `position`, sıralarını kontrol eder. Özel sekmeleri yerleşik olanların sonrasına yerleştirmek için daha yüksek değerler kullanın (ör. 50).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Mevcut bir sayfa düzenine bir sekme ekleyin">
|
||||
|
||||
`definePageLayoutTab` uygulamanızın tek bir sekmeyi — isteğe bağlı widget'larla — **mevcut** bir sayfa düzenine eklemesine olanak tanır. En yaygın kullanım örneği, Twenty'nin yerleşik kayıt sayfalarından birine (örneğin, bir analitik veya yapay zekâ özet sekmesi) özel bir sekme eklemek ya da kendi uygulamanızın zaten sunduğu bir sayfa düzenine eklemektir.
|
||||
|
||||
Hedeflenen sayfa düzeni ya **standart** bir Twenty sayfa düzeni ya da **kendi uygulamanız** tarafından tanımlanan bir düzen olmalıdır; yüklü başka bir uygulamaya ait sayfa düzenlerine uygulamalar arası referanslar şu anda desteklenmemektedir.
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Önemli noktalar:
|
||||
* `pageLayoutUniversalIdentifier`, `definePageLayoutTab` kullanılırken **zorunludur** ve kurulum sırasında (standart veya uygulamanızınki) zaten var olan bir sayfa düzenini işaret etmelidir. Üst sayfa düzeni eksikse, kurulum belirgin bir doğrulama hatasıyla başarısız olur.
|
||||
* `widgets` yalnızca bu sekmeyle sınırlıdır — satır içi olarak `definePageLayout` içinde tanımlanan widget'larda olduğu gibi, ön uç bileşenlerine, görünümlere vb. tam olarak aynı şekilde referans verirler.
|
||||
* `position`, hedeflenen düzende mevcut sekmelere göre sıralamayı kontrol eder. Yerleşik sekmelere göre sekmenizi istediğiniz konuma yerleştirecek bir değer seçin.
|
||||
* Yalnızca mevcut bir düzene **ekleme** yapmak istediğinizde `definePageLayout` yerine bunu kullanın. Tüm düzen size ait olduğunda `definePageLayout` kullanın (genellikle uygulamanızda sunduğunuz bir nesne için bir `RECORD_PAGE` veya bir `STANDALONE_PAGE`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -8,11 +8,12 @@ icon: table-columns
|
||||
|
||||
## 布局概念
|
||||
|
||||
| 概念 | 控制内容 | 实体 |
|
||||
| --------- | -------------------------- | -------------------------- |
|
||||
| **视图** | 对象的已保存列表配置——可见字段、顺序、筛选器、分组 | `defineView` |
|
||||
| **导航菜单项** | 左侧侧边栏中的一项,链接到某个视图或外部 URL | `defineNavigationMenuItem` |
|
||||
| **页面布局** | 构成记录详情页的选项卡和小部件 | `definePageLayout` |
|
||||
| 概念 | 控制内容 | 实体 |
|
||||
| ----------- | ----------------------------------- | -------------------------- |
|
||||
| **视图** | 对象的已保存列表配置——可见字段、顺序、筛选器、分组 | `defineView` |
|
||||
| **导航菜单项** | 左侧侧边栏中的一项,链接到某个视图或外部 URL | `defineNavigationMenuItem` |
|
||||
| **页面布局** | 构成记录详情页的选项卡和小部件 | `definePageLayout` |
|
||||
| **页面布局选项卡** | 附加到现有页面布局(标准页面布局或你自己的应用的页面布局)的独立选项卡 | `definePageLayoutTab` |
|
||||
|
||||
视图、导航菜单项和页面布局通过 `universalIdentifier` 相互引用:
|
||||
|
||||
@@ -127,5 +128,51 @@ export default definePageLayout({
|
||||
* 选项卡内的每个 `widget` 可以渲染一个前端组件、关系列表或其他内置小部件类型。
|
||||
* 选项卡上的 `position` 控制其顺序。 使用更高的值(例如 50)可将自定义选项卡放在内置选项卡之后。
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="向现有页面布局添加一个选项卡">
|
||||
|
||||
`definePageLayoutTab` 允许你的应用将单个选项卡 — 可选小部件 — 附加到一个**现有**页面布局。 最常见的用例是向 Twenty 内置的某个记录页面添加自定义选项卡(例如,分析或 AI 摘要选项卡),或向你的应用已随附的页面布局添加该选项卡。
|
||||
|
||||
目标页面布局必须是 **标准** 的 Twenty 页面布局,或由 **你自己的应用** 定义的布局;目前不支持跨应用引用由其他已安装应用拥有的页面布局。
|
||||
|
||||
```ts src/page-layouts/example-extra-tab.ts
|
||||
import {
|
||||
definePageLayoutTab,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-sdk/define';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||||
|
||||
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-ab01-4001-8001-c0aba11c0100';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
|
||||
pageLayoutUniversalIdentifier:
|
||||
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Hello World',
|
||||
position: 1000,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
关键点:
|
||||
* `pageLayoutUniversalIdentifier` 在使用 `definePageLayoutTab` 时是**必需**的,并且必须指向在安装时已存在的页面布局(标准布局或你的应用的布局)。 当父页面布局缺失时,安装会失败,并给出清晰的验证错误。
|
||||
* `widgets` 仅作用于此选项卡 — 它们引用前端组件、视图等,其方式与在 `definePageLayout` 中内联定义的小部件完全相同。
|
||||
* `position` 控制目标布局中相对于现有选项卡的排序。 选择一个取值,使你的选项卡相对于内置选项卡位于你想要的位置。
|
||||
* 当你只想向现有布局进行**添加**时,请使用此功能,而不是 `definePageLayout`。 当你拥有整个布局时,请使用 `definePageLayout`(通常是你在应用中提供的对象的 `RECORD_PAGE`,或 `STANDALONE_PAGE`)。
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCheck, IconPaint } from 'twenty-ui/display';
|
||||
import { GRAY_SCALE_LIGHT } from 'twenty-ui/theme';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { LayoutCustomizationBarMenuDropdown } from '@/layout-customization/components/LayoutCustomizationBarMenuDropdown';
|
||||
@@ -24,11 +25,20 @@ const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.color.blue};
|
||||
box-sizing: border-box;
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
color: ${GRAY_SCALE_LIGHT.gray1};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]};
|
||||
width: 100%;
|
||||
|
||||
button,
|
||||
button * {
|
||||
color: ${GRAY_SCALE_LIGHT.gray1};
|
||||
}
|
||||
|
||||
button[type='submit']:not(:disabled):not(:focus) {
|
||||
border-color: color(display-p3 1 1 1 / 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLeftSection = styled.div`
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { IconDotsVertical, IconReload } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { GRAY_SCALE_LIGHT } from 'twenty-ui/theme';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { LAYOUT_CUSTOMIZATION_BAR_DROPDOWN_ID } from '@/layout-customization/constants/LayoutCustomizationBarDropdownId';
|
||||
@@ -19,7 +20,7 @@ const StyledInvertedIconButtonWrapper = styled.span`
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
color: ${GRAY_SCALE_LIGHT.gray1};
|
||||
}
|
||||
|
||||
button:hover {
|
||||
|
||||
+1
@@ -315,6 +315,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
roles: [
|
||||
{
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
|
||||
|
||||
+23
@@ -4,6 +4,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
NavigationMenuItemType,
|
||||
PageLayoutTabLayoutMode,
|
||||
RelationOnDeleteAction,
|
||||
RelationType,
|
||||
ViewCalendarLayout,
|
||||
@@ -12,6 +13,28 @@ import {
|
||||
|
||||
export const EXPECTED_MANIFEST: Manifest = {
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000010',
|
||||
pageLayoutUniversalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000020',
|
||||
title: 'Extra Tab',
|
||||
position: 1000,
|
||||
icon: 'IconLayout',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000011',
|
||||
title: 'Extra Widget',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
'370ae182-743f-4ecb-b625-7ac48e21f0e5',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
publicAssets: [
|
||||
{
|
||||
checksum: '99496069dcc2a1488e1cae9f826d2707',
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ export const defineManifestTests = (appPath: string): void => {
|
||||
expect(manifest.fields).toHaveLength(23);
|
||||
expect(manifest.views).toHaveLength(5);
|
||||
expect(manifest.navigationMenuItems).toHaveLength(3);
|
||||
expect(manifest.pageLayoutTabs).toHaveLength(1);
|
||||
|
||||
expect(normalizeManifestForComparison(manifest)).toEqual(
|
||||
normalizeManifestForComparison(EXPECTED_MANIFEST),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const normalizeManifestForComparison = <T extends Manifest>(
|
||||
views: sortById(manifest.views),
|
||||
navigationMenuItems: sortById(manifest.navigationMenuItems),
|
||||
pageLayouts: sortById(manifest.pageLayouts),
|
||||
pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
|
||||
logicFunctions: sortById(
|
||||
manifest.logicFunctions?.map((fn) => ({
|
||||
...fn,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-fu
|
||||
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
|
||||
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
|
||||
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
|
||||
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
|
||||
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
|
||||
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
|
||||
import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template';
|
||||
@@ -189,6 +190,15 @@ export class EntityAddCommand {
|
||||
return { name, file };
|
||||
}
|
||||
|
||||
case SyncableEntity.PageLayoutTab: {
|
||||
const name = await this.getEntityName(entity);
|
||||
|
||||
const file = getPageLayoutTabBaseFile({
|
||||
name,
|
||||
});
|
||||
return { name, file };
|
||||
}
|
||||
|
||||
default:
|
||||
assertUnreachable(entity);
|
||||
}
|
||||
|
||||
+1
@@ -37,6 +37,7 @@ const validManifest: Manifest = {
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
};
|
||||
|
||||
describe('manifestValidate', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
|
||||
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
|
||||
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
|
||||
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
|
||||
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
|
||||
import { type ViewConfig } from '@/sdk/define/views/view-config';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, extname, relative } from 'path';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
type NavigationMenuItemManifest,
|
||||
type ObjectManifest,
|
||||
type PageLayoutManifest,
|
||||
type PageLayoutTabManifest,
|
||||
type RoleManifest,
|
||||
type SkillManifest,
|
||||
type ViewManifest,
|
||||
@@ -81,6 +83,7 @@ export const buildManifest = async (
|
||||
const views: ViewManifest[] = [];
|
||||
const navigationMenuItems: NavigationMenuItemManifest[] = [];
|
||||
const pageLayouts: PageLayoutManifest[] = [];
|
||||
const pageLayoutTabs: PageLayoutTabManifest[] = [];
|
||||
const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] =
|
||||
[];
|
||||
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
|
||||
@@ -97,6 +100,7 @@ export const buildManifest = async (
|
||||
const viewsFilePaths: string[] = [];
|
||||
const navigationMenuItemsFilePaths: string[] = [];
|
||||
const pageLayoutsFilePaths: string[] = [];
|
||||
const pageLayoutTabsFilePaths: string[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
@@ -331,6 +335,21 @@ export const buildManifest = async (
|
||||
pageLayoutsFilePaths.push(relativePath);
|
||||
break;
|
||||
}
|
||||
case ManifestEntityKey.PageLayoutTabs: {
|
||||
const extract = await extractManifestFromFile<PageLayoutTabConfig>({
|
||||
appPath,
|
||||
filePath,
|
||||
});
|
||||
|
||||
const pageLayoutTabManifest: PageLayoutTabManifest = {
|
||||
...extract.config,
|
||||
};
|
||||
|
||||
pageLayoutTabs.push(pageLayoutTabManifest);
|
||||
errors.push(...extract.errors);
|
||||
pageLayoutTabsFilePaths.push(relativePath);
|
||||
break;
|
||||
}
|
||||
case ManifestEntityKey.PublicAssets: {
|
||||
// Public assets are handled below
|
||||
break;
|
||||
@@ -407,6 +426,7 @@ export const buildManifest = async (
|
||||
views: views.sort(byId),
|
||||
navigationMenuItems: navigationMenuItems.sort(byId),
|
||||
pageLayouts: pageLayouts.sort(byId),
|
||||
pageLayoutTabs: pageLayoutTabs.sort(byId),
|
||||
};
|
||||
|
||||
const entityFilePaths: EntityFilePaths = {
|
||||
@@ -422,6 +442,7 @@ export const buildManifest = async (
|
||||
views: viewsFilePaths,
|
||||
navigationMenuItems: navigationMenuItemsFilePaths,
|
||||
pageLayouts: pageLayoutsFilePaths,
|
||||
pageLayoutTabs: pageLayoutTabsFilePaths,
|
||||
};
|
||||
|
||||
return { manifest, filePaths: entityFilePaths, errors };
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum TargetFunction {
|
||||
DefineView = 'defineView',
|
||||
DefineNavigationMenuItem = 'defineNavigationMenuItem',
|
||||
DefinePageLayout = 'definePageLayout',
|
||||
DefinePageLayoutTab = 'definePageLayoutTab',
|
||||
}
|
||||
|
||||
export enum ManifestEntityKey {
|
||||
@@ -29,6 +30,7 @@ export enum ManifestEntityKey {
|
||||
Views = 'views',
|
||||
NavigationMenuItems = 'navigationMenuItems',
|
||||
PageLayouts = 'pageLayouts',
|
||||
PageLayoutTabs = 'pageLayoutTabs',
|
||||
}
|
||||
|
||||
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
|
||||
@@ -53,6 +55,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
|
||||
[TargetFunction.DefineNavigationMenuItem]:
|
||||
ManifestEntityKey.NavigationMenuItems,
|
||||
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
|
||||
[TargetFunction.DefinePageLayoutTab]: ManifestEntityKey.PageLayoutTabs,
|
||||
};
|
||||
|
||||
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
|
||||
|
||||
@@ -74,6 +74,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
|
||||
views: SyncableEntity.View,
|
||||
navigationMenuItems: SyncableEntity.NavigationMenuItem,
|
||||
pageLayouts: SyncableEntity.PageLayout,
|
||||
pageLayoutTabs: SyncableEntity.PageLayoutTab,
|
||||
};
|
||||
|
||||
const MAX_EVENT_COUNT = 200;
|
||||
|
||||
@@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.View]: 'Views',
|
||||
[SyncableEntity.NavigationMenuItem]: 'Navigation menu items',
|
||||
[SyncableEntity.PageLayout]: 'Page layouts',
|
||||
[SyncableEntity.PageLayoutTab]: 'Page layout tabs',
|
||||
[SyncableEntity.Agent]: 'Agents',
|
||||
};
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
|
||||
|
||||
describe('getPageLayoutTabBaseFile', () => {
|
||||
it('should render proper file using definePageLayoutTab', () => {
|
||||
const result = getPageLayoutTabBaseFile({
|
||||
name: 'My Custom Tab',
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
"import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';",
|
||||
);
|
||||
expect(result).toContain('export default definePageLayoutTab({');
|
||||
expect(result).toContain("title: 'My Custom Tab'");
|
||||
expect(result).toContain('pageLayoutUniversalIdentifier:');
|
||||
expect(result).toContain('widgets: []');
|
||||
expect(result).toContain('layoutMode: PageLayoutTabLayoutMode.CANVAS');
|
||||
});
|
||||
|
||||
it('should generate a valid UUID for the tab', () => {
|
||||
const result = getPageLayoutTabBaseFile({
|
||||
name: 'tab',
|
||||
});
|
||||
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/g;
|
||||
const matches = result.match(uuidRegex);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs across calls', () => {
|
||||
const result1 = getPageLayoutTabBaseFile({ name: 'tab-1' });
|
||||
const result2 = getPageLayoutTabBaseFile({ name: 'tab-2' });
|
||||
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const uuid1 = result1.match(uuidRegex)?.[1];
|
||||
const uuid2 = result2.match(uuidRegex)?.[1];
|
||||
|
||||
expect(uuid1).toBeDefined();
|
||||
expect(uuid2).toBeDefined();
|
||||
expect(uuid1).not.toBe(uuid2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getPageLayoutTabBaseFile = ({ name }: { name: string }) => {
|
||||
return `import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayoutTab({
|
||||
universalIdentifier: '${uuidv4()}',
|
||||
pageLayoutUniversalIdentifier: 'replace-with-existing-page-layout-uuid',
|
||||
title: '${name}',
|
||||
position: 1000,
|
||||
icon: 'IconLayout',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [],
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { type FrontComponentConfig } from '@/sdk/define/front-component/front-co
|
||||
import { type LogicFunctionConfig } from '@/sdk/define/logic-functions/logic-function-config';
|
||||
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
|
||||
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
|
||||
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
|
||||
import { type ViewConfig } from '@/sdk/define/views/view-config';
|
||||
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
|
||||
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
|
||||
@@ -33,7 +34,8 @@ export type DefinableEntity =
|
||||
| SkillManifest
|
||||
| ViewConfig
|
||||
| NavigationMenuItemManifest
|
||||
| PageLayoutConfig;
|
||||
| PageLayoutConfig
|
||||
| PageLayoutTabConfig;
|
||||
|
||||
export type DefineEntity<T extends DefinableEntity = DefinableEntity> = (
|
||||
config: T,
|
||||
|
||||
@@ -70,7 +70,14 @@ export {
|
||||
} from '@/sdk/define/objects/standard-object-ids';
|
||||
|
||||
export { definePageLayout } from '@/sdk/define/page-layouts/define-page-layout';
|
||||
export { definePageLayoutTab } from '@/sdk/define/page-layouts/define-page-layout-tab';
|
||||
export type { PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
|
||||
export type { PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
|
||||
export type {
|
||||
PageLayoutManifest,
|
||||
PageLayoutTabManifest,
|
||||
PageLayoutWidgetManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export { defineRole } from '@/sdk/define/roles/define-role';
|
||||
export { PermissionFlag } from '@/sdk/define/roles/permission-flag-type';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
|
||||
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
|
||||
|
||||
export const definePageLayoutTab: DefineEntity<PageLayoutTabConfig> = (
|
||||
config,
|
||||
) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('PageLayoutTab must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.title) {
|
||||
errors.push('PageLayoutTab must have a title');
|
||||
}
|
||||
|
||||
if (!config.pageLayoutUniversalIdentifier) {
|
||||
errors.push(
|
||||
'PageLayoutTab must have a pageLayoutUniversalIdentifier when defined standalone (use the parent page layout universalIdentifier)',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.widgets) {
|
||||
for (const widget of config.widgets) {
|
||||
if (!widget.universalIdentifier) {
|
||||
errors.push('PageLayoutWidget must have a universalIdentifier');
|
||||
}
|
||||
if (!widget.title) {
|
||||
errors.push('PageLayoutWidget must have a title');
|
||||
}
|
||||
if (!widget.type) {
|
||||
errors.push('PageLayoutWidget must have a type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createValidationResult({ config, errors });
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type PageLayoutTabManifest } from 'twenty-shared/application';
|
||||
|
||||
export type PageLayoutTabConfig = Omit<
|
||||
PageLayoutTabManifest,
|
||||
'pageLayoutUniversalIdentifier'
|
||||
> & {
|
||||
pageLayoutUniversalIdentifier: string;
|
||||
};
|
||||
+1
@@ -47,6 +47,7 @@ describe('formatPgCopyField', () => {
|
||||
it('should format PostgreSQL array literals', () => {
|
||||
expect(formatPgCopyField([])).toBe('{}');
|
||||
expect(formatPgCopyField(['a', 'b'])).toBe('{"a","b"}');
|
||||
expect(formatPgCopyField(['val\twith\ttab'])).toBe('{"val\\twith\\ttab"}');
|
||||
});
|
||||
|
||||
it('should JSON-serialize arrays of objects with escaping', () => {
|
||||
|
||||
+4
-2
@@ -38,9 +38,11 @@ export const formatPgCopyField = (
|
||||
const formattedElements = value.map((element) => {
|
||||
if (!isDefined(element)) return 'NULL';
|
||||
|
||||
const stringElement = String(element);
|
||||
const escapedElement = stringElement
|
||||
const escapedElement = String(element)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
return `"${escapedElement}"`;
|
||||
|
||||
-1
@@ -2,7 +2,6 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { escapeLiteral } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
|
||||
export const formatSqlValue = (
|
||||
value: unknown,
|
||||
isJsonColumn = false,
|
||||
|
||||
+7
-8
@@ -21,10 +21,9 @@ import { getCoreEntityMetadatasWithWorkspaceId } from 'src/database/commands/wor
|
||||
import { generateWorkspaceSchemaDdl } from 'src/database/commands/workspace-export/utils/generate-workspace-schema-ddl.util';
|
||||
import { buildInsertPrefix } from 'src/database/commands/workspace-export/utils/build-insert-prefix.util';
|
||||
import { buildWorkspaceTableColumnSets } from 'src/database/commands/workspace-export/utils/build-workspace-table-column-sets.util';
|
||||
import {
|
||||
formatSqlValue,
|
||||
} from 'src/database/commands/workspace-export/utils/format-sql-value.util';
|
||||
import { formatSqlValue } from 'src/database/commands/workspace-export/utils/format-sql-value.util';
|
||||
import { formatPgCopyField } from './utils/format-pg-copy-value.util';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
@@ -176,7 +175,7 @@ export class WorkspaceExportService {
|
||||
whereClause: '"workspaceId" = $1',
|
||||
queryParameters: [workspaceId],
|
||||
jsonColumns: this.buildJsonColumnSet(entityMetadata),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`${entityMetadata.tableName}: skipped`, error);
|
||||
}
|
||||
@@ -231,7 +230,7 @@ export class WorkspaceExportService {
|
||||
queryParameters,
|
||||
);
|
||||
|
||||
if (rows.length === 0) break;
|
||||
if (!isNonEmptyArray(rows)) break;
|
||||
|
||||
if (!columnNames) {
|
||||
columnNames = Object.keys(rows[0]).filter(
|
||||
@@ -274,7 +273,7 @@ export class WorkspaceExportService {
|
||||
stream,
|
||||
jsonColumns,
|
||||
excludedColumns,
|
||||
}: Omit<WriteRowsOptions, 'onConflictDoNothing'>): Promise<void> {
|
||||
}: Omit<WriteRowsOptions, 'whereClause' | 'queryParameters'>): Promise<void> {
|
||||
let columnNames: string[] | undefined;
|
||||
let totalRows = 0;
|
||||
|
||||
@@ -283,7 +282,7 @@ export class WorkspaceExportService {
|
||||
`SELECT * FROM "${schemaName}"."${tableName}" ORDER BY "id" LIMIT ${BATCH_SIZE} OFFSET ${offset}`,
|
||||
);
|
||||
|
||||
if (rows.length === 0) break;
|
||||
if (!isNonEmptyArray(rows)) break;
|
||||
|
||||
if (!columnNames) {
|
||||
columnNames = Object.keys(rows[0]).filter(
|
||||
@@ -312,7 +311,7 @@ export class WorkspaceExportService {
|
||||
if (rows.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
if (columnNames) {
|
||||
if (isNonEmptyArray(columnNames)) {
|
||||
stream.write('\\.\n\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/co
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AdminResolver } from 'src/engine/api/graphql/graphql-config/decorators/admin-resolver.decorator';
|
||||
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
|
||||
@@ -98,6 +98,7 @@ export class AdminPanelResolver {
|
||||
private featureFlagService: FeatureFlagService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly defaultAiCatalogService: DefaultAiCatalogService,
|
||||
private readonly modelsDevCatalogService: ModelsDevCatalogService,
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
@@ -424,7 +425,7 @@ export class AdminPanelResolver {
|
||||
const providers =
|
||||
this.aiModelRegistryService.getResolvedProvidersForAdmin();
|
||||
const catalogNames = this.aiModelRegistryService.getCatalogProviderNames();
|
||||
const rawCatalog = loadDefaultAiProviders();
|
||||
const rawCatalog = this.defaultAiCatalogService.getDefaultAiCatalog();
|
||||
const masked: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const [key, config] of Object.entries(providers)) {
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService {
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
+37
@@ -397,5 +397,42 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
|
||||
}
|
||||
}
|
||||
|
||||
for (const pageLayoutTabManifest of manifest.pageLayoutTabs ?? []) {
|
||||
if (!isDefined(pageLayoutTabManifest.pageLayoutUniversalIdentifier)) {
|
||||
throw new Error(
|
||||
`Top-level pageLayoutTab "${pageLayoutTabManifest.universalIdentifier}" is missing required pageLayoutUniversalIdentifier`,
|
||||
);
|
||||
}
|
||||
|
||||
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
|
||||
universalFlatEntity:
|
||||
fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
|
||||
pageLayoutTabManifest,
|
||||
pageLayoutUniversalIdentifier:
|
||||
pageLayoutTabManifest.pageLayoutUniversalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
universalFlatEntityMapsToMutate:
|
||||
allUniversalFlatEntityMaps.flatPageLayoutTabMaps,
|
||||
});
|
||||
|
||||
for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ??
|
||||
[]) {
|
||||
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
|
||||
universalFlatEntity:
|
||||
fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
|
||||
pageLayoutWidgetManifest,
|
||||
pageLayoutTabUniversalIdentifier:
|
||||
pageLayoutTabManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
universalFlatEntityMapsToMutate:
|
||||
allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allUniversalFlatEntityMaps;
|
||||
};
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ const buildMinimalManifest = (
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
describe('resolveManifestAssetUrls', () => {
|
||||
|
||||
@@ -1393,6 +1393,15 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
AI_PROVIDERS: AiProvidersConfig = {};
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
'Storage path for the AI catalog override (e.g. config/ai-catalog.json). When set, the catalog is fetched from the configured storage backend at startup instead of using the built-in ai-providers.json.',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
AI_CATALOG_STORAGE_PATH?: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import defaultAiProviders from 'src/engine/metadata-modules/ai/ai-models/ai-providers.json';
|
||||
import { aiProvidersConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.schema';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
|
||||
import { normalizeAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/normalize-ai-providers.util';
|
||||
|
||||
const PROVIDERS = normalizeAiProviders(defaultAiProviders as AiProvidersConfig);
|
||||
|
||||
const EXPECTED_PROVIDER_NAMES = [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'google',
|
||||
'xai',
|
||||
'mistral',
|
||||
];
|
||||
|
||||
describe('ai-providers.json integrity', () => {
|
||||
it('should pass Zod schema validation', () => {
|
||||
expect(() =>
|
||||
aiProvidersConfigSchema.parse(defaultAiProviders),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have at least one model per expected provider', () => {
|
||||
EXPECTED_PROVIDER_NAMES.forEach((providerName) => {
|
||||
const config = PROVIDERS[providerName];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect((config?.models?.length ?? 0) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have all required fields for each model', () => {
|
||||
Object.values(PROVIDERS).forEach((config) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.label).toBeDefined();
|
||||
expect(model.inputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.outputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.contextWindowTokens).toBeGreaterThan(0);
|
||||
expect(model.maxOutputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique composite model IDs across all providers', () => {
|
||||
const allCompositeIds: string[] = [];
|
||||
|
||||
Object.entries(PROVIDERS).forEach(([key, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
allCompositeIds.push(buildCompositeModelId(key, model.name));
|
||||
});
|
||||
});
|
||||
|
||||
expect(new Set(allCompositeIds).size).toBe(allCompositeIds.length);
|
||||
});
|
||||
|
||||
it('should have at least one non-deprecated model per expected provider', () => {
|
||||
EXPECTED_PROVIDER_NAMES.forEach((providerName) => {
|
||||
const config = PROVIDERS[providerName];
|
||||
const hasActiveModel = (config?.models ?? []).some(
|
||||
(model) => !model.isDeprecated,
|
||||
);
|
||||
|
||||
expect(hasActiveModel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set source to catalog for all models after normalization', () => {
|
||||
Object.values(PROVIDERS).forEach((config) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.source).toBe('catalog');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have npm field set for all providers', () => {
|
||||
Object.values(PROVIDERS).forEach((config) => {
|
||||
expect(config.npm).toBeDefined();
|
||||
expect(config.npm).toMatch(/^@ai-sdk\//);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
|
||||
import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service';
|
||||
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
@@ -10,6 +11,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
DefaultAiCatalogService,
|
||||
ProviderConfigService,
|
||||
SdkProviderFactoryService,
|
||||
ModelsDevCatalogService,
|
||||
@@ -18,6 +20,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod
|
||||
AiModelConfigService,
|
||||
],
|
||||
exports: [
|
||||
DefaultAiCatalogService,
|
||||
AiModelRegistryService,
|
||||
AiModelConfigService,
|
||||
SdkProviderFactoryService,
|
||||
|
||||
+1
-72
@@ -3,82 +3,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
|
||||
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
|
||||
|
||||
const DEFAULT_PROVIDERS: AiProvidersConfig = loadDefaultAiProviders();
|
||||
|
||||
const EXPECTED_PROVIDERS = ['openai', 'anthropic', 'google', 'xai', 'mistral'];
|
||||
|
||||
describe('Default AI Providers (ai-providers.json)', () => {
|
||||
it('should have at least one model per provider', () => {
|
||||
EXPECTED_PROVIDERS.forEach((providerName) => {
|
||||
const config = DEFAULT_PROVIDERS[providerName];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect((config?.models?.length ?? 0) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have all required fields for each model', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.label).toBeDefined();
|
||||
expect(model.inputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.outputCostPerMillionTokens).toBeDefined();
|
||||
expect(model.contextWindowTokens).toBeGreaterThan(0);
|
||||
expect(model.maxOutputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique model IDs across all providers', () => {
|
||||
const allCompositeIds: string[] = [];
|
||||
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([key, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
allCompositeIds.push(buildCompositeModelId(key, model.name));
|
||||
});
|
||||
});
|
||||
|
||||
const unique = new Set(allCompositeIds);
|
||||
|
||||
expect(unique.size).toBe(allCompositeIds.length);
|
||||
});
|
||||
|
||||
it('should have at least one non-deprecated model per provider', () => {
|
||||
EXPECTED_PROVIDERS.forEach((providerName) => {
|
||||
const config = DEFAULT_PROVIDERS[providerName];
|
||||
const hasActiveModel = (config?.models ?? []).some(
|
||||
(model) => !model.isDeprecated,
|
||||
);
|
||||
|
||||
expect(hasActiveModel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have source set to catalog for all models', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
(config.models ?? []).forEach((model) => {
|
||||
expect(model.source).toBe('catalog');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have npm field set for all providers', () => {
|
||||
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
|
||||
expect(config.npm).toBeDefined();
|
||||
expect(config.npm).toMatch(/^@ai-sdk\//);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRegistryService', () => {
|
||||
let service: AiModelRegistryService;
|
||||
let mockConfigService: jest.Mocked<TwentyConfigService>;
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
|
||||
|
||||
const mockReadFile = jest.fn();
|
||||
|
||||
describe('DefaultAiCatalogService', () => {
|
||||
let service: DefaultAiCatalogService;
|
||||
let mockConfigService: jest.Mocked<TwentyConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockConfigService = {
|
||||
get: jest.fn().mockReturnValue(undefined),
|
||||
} as any;
|
||||
|
||||
const mockDriverFactory = {
|
||||
getCurrentDriver: jest.fn().mockReturnValue({ readFile: mockReadFile }),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DefaultAiCatalogService,
|
||||
{ provide: TwentyConfigService, useValue: mockConfigService },
|
||||
{ provide: FileStorageDriverFactory, useValue: mockDriverFactory },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DefaultAiCatalogService);
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('should use built-in catalog when AI_CATALOG_STORAGE_PATH is not set', async () => {
|
||||
await service.onModuleInit();
|
||||
|
||||
const providers = service.getDefaultAiCatalog();
|
||||
|
||||
expect(providers).toBeDefined();
|
||||
expect(Object.keys(providers).length).toBeGreaterThan(0);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load catalog from storage when AI_CATALOG_STORAGE_PATH is set', async () => {
|
||||
const catalog = JSON.stringify({
|
||||
customProvider: {
|
||||
npm: '@ai-sdk/openai',
|
||||
models: [
|
||||
{
|
||||
name: 'custom-model',
|
||||
label: 'Custom Model',
|
||||
inputCostPerMillionTokens: 1,
|
||||
outputCostPerMillionTokens: 2,
|
||||
contextWindowTokens: 4096,
|
||||
maxOutputTokens: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockReadFile.mockResolvedValue(Readable.from([Buffer.from(catalog)]));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
const providers = service.getDefaultAiCatalog();
|
||||
|
||||
expect(Object.keys(providers)).toEqual(['customProvider']);
|
||||
expect(providers['customProvider'].name).toBe('customProvider');
|
||||
expect(providers['customProvider'].models?.[0].source).toBe('catalog');
|
||||
expect(mockReadFile).toHaveBeenCalledWith({
|
||||
filePath: 'config/ai-catalog.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset catalog to empty object when storage read fails', async () => {
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockReadFile.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.getDefaultAiCatalog()).toEqual({});
|
||||
});
|
||||
|
||||
it('should reset catalog to empty object when storage returns invalid JSON', async () => {
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockReadFile.mockResolvedValue(
|
||||
Readable.from([Buffer.from('not valid json')]),
|
||||
);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.getDefaultAiCatalog()).toEqual({});
|
||||
});
|
||||
|
||||
it('should reset catalog to empty object when payload fails Zod validation', async () => {
|
||||
const invalidCatalog = JSON.stringify({
|
||||
badProvider: { models: 'not-an-array' },
|
||||
});
|
||||
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key === 'AI_CATALOG_STORAGE_PATH') return 'config/ai-catalog.json';
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockReadFile.mockResolvedValue(
|
||||
Readable.from([Buffer.from(invalidCatalog)]),
|
||||
);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.getDefaultAiCatalog()).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import defaultAiProviders from 'src/engine/metadata-modules/ai/ai-models/ai-providers.json';
|
||||
import { aiProvidersConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.schema';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { normalizeAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/normalize-ai-providers.util';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@Injectable()
|
||||
export class DefaultAiCatalogService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DefaultAiCatalogService.name);
|
||||
private catalog: AiProvidersConfig = normalizeAiProviders(
|
||||
defaultAiProviders as AiProvidersConfig,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const catalogPath = this.twentyConfigService.get('AI_CATALOG_STORAGE_PATH');
|
||||
|
||||
if (!catalogPath) {
|
||||
this.logger.log(
|
||||
'Using built-in AI catalog (AI_CATALOG_STORAGE_PATH not set)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await this.fetchCatalog(catalogPath);
|
||||
|
||||
this.catalog = normalizeAiProviders(raw);
|
||||
this.logger.log(`Loaded AI catalog from storage: ${catalogPath}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.warn(`Failed to load AI catalog from storage: ${message}`);
|
||||
this.catalog = {};
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultAiCatalog(): AiProvidersConfig {
|
||||
return structuredClone(this.catalog);
|
||||
}
|
||||
|
||||
private async fetchCatalog(filePath: string): Promise<AiProvidersConfig> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const stream = await driver.readFile({ filePath });
|
||||
const body = (await streamToBuffer(stream)).toString('utf-8');
|
||||
|
||||
return aiProvidersConfigSchema.parse(JSON.parse(body));
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -2,21 +2,27 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service';
|
||||
|
||||
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
|
||||
@Injectable()
|
||||
export class ProviderConfigService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly defaultAiCatalogService: DefaultAiCatalogService,
|
||||
) {}
|
||||
|
||||
getCatalogProviderNames(): Set<string> {
|
||||
return new Set(Object.keys(loadDefaultAiProviders()));
|
||||
return new Set(
|
||||
Object.keys(this.defaultAiCatalogService.getDefaultAiCatalog()),
|
||||
);
|
||||
}
|
||||
|
||||
getResolvedProviders(): AiProvidersConfig {
|
||||
const rawCatalog = loadDefaultAiProviders();
|
||||
const rawCatalog = this.defaultAiCatalogService.getDefaultAiCatalog();
|
||||
// Only resolve {{VAR}} templates in the committed catalog — never in
|
||||
// user-supplied custom providers, to prevent config variable exfiltration.
|
||||
const catalog = this.resolveTemplates(rawCatalog);
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aiProviderAuthTypeSchema = z.enum(['key', 'credentials', 'role']);
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
export type AiProviderAuthType = 'key' | 'credentials' | 'role';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { aiProviderAuthTypeSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-auth-type.schema';
|
||||
|
||||
export type AiProviderAuthType = z.infer<typeof aiProviderAuthTypeSchema>;
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AI_SDK_PACKAGES, DATA_RESIDENCY_KEYS } from 'twenty-shared/ai';
|
||||
|
||||
import { aiProviderAuthTypeSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-auth-type.schema';
|
||||
import { aiProviderModelConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.schema';
|
||||
|
||||
export const aiProviderConfigSchema = z.object({
|
||||
npm: z.enum(AI_SDK_PACKAGES),
|
||||
name: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
authType: aiProviderAuthTypeSchema.optional(),
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
dataResidency: z.enum(DATA_RESIDENCY_KEYS).optional(),
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
sessionToken: z.string().optional(),
|
||||
models: z.array(aiProviderModelConfigSchema).optional(),
|
||||
});
|
||||
+6
-15
@@ -1,20 +1,11 @@
|
||||
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AiProviderAuthType } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-auth-type.type';
|
||||
import { aiProviderConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.schema';
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
|
||||
export type AiProviderConfig = {
|
||||
npm: AiSdkPackage;
|
||||
// Optional provider display/catalog name (e.g. models.dev label). Not a model name; per-model names live on `models[].name`.
|
||||
name?: string;
|
||||
label?: string;
|
||||
authType?: AiProviderAuthType;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
region?: string;
|
||||
dataResidency?: DataResidency;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string;
|
||||
export type AiProviderConfig = Omit<
|
||||
z.infer<typeof aiProviderConfigSchema>,
|
||||
'models'
|
||||
> & {
|
||||
models?: AiProviderModelConfig[];
|
||||
};
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
import { longContextCostSchema } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.schema';
|
||||
|
||||
export const aiProviderModelConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
modelFamily: z.nativeEnum(ModelFamily).optional(),
|
||||
inputCostPerMillionTokens: z.number().optional(),
|
||||
outputCostPerMillionTokens: z.number().optional(),
|
||||
cachedInputCostPerMillionTokens: z.number().optional(),
|
||||
cacheCreationCostPerMillionTokens: z.number().optional(),
|
||||
longContextCost: longContextCostSchema.optional(),
|
||||
contextWindowTokens: z.number().int().positive().optional(),
|
||||
maxOutputTokens: z.number().int().positive().optional(),
|
||||
modalities: z.array(z.string()).optional(),
|
||||
supportsReasoning: z.boolean().optional(),
|
||||
isDeprecated: z.boolean().optional(),
|
||||
});
|
||||
+6
-18
@@ -1,23 +1,11 @@
|
||||
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
|
||||
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { aiProviderModelConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.schema';
|
||||
|
||||
export type AiModelSource = 'catalog' | 'manual';
|
||||
|
||||
export type AiProviderModelConfig = {
|
||||
// Bare model name passed to the AI SDK (e.g. `gpt-4o`, `claude-3-opus`), not the composite `provider/modelName` id.
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
modelFamily?: ModelFamily;
|
||||
inputCostPerMillionTokens?: number;
|
||||
outputCostPerMillionTokens?: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
longContextCost?: LongContextCost;
|
||||
contextWindowTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
modalities?: string[];
|
||||
supportsReasoning?: boolean;
|
||||
isDeprecated?: boolean;
|
||||
export type AiProviderModelConfig = z.infer<
|
||||
typeof aiProviderModelConfigSchema
|
||||
> & {
|
||||
source?: AiModelSource;
|
||||
};
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { aiProviderConfigSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.schema';
|
||||
|
||||
export const aiProvidersConfigSchema = z.record(
|
||||
z.string(),
|
||||
aiProviderConfigSchema,
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const longContextCostSchema = z.object({
|
||||
inputCostPerMillionTokens: z.number(),
|
||||
outputCostPerMillionTokens: z.number(),
|
||||
cachedInputCostPerMillionTokens: z.number().optional(),
|
||||
cacheCreationCostPerMillionTokens: z.number().optional(),
|
||||
thresholdTokens: z.number(),
|
||||
});
|
||||
+5
-7
@@ -1,7 +1,5 @@
|
||||
export type LongContextCost = {
|
||||
inputCostPerMillionTokens: number;
|
||||
outputCostPerMillionTokens: number;
|
||||
cachedInputCostPerMillionTokens?: number;
|
||||
cacheCreationCostPerMillionTokens?: number;
|
||||
thresholdTokens: number;
|
||||
};
|
||||
import { z } from 'zod';
|
||||
|
||||
import { longContextCostSchema } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.schema';
|
||||
|
||||
export type LongContextCost = z.infer<typeof longContextCostSchema>;
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
|
||||
import defaultAiProviders from '../ai-providers.json';
|
||||
|
||||
export const loadDefaultAiProviders = (): AiProvidersConfig => {
|
||||
const raw = defaultAiProviders as unknown as AiProvidersConfig;
|
||||
export const normalizeAiProviders = (
|
||||
raw: AiProvidersConfig,
|
||||
): AiProvidersConfig => {
|
||||
const result: AiProvidersConfig = {};
|
||||
|
||||
for (const [key, config] of Object.entries(raw)) {
|
||||
@@ -64,16 +64,31 @@ export class EventStreamResolver {
|
||||
) {
|
||||
const eventStreamChannelId = eventStreamIdToChannelId(eventStreamId);
|
||||
|
||||
const streamData = await this.eventStreamService.getStreamData(
|
||||
const existingStreamData = await this.eventStreamService.getStreamData(
|
||||
workspace.id,
|
||||
eventStreamChannelId,
|
||||
);
|
||||
|
||||
if (isDefined(streamData)) {
|
||||
throw new EventStreamException(
|
||||
'Event stream already exists',
|
||||
EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS,
|
||||
);
|
||||
if (isDefined(existingStreamData)) {
|
||||
const isAuthorized = await this.eventStreamService.isAuthorized({
|
||||
streamData: existingStreamData,
|
||||
authContext: {
|
||||
userWorkspaceId,
|
||||
apiKeyId: apiKey?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw new EventStreamException(
|
||||
'Event stream already exists',
|
||||
EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS,
|
||||
);
|
||||
}
|
||||
|
||||
await this.eventStreamService.destroyEventStream({
|
||||
workspaceId: workspace.id,
|
||||
eventStreamChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.eventStreamService.createEventStream({
|
||||
|
||||
+1
@@ -166,6 +166,7 @@ export class MessagingMessageService {
|
||||
headerMessageId: message.headerMessageId,
|
||||
subject: message.subject,
|
||||
receivedAt: message.receivedAt,
|
||||
direction: message.direction,
|
||||
text: message.text,
|
||||
messageThreadId,
|
||||
};
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ export class SentMessagePersistenceService {
|
||||
headerMessageId: input.sendResult.headerMessageId,
|
||||
subject: input.subject,
|
||||
text: input.body,
|
||||
direction: MessageDirection.OUTGOING,
|
||||
receivedAt: new Date(),
|
||||
messageThreadId,
|
||||
});
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ import {
|
||||
const activateWorkflowVersionSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version to activate'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version to activate'),
|
||||
});
|
||||
|
||||
type ActivateWorkflowVersionInput = z.infer<
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ const computeStepOutputSchemaSchema = z.object({
|
||||
step: z
|
||||
.union([workflowTriggerSchema, workflowActionSchema])
|
||||
.describe('The workflow step configuration'),
|
||||
workflowVersionId: z.string().describe('The ID of the workflow version'),
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version'),
|
||||
});
|
||||
|
||||
export const createComputeStepOutputSchemaTool = (
|
||||
|
||||
+3
-2
@@ -6,10 +6,11 @@ import {
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
const createDraftFromWorkflowVersionSchema = z.object({
|
||||
workflowId: z.string().describe('The ID of the workflow'),
|
||||
workflowId: z.string().uuid().describe('The UUID of the workflow'),
|
||||
workflowVersionIdToCopy: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version to create a draft from'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version to create a draft from'),
|
||||
});
|
||||
|
||||
type CreateDraftFromWorkflowVersionInput = z.infer<
|
||||
|
||||
+8
-3
@@ -7,9 +7,14 @@ import {
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
const createWorkflowVersionEdgeSchema = z.object({
|
||||
workflowVersionId: z.string().describe('The ID of the workflow version'),
|
||||
source: z.string().describe('The ID of the source step'),
|
||||
target: z.string().describe('The ID of the target step'),
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version'),
|
||||
source: z
|
||||
.union([z.literal('trigger'), z.string().uuid()])
|
||||
.describe('The source step: "trigger" or a step UUID'),
|
||||
target: z.string().uuid().describe('The UUID of the target step'),
|
||||
sourceConnectionOptions: z
|
||||
.object({
|
||||
connectedStepType: z.literal(WorkflowActionType.ITERATOR),
|
||||
|
||||
+3
-2
@@ -13,12 +13,13 @@ import {
|
||||
const baseStepFields = {
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version to add the step to'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version to add the step to'),
|
||||
parentStepId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional ID of the parent step this step should come after. If not provided, the step will be added at the end of the workflow.',
|
||||
'Optional ID of the parent step this step should come after (UUID, or "trigger" for the trigger step). If not provided, the step will be added at the end of the workflow.',
|
||||
),
|
||||
parentStepConnectionOptions: z
|
||||
.object({
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ import {
|
||||
const deactivateWorkflowVersionSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version to deactivate'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version to deactivate'),
|
||||
});
|
||||
|
||||
type DeactivateWorkflowVersionInput = z.infer<
|
||||
|
||||
+8
-3
@@ -7,9 +7,14 @@ import {
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
const deleteWorkflowVersionEdgeSchema = z.object({
|
||||
workflowVersionId: z.string().describe('The ID of the workflow version'),
|
||||
source: z.string().describe('The ID of the source step'),
|
||||
target: z.string().describe('The ID of the target step'),
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version'),
|
||||
source: z
|
||||
.union([z.literal('trigger'), z.string().uuid()])
|
||||
.describe('The source step: "trigger" or a step UUID'),
|
||||
target: z.string().uuid().describe('The UUID of the target step'),
|
||||
sourceConnectionOptions: z
|
||||
.object({
|
||||
connectedStepType: z.literal(WorkflowActionType.ITERATOR),
|
||||
|
||||
+3
-2
@@ -8,8 +8,9 @@ import {
|
||||
const deleteWorkflowVersionStepSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version containing the step'),
|
||||
stepId: z.string().describe('The ID of the step to delete'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version containing the step'),
|
||||
stepId: z.string().uuid().describe('The UUID of the step to delete'),
|
||||
});
|
||||
|
||||
type DeleteWorkflowVersionStepInput = z.infer<
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ import {
|
||||
const getWorkflowCurrentVersionSchema = z.object({
|
||||
workflowId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow to get the current version for'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow to get the current version for'),
|
||||
});
|
||||
|
||||
type GetWorkflowCurrentVersionInput = z.infer<
|
||||
|
||||
+4
-1
@@ -7,7 +7,10 @@ import {
|
||||
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
const updateWorkflowVersionPositionsSchema = z.object({
|
||||
workflowVersionId: z.string().describe('The ID of the workflow version'),
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version'),
|
||||
positions: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ import {
|
||||
const updateWorkflowVersionStepSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version containing the step'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version containing the step'),
|
||||
step: z
|
||||
.union([workflowActionSchema])
|
||||
.describe('The updated step configuration'),
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ import {
|
||||
const updateWorkflowVersionTriggerSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.describe('The ID of the workflow version containing the trigger'),
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version containing the trigger'),
|
||||
trigger: workflowTriggerSchema.describe('The updated trigger configuration'),
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ const createValidManifest = (universalIdentifier: string) =>
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
const insertRegistrationWithSource = async (
|
||||
|
||||
+1
@@ -95,6 +95,7 @@ const buildManifestWithCrossEntityIdentifierConflict = (
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
describe('Install application should return structured validation errors', () => {
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
|
||||
import { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
const TEST_ROLE_ID = uuidv4();
|
||||
const TEST_TAB_ID = uuidv4();
|
||||
|
||||
const STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID =
|
||||
'20202020-a102-4002-8002-ae0a1ea11002';
|
||||
|
||||
const PAGE_LAYOUT_TAB_GQL_FIELDS = `
|
||||
id
|
||||
title
|
||||
position
|
||||
pageLayoutId
|
||||
applicationId
|
||||
`;
|
||||
|
||||
let testApplicationId: string;
|
||||
let standardPersonPageLayoutId: string;
|
||||
|
||||
const buildManifest = (
|
||||
overrides?: Partial<Pick<Manifest, 'pageLayoutTabs'>>,
|
||||
) =>
|
||||
buildBaseManifest({
|
||||
appId: TEST_APP_ID,
|
||||
roleId: TEST_ROLE_ID,
|
||||
overrides,
|
||||
});
|
||||
|
||||
const findStandardPersonPageLayoutTabs = async () => {
|
||||
const { data } = await findPageLayoutTabs({
|
||||
gqlFields: PAGE_LAYOUT_TAB_GQL_FIELDS,
|
||||
expectToFail: false,
|
||||
input: { pageLayoutId: standardPersonPageLayoutId },
|
||||
});
|
||||
|
||||
return data.getPageLayoutTabs.filter(
|
||||
(tab) => tab.applicationId === testApplicationId,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Manifest update - page layout tabs (standalone)', () => {
|
||||
beforeEach(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test Application',
|
||||
description: 'App for testing standalone page layout tab manifest updates',
|
||||
sourcePath: 'test-manifest-update-page-layout-tab',
|
||||
});
|
||||
|
||||
const applicationRow = await globalThis.testDataSource.query(
|
||||
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
|
||||
[TEST_APP_ID],
|
||||
);
|
||||
|
||||
testApplicationId = applicationRow[0].id;
|
||||
|
||||
const pageLayoutRow = await globalThis.testDataSource.query(
|
||||
`SELECT id FROM core."pageLayout" WHERE "universalIdentifier" = $1`,
|
||||
[STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID],
|
||||
);
|
||||
|
||||
standardPersonPageLayoutId = pageLayoutRow[0].id;
|
||||
}, 60000);
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should attach a standalone tab to a standard page layout', async () => {
|
||||
await syncApplication({
|
||||
manifest: buildManifest({
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: TEST_TAB_ID,
|
||||
pageLayoutUniversalIdentifier:
|
||||
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
icon: 'IconChartBar',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const tabs = await findStandardPersonPageLayoutTabs();
|
||||
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]).toMatchObject({
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
pageLayoutId: standardPersonPageLayoutId,
|
||||
applicationId: testApplicationId,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
it('should rename and reposition a standalone tab on second sync', async () => {
|
||||
await syncApplication({
|
||||
manifest: buildManifest({
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: TEST_TAB_ID,
|
||||
pageLayoutUniversalIdentifier:
|
||||
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
|
||||
|
||||
expect(tabsAfterFirstSync).toHaveLength(1);
|
||||
expect(tabsAfterFirstSync[0]).toMatchObject({
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
});
|
||||
|
||||
await syncApplication({
|
||||
manifest: buildManifest({
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: TEST_TAB_ID,
|
||||
pageLayoutUniversalIdentifier:
|
||||
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
|
||||
title: 'Renamed Insights',
|
||||
position: 1500,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
|
||||
|
||||
expect(tabsAfterSecondSync).toHaveLength(1);
|
||||
expect(tabsAfterSecondSync[0]).toMatchObject({
|
||||
title: 'Renamed Insights',
|
||||
position: 1500,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
it('should delete a standalone tab when removed from manifest on second sync', async () => {
|
||||
await syncApplication({
|
||||
manifest: buildManifest({
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: TEST_TAB_ID,
|
||||
pageLayoutUniversalIdentifier:
|
||||
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
|
||||
|
||||
expect(tabsAfterFirstSync).toHaveLength(1);
|
||||
|
||||
await syncApplication({
|
||||
manifest: buildManifest({ pageLayoutTabs: [] }),
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
|
||||
|
||||
expect(tabsAfterSecondSync).toHaveLength(0);
|
||||
}, 60000);
|
||||
|
||||
it('should fail to sync when standalone tab references a non-existent page layout', async () => {
|
||||
const { errors } = await syncApplication({
|
||||
manifest: buildManifest({
|
||||
pageLayoutTabs: [
|
||||
{
|
||||
universalIdentifier: TEST_TAB_ID,
|
||||
pageLayoutUniversalIdentifier: uuidv4(),
|
||||
title: 'Insights',
|
||||
position: 1000,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors?.length).toBeGreaterThan(0);
|
||||
}, 60000);
|
||||
});
|
||||
+1
@@ -36,5 +36,6 @@ export const buildBaseManifest = ({
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -9,4 +9,5 @@ export enum SyncableEntity {
|
||||
View = 'view',
|
||||
NavigationMenuItem = 'navigationMenuItem',
|
||||
PageLayout = 'pageLayout',
|
||||
PageLayoutTab = 'pageLayoutTab',
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { type FrontComponentManifest } from './frontComponentManifestType';
|
||||
import { type LogicFunctionManifest } from './logicFunctionManifestType';
|
||||
import { type NavigationMenuItemManifest } from './navigationMenuItemManifestType';
|
||||
import { type ObjectManifest } from './objectManifestType';
|
||||
import { type PageLayoutManifest } from './pageLayoutManifestType';
|
||||
import {
|
||||
type PageLayoutManifest,
|
||||
type PageLayoutTabManifest,
|
||||
} from './pageLayoutManifestType';
|
||||
import { type RoleManifest } from './roleManifestType';
|
||||
import { type SkillManifest } from './skillManifestType';
|
||||
import { type ViewManifest } from './viewManifestType';
|
||||
@@ -24,4 +27,5 @@ export type Manifest = {
|
||||
views: ViewManifest[];
|
||||
navigationMenuItems: NavigationMenuItemManifest[];
|
||||
pageLayouts: PageLayoutManifest[];
|
||||
pageLayoutTabs: PageLayoutTabManifest[];
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export type PageLayoutTabManifest = SyncableEntityOptions & {
|
||||
icon?: string;
|
||||
layoutMode?: PageLayoutTabLayoutMode;
|
||||
widgets?: PageLayoutWidgetManifest[];
|
||||
pageLayoutUniversalIdentifier?: string;
|
||||
};
|
||||
|
||||
export type PageLayoutManifest = SyncableEntityOptions & {
|
||||
|
||||
Reference in New Issue
Block a user