From 9b18531d3ef37e24f7a4d002ac5634e6cf5632bf Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:53:10 +0200 Subject: [PATCH] Prace nad celym modelem: cesty, ukazka tela a smycka nad seznamem Odesilatel posila cely model. Objednavka ze Shoptetu ma zanoreni, ceny v podobjektech a seznam polozek - a dosud sel napojit jen plochy seznam skalarnich poli, takze items[] neslo pouzit vubec. - Odkaz v sablone muze byt cesta: {{data.order.billingAddress.city}}, {{data.order.items[0].name}}, {{st_faktura.invoiceId}}. Overuje se prvni cast odkazu, takze ploche odkazy funguji dal presne jako driv. - Cele telo je v kontextu i v puvodnim tvaru. Deklarovane parametry maji pri shode jmen prednost. - Spoustec si pamatuje ukazku skutecneho tela. Server z ni odvodi model, tedy seznam cest i s typy, a ten se v krocich klika misto opisovani. Tlacitko doplni z hodnot v ukazce parametry pro podminky. - Novy krok Pro kazdou polozku: projde seznam a za kazdou polozku vykona vnoreny podstrom. Uvnitr je item a index, po skonceni krok.results se seznamem vysledku. Kazdy vysledek nese i puvodni polozku - radek objednavky potrebuje jak ID z CRM, tak mnozstvi z puvodnich dat. Strop 200 polozek, mimo seznam krok selze s tim, co tam misto nej je. - Prevod Za kazdou polozku seznamu v klikacim editoru mapovani. Engine ho umel, sel ale napsat jen rucnim JSONem. - Typograficke uvozovky a sipka z kodu pryc. Overeno nad skutecnym modelem objednavky: 23 cest vcetne data.order.items[].unitPrice.withoutVat, sablony s cestou i s indexem, smycka nad dvema polozkami s posbiranymi vysledky. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/01-prehled-a-stav.md | 1 + documentation/05-dashboard-a-builder.md | 59 ++++++ documentation/13-transformace-dat.md | 24 ++- documentation/99-zmeny.md | 46 +++++ src/data/automationStore.ts | 104 ++++++++-- src/data/connectors/types.ts | 2 +- src/data/dashboardLayouts.ts | 6 +- src/data/flowScope.ts | 25 +++ src/data/model.ts | 102 ++++++++++ src/data/templates.ts | 71 +++++-- src/data/ticketTypes.ts | 2 +- src/routes/dashboard.ts | 39 +++- src/routes/webhook.ts | 12 +- src/runtime/executor.ts | 141 +++++++++++++- .../components/dashboard/flow/FlowCanvas.tsx | 178 +++++++++++++++++- .../dashboard/flow/MappingEditor.tsx | 114 +++++++---- .../components/dashboard/flow/StepInputs.tsx | 97 +++++++++- .../components/dashboard/flow/StepPicker.tsx | 33 +++- .../dashboard/flow/TriggerConfig.tsx | 171 ++++++++++++++++- .../dashboard/widgets/WidgetCard.tsx | 2 +- web/src/lib/flow.ts | 119 +++++++++--- web/src/pages/dashboard/ActionDetail.tsx | 10 + web/src/pages/dashboard/AutomationDetail.tsx | 35 +++- web/src/pages/dashboard/TicketDetail.tsx | 2 +- web/src/types/dashboard.ts | 28 +++ 25 files changed, 1299 insertions(+), 124 deletions(-) create mode 100644 src/data/model.ts diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index f3ed90a..de657ea 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -37,6 +37,7 @@ React aplikaci ze slozky `dist/public`. | Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad | | Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni | | Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury | +| Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem | | Sprava clenstvi z portalu | hotovo | firmy a role v Nastaveni, lide a pozvanky v Lidech | | Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu | | Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta | diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index 51309e9..59f3650 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -178,3 +178,62 @@ Builder i katalog ji vezmou automaticky. | Beh automatizaci | ulozeny strom se nevykonava | | Historie behu a logy | prazdne, chybi runtime | | Drag and drop | presouvani je zatim tlacitky nahoru a dolu | + +## Prace nad celym modelem + +Odesilatel neposila ploche telo. Objednavka ze Shoptetu ma zanoreni, ceny +v podobjektech a seznam polozek - a to je bezny pripad, ne vyjimka. + +### Ukazka tela + +U spoustece s vlastnimi daty (webhook, formular) se vlepi **telo z realneho +volani**. Server z nej odvodi model, tedy seznam cest i s typy +(`src/data/model.ts`), a ten se v krocich nabizi ke kliknuti. + +Neni to kontrakt: telo se nezahodi, kdyz se od ukazky lisi. Je to napoveda pro +cloveka - prave ta u modelu o padesati cestach chybela. + +Tlacitko **Doplnit parametry pro podminky** z hodnot v ukazce udela deklarovane +parametry. Podminka se totiz pta na parametr, ne na cestu. + +### Odkaz muze byt cesta + +| Odkaz | Co vrati | +| --- | --- | +| `{{callSid}}` | deklarovany parametr, jako driv | +| `{{data.order.code}}` | hodnotu z prijateho tela | +| `{{data.order.items[0].name}}` | prvni polozku seznamu | +| `{{st_faktura.invoiceId}}` | vystup kroku `st_faktura` | +| `{{item.amount}}` | polozku uvnitr smycky | + +Overuje se jen **prvni cast** odkazu. Zbytek je cesta a tu predem overit nejde - +co presne prijde v tele, vime az pri behu. Diky tomu ploche odkazy funguji dal +presne jako driv. + +### Pro kazdou polozku + +Krok `foreach` projde seznam a za kazdou polozku vykona vnoreny podstrom. + +``` +Pro kazdou polozku: data.order.items + +- CRM: upsert produktu kod {{item.code}}, nazev {{item.name}} +``` + +Uvnitr je `{{item}}` a `{{index}}`. Po skonceni je +`{{st_loop.results}}` seznam vysledku, kde kazdy zaznam nese `index`, +**celou puvodni polozku** a vystupy kroku: + +```json +[{ "index": 0, "item": { "code": "PROD-001", "amount": 2 }, "crmItemId": "42" }] +``` + +Proc i puvodni polozka: radek objednavky potrebuje jak ID z CRM, tak mnozstvi +z puvodnich dat. Kdyby se neslo obe, muselo by se to znovu parovat podle kodu. + +Na tenhle seznam pak jde poslat krok Transformace s prevodem **Za kazdou +polozku seznamu** a poskladat z nej radky objednavky jednim volanim. + +**Strop 200 polozek.** Vic uz neni automatizace, ale zatez, kterou nikdo +necekal. Beh se zastavi a rekne to, misto aby seznam tise usekl. + +Kdyz na ceste neni seznam, krok selze a v hlasce stoji, co tam misto nej je. diff --git a/documentation/13-transformace-dat.md b/documentation/13-transformace-dat.md index 059987c..94de291 100644 --- a/documentation/13-transformace-dat.md +++ b/documentation/13-transformace-dat.md @@ -24,9 +24,14 @@ Dosud si kroky predavaly jen jednotlive hodnoty. Pribyly dva typy parametru: | `object` | cely objekt | ne | je / neni prazdny | | `list` | seznam | ne | je / neni prazdny | -**Do sablony se nedosazuji.** `{{order}}` v textu by znamenalo vlozit do vety -kus JSONu, coz nikdo nechce. Struktura se predava **jako celek** dalsimu kroku, -proto je u ni v builderu vyber a ne textove pole. +**Do sablony se nedosazuji jako celek.** `{{order}}` v textu by znamenalo +vlozit do vety kus JSONu, coz nikdo nechce. Struktura se predava **jako celek** +dalsimu kroku, proto je u ni v builderu vyber a ne textove pole. + +Na **hodnotu uvnitr** struktury ale sablona sahnout umi: +`{{data.order.billingAddress.city}}` i `{{data.order.items[0].name}}`. Bez toho +by slo pracovat jen s tim, co nekdo predem vypsal jako ploche parametry - a model +objednavky ze Shoptetu ma pres dvacet cest a seznam polozek. Nad strukturou ma z podminek smysl jen "prisla / neprisla". Porovnavat dva objekty by znamenalo urcit, co je "stejny", a to zavisi na pripadu. @@ -195,9 +200,10 @@ z transformace ho prepise jen tam, kde neco rika. ## Co chybi -| Chybi | Poznamka | -| --------------------------- | ------------------------------------------------------- | -| Nahled transformace | pravidla jde zkusit jen pres test skriptu | -| Vnorena pravidla klikanim | prevod `map` se upravuje v rezimu JSON | -| Napoveda cest ze skutecnych dat | nabizeji se jmena parametru, ne cesty uvnitr objektu | -| Vykonavani ze stromu | runner je hotovy, runtime automatizaci ne | +| Chybi | Poznamka | +| -------------------- | ------------------------------------------- | +| Nahled transformace | pravidla jde zkusit jen pres test skriptu | + +Hotovo od 2026-08-20: vnorena pravidla se klikaji (prevod **Za kazdou polozku +seznamu**), cesty se nabizeji z ukazky skutecneho tela a strom se vykonava pres +frontu. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 0ca8957..dd96924 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,52 @@ Nejnovejsi nahore. +## 2026-08-20 - prace nad celym modelem, ne nad plochym seznamem poli + +Odesilatel posle cely model - objednavka ze Shoptetu ma zanoreni, ceny +v podobjektech a seznam polozek. Dosud se dal napojit jen plochy seznam +skalarnich poli, takze `items[]` neslo pouzit vubec. + +### Pridano + +- **Cesty v sablonach.** `{{data.order.billingAddress.city}}`, + `{{data.order.items[0].name}}`, `{{st_faktura.invoiceId}}`. Hleda se nejdriv + presny klic, teprve pak cesta - vystupy kroku se ukladaji i pod klic s teckou + a presna shoda musi mit prednost. +- **Cele telo v kontextu.** Krome pojmenovanych hodnot je k dispozici i telo + v puvodnim tvaru, takze cesta funguje, aniz by to nekdo predem vypsal jako + parametr. Deklarovane parametry maji prednost pri shode jmen. +- **Ukazka tela u spoustece** (`trigger.sample`). Vlepi se telo z realneho + volani a odvodi se z nej model, tedy seznam cest i s typy (`src/data/model.ts`). + V krocich se cesty **klikaji**, misto aby se opisovaly, a kontrola vi, ze + `data.order.code` neni preklep. + - Tlacitko **Doplnit parametry pro podminky** z hodnot v ukazce udela + deklarovane parametry. Podminka se pta na parametr, ne na cestu. +- **Krok Pro kazdou polozku** (`foreach`). Projde seznam v datech a za kazdou + polozku vykona vnoreny podstrom. Uvnitr je `{{item}}` a `{{index}}`, po + skonceni `{{krok.results}}` se seznamem vysledku. + - Kazdy vysledek nese `index`, **celou puvodni polozku** a vystupy kroku. + Radek objednavky potrebuje jak ID z CRM, tak mnozstvi z puvodnich dat - + kdyby se nesly obe, muselo by se to znovu parovat. + - Strop je 200 polozek. Vic uz neni automatizace, ale zatez, kterou nikdo + necekal, takze se beh zastavi a rekne to. + - Kdyz na ceste neni seznam, krok selze s tim, co tam misto nej je. +- **Prevod Za kazdou polozku seznamu** v klikacim editoru mapovani. Engine ho + umel (`op: 'map'`), ale sel napsat jen rucnim JSONem. + +### Overeno + +Nad skutecnym modelem objednavky ze Shoptetu: 23 cest vcetne +`data.order.items[].unitPrice.withoutVat`, sablony s cestou i s indexem, +struktura predana jako JSON, neexistujici cesta jako prazdno s hlaskou v logu, +smycka nad dvema polozkami s posbiranymi vysledky. + +### Co to nemeni + +Ploche odkazy `{{callSid}}` funguji dal presne jako driv - overuje se **prvni +cast** odkazu, takze u nich se nic nezmenilo. Existujici automatizace se nemusi +nijak upravovat. + ## 2026-08-17 - log rekne, co zpusobilo jakou zmenu Nalezeno na bezicim serveru: ticket TK-4946 mel 177 prichozich udalosti diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 9e3ce76..6e91f50 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -14,7 +14,8 @@ import { withMirror } from './store/mirror.js'; import { isUnary, type ConditionOperator, type FieldType } from './conditions.js'; import { actionInputsFor, findService } from './services.js'; import { collectScopes, duplicateNames, scopeFor } from './flowScope.js'; -import { referencedFields } from './templates.js'; +import { referencedFields, rootOf } from './templates.js'; +import { describeModel, rootsOf, type ModelNode } from './model.js'; export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report'; @@ -42,6 +43,18 @@ export interface FlowTrigger { operationId: string; /** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */ fields: TriggerField[]; + /** + * Ukazka toho, co odesilatel opravdu posila. + * + * Vlepi se cele telo z realneho volani. Z nej se odvodi model, tedy seznam + * cest i s typy, ktery se v builderu ukaze jako strom a klikanim se z nej + * vklada odkaz do sablony. + * + * Proc ukazka a ne rucne psany seznam poli: model objednavky ze Shoptetu ma + * desitky cest a seznam polozek. Vypsat ho po jednom nikdo nebude a pole + * do plocheho seznamu nepatri vubec. + */ + sample?: unknown; /** * Jak casto se ma sluzba obvolavat, kdyz nam sama nezavola (v sekundach). * Plati jen u spoustecu, ktere se musi ptat. Minimum je 10 s. @@ -91,6 +104,25 @@ export type FlowStep = value?: string; yes: FlowStep[]; no: FlowStep[]; + } + | { + id: string; + /** + * Podstrom, ktery se vykona **nad kazdou polozkou seznamu**. + * + * Bez tohohle nejde rict "kazdou polozku objednavky zaloz v CRM". Jedno + * volani se seznamem uvnitr se da poskladat transformaci, ale kdyz ma + * kazda polozka vyvolat vlastni akci a vratit vlastni ID, je potreba + * smycka. + * + * Uvnitr je k dispozici `item` (cela polozka) a `index`. Po skonceni + * `krok.results`, coz je seznam vysledku za kazdou polozku - prave to se + * pak posila dal jako radky objednavky. + */ + kind: 'foreach'; + /** Cesta k seznamu v datech, napr. `data.order.items`. */ + path: string; + steps: FlowStep[]; }; export interface AutomationFlow { @@ -140,6 +172,14 @@ export interface Automation { export interface AutomationDetail extends Automation { flow: AutomationFlow; + /** + * Model prichozich dat odvozeny z ukazky u spoustece. + * + * Pocita se pri cteni, neuklada se - je to pohled na `trigger.sample`. + * Kdyby se ukladal, sel by rozejit s ukazkou a nikdo by nepoznal, ktery + * z nich plati. + */ + model: ModelNode[]; createdAt: string; updatedAt: string; } @@ -225,6 +265,9 @@ export function countSteps(steps: FlowStep[]): number { if (step.kind === 'condition') { return sum + 1 + countSteps(step.yes) + countSteps(step.no); } + if (step.kind === 'foreach') { + return sum + 1 + countSteps(step.steps); + } return sum + 1; }, 0); } @@ -239,6 +282,11 @@ export function countSteps(steps: FlowStep[]): number { function actionInputIssues( step: Extract, available: TriggerField[], + /** + * Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem: + * klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky. + */ + extraRoots: Set, ): string[] { const knownNames = new Set(available.map((field) => field.name)); const catalog = actionInputsFor(step.serviceId, step.operationId); @@ -251,14 +299,19 @@ function actionInputIssues( const value = step.inputs?.[field.id] ?? ''; if (field.required && value.trim().length === 0) { - issues.push(`Krok „${operationName}": chybí ${field.label.toLowerCase()}.`); + issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`); continue; } - for (const name of referencedFields(value)) { - if (!knownNames.has(name)) { + for (const reference of referencedFields(value)) { + /* + * Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury + * a tu predem overit nejde - co presne prijde v tele, vime az pri behu. + */ + const root = rootOf(reference); + if (!knownNames.has(root) && !extraRoots.has(root)) { issues.push( - `Krok „${operationName}", pole ${field.label.toLowerCase()}: parametr „${name}" u spouštěče neexistuje.`, + `Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`, ); } } @@ -291,18 +344,37 @@ export function collectFlowIssues(flow: AutomationFlow): string[] { const scopes = collectScopes(flow); - const walk = (steps: FlowStep[]) => { + /* + * Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave + * kvuli nim se ukazka vlepuje, a `_body` s celym telem. + */ + const sampleRoots = rootsOf(flow.trigger.sample); + sampleRoots.add('_body'); + + const walk = (steps: FlowStep[], roots: Set) => { for (const step of steps) { const available = scopeFor(scopes, step.id); for (const name of duplicateNames(available)) { issues.push( - `Parametr „${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`, + `Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`, ); } if (step.kind === 'action') { - issues.push(...actionInputIssues(step, available)); + issues.push(...actionInputIssues(step, available, roots)); + // ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`. + roots.add(step.id); + continue; + } + + if (step.kind === 'foreach') { + if (step.path.trim().length === 0) { + issues.push('Smyčka nemá vyplněnou cestu k seznamu.'); + } + // Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam. + walk(step.steps, new Set([...roots, 'item', 'index'])); + roots.add(step.id); continue; } @@ -316,18 +388,18 @@ export function collectFlowIssues(flow: AutomationFlow): string[] { : 'Podmínka se odkazuje na parametr, který už neexistuje.', ); } else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) { - issues.push(`Podmínka nad parametrem „${field.name}" nemá vyplněnou hodnotu.`); + issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`); } else if (field.type === 'number' && !isUnary(step.operator)) { if (Number.isNaN(Number(step.value))) { - issues.push(`Podmínka nad parametrem „${field.name}" má nečíselnou hodnotu.`); + issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`); } } - walk(step.yes); - walk(step.no); + walk(step.yes, new Set(roots)); + walk(step.no, new Set(roots)); } }; - walk(flow.steps); + walk(flow.steps, sampleRoots); // Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru). // Uzivateli staci rict jednou. @@ -340,6 +412,7 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean { if (step.kind === 'condition') { return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category); } + if (step.kind === 'foreach') return flowUsesCategory(step.steps, category); return findService(step.serviceId)?.category === category; }); } @@ -514,7 +587,7 @@ function seedDemoAutomations(): void { }); seed({ - name: 'Synchronizace CRM ↔ účetnictví', + name: 'Synchronizace CRM a účetnictví', enabled: true, runsToday: 96, successRate: 98.9, @@ -980,6 +1053,9 @@ function toDetail(stored: StoredAutomation): AutomationDetail { return { ...toSummary(stored), flow: stored.flow, + model: stored.flow.trigger?.sample === undefined + ? [] + : describeModel(stored.flow.trigger.sample), createdAt: stored.createdAt, updatedAt: stored.updatedAt, }; diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 87c3a0e..086519a 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -165,7 +165,7 @@ export function validateConnectorValues( const issues: ConnectorIssue[] = []; for (const key of Object.keys(values)) { if (!known.has(key)) { - issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` }); + issues.push({ field: key, message: `Služba ${service.name} pole "${key}" nemá.` }); } } return issues; diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 47a5337..58454e0 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -103,16 +103,16 @@ export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): s const seen = new Set(); for (const item of items) { - if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`); + if (seen.has(item.id)) problems.push(`Widget s ID "${item.id}" je uvedený dvakrát.`); seen.add(item.id); const definition = byId.get(item.widgetId); if (!definition) { - problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`); + problems.push(`Widget "${item.widgetId}" v katalogu neexistuje.`); continue; } if (!definition.sizes.includes(item.size)) { - problems.push(`Widget „${definition.name}" nelze zobrazit v této šířce.`); + problems.push(`Widget "${definition.name}" nelze zobrazit v této šířce.`); } } diff --git a/src/data/flowScope.ts b/src/data/flowScope.ts index 42f9259..473960f 100644 --- a/src/data/flowScope.ts +++ b/src/data/flowScope.ts @@ -58,6 +58,31 @@ export function collectScopes(flow: AutomationFlow): FlowScopes { continue; } + if (step.kind === 'foreach') { + /* + * Uvnitr smycky je navic polozka a jeji poradi. Ven se nedostanou - + * za smyckou uz zadna "aktualni polozka" neni. + */ + const inside: TriggerField[] = [ + ...available, + { id: `${step.id}.item`, name: 'item', type: 'object', required: true }, + { id: `${step.id}.index`, name: 'index', type: 'number', required: true }, + ]; + walk(step.steps, inside); + + /* + * Po smycce je k dispozici seznam vysledku za kazdou polozku. Prave to + * se posila dal jako radky objednavky nebo faktury. + */ + const results: TriggerField[] = [ + { id: `${step.id}.results`, name: `${step.id}.results`, type: 'list', required: true }, + { id: `${step.id}.count`, name: `${step.id}.count`, type: 'number', required: true }, + ]; + for (const output of results) all.set(output.id, output); + available = [...available, ...results]; + continue; + } + const outputs = outputsOf(step); if (outputs.length === 0) continue; diff --git a/src/data/model.ts b/src/data/model.ts new file mode 100644 index 0000000..d179fb5 --- /dev/null +++ b/src/data/model.ts @@ -0,0 +1,102 @@ +/** + * Model prichozich dat odvozeny z ukazky. + * + * Odesilatel posle cely objekt - objednavka ze Shoptetu ma zanoreni, ceny + * v podobjektech a seznam polozek. Vypsat to rucne po jednom poli nikdo nebude + * a seznam polozek se do plocheho seznamu parametru nevejde vubec. + * + * Proto se vlepi **ukazka skutecneho tela** a z ni se odvodi seznam cest. Ten + * slouzi ke trem vecem: + * - v builderu je z nej strom, ze ktereho se klikanim vklada `{{cesta}}`, + * - validace pozna, ze `{{data.order.code}}` neni preklep, + * - u seznamu je videt, ze je to seznam, a co ma polozka uvnitr. + * + * Odvozuje se **z jedne ukazky**, takze to neni schema v pravem slova smyslu: + * co odesilatel jednou neposlal, tady nebude. To je prijatelne - je to napoveda + * pro cloveka, ne kontrakt, na kterem stoji beh. + */ + +import type { FieldType } from './conditions.js'; + +export interface ModelNode { + /** Cesta k hodnote, napr. `data.order.items[].name`. */ + path: string; + type: FieldType; + /** Ukazkova hodnota, zkracena. Podle ni clovek pozna, co to je. */ + sample: string | null; + /** Kolik polozek melo pole v ukazce. Jen u typu `list`. */ + count?: number; +} + +/** Kolik urovni zanoreni se prochazi. Hlubsi model uz nikdo necte. */ +const MAX_DEPTH = 8; +/** Strop poctu cest. Chrani builder pred modelem o tisicich radcich. */ +const MAX_NODES = 400; + +function typeOf(value: unknown): FieldType { + if (Array.isArray(value)) return 'list'; + if (value === null) return 'string'; + switch (typeof value) { + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'object': + return 'object'; + default: + return isDate(value as string) ? 'date' : 'string'; + } +} + +/** Vypada to jako datum? Jen podle tvaru, at se v modelu pozna cas od textu. */ +function isDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(value) && !Number.isNaN(Date.parse(value)); +} + +function shorten(value: unknown): string | null { + if (value === null || value === undefined) return null; + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > 60 ? `${text.slice(0, 60)}...` : text; +} + +/** + * Seznam cest v ukazce. + * + * U pole se vytvori jeden uzel pro pole samotne a pod nim cesty polozky + * s `[]` misto indexu: `data.order.items[].name`. Index se do napovedy nehodi - + * clovek chce vedet, co polozka ma, ne co mela ta prvni. + */ +export function describeModel(sample: unknown): ModelNode[] { + const nodes: ModelNode[] = []; + + const walk = (value: unknown, path: string, depth: number): void => { + if (nodes.length >= MAX_NODES || depth > MAX_DEPTH) return; + + if (Array.isArray(value)) { + nodes.push({ path, type: 'list', sample: null, count: value.length }); + // Tvar polozky se bere z prvni. Ruzne tvary v jednom poli jsou vzacne + // a hadat z nich prunik by dalo model, ktery neodpovida nicemu. + if (value.length > 0) walk(value[0], `${path}[]`, depth + 1); + return; + } + + if (value !== null && typeof value === 'object') { + if (path !== '') nodes.push({ path, type: 'object', sample: null }); + for (const [key, item] of Object.entries(value as Record)) { + walk(item, path === '' ? key : `${path}.${key}`, depth + 1); + } + return; + } + + nodes.push({ path, type: typeOf(value), sample: shorten(value) }); + }; + + walk(sample, '', 0); + return nodes; +} + +/** Prvni cast kazde cesty. Podle ni se pozna, jestli odkaz ma o co se oprit. */ +export function rootsOf(sample: unknown): Set { + if (sample === null || typeof sample !== 'object' || Array.isArray(sample)) return new Set(); + return new Set(Object.keys(sample as Record)); +} diff --git a/src/data/templates.ts b/src/data/templates.ts index 1cdbdfb..1b6006b 100644 --- a/src/data/templates.ts +++ b/src/data/templates.ts @@ -1,22 +1,32 @@ /** * Sablony hodnot v krocich stromu. * - * Hodnota nastavitelneho pole akce muze obsahovat odkazy na parametry - * spoustece: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi - * skutecnou hodnotou. + * Hodnota nastavitelneho pole akce muze obsahovat odkazy na to, co uz v behu + * je: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi skutecnou + * hodnotou. * - * Odkazuje se **jmenem** parametru, ne jeho ID. Uzivatel jmeno vidi a pise, + * Odkazuje se **jmenem**, ne ID parametru. Uzivatel jmeno vidi a pise, * `{{f_42}}` by nikdo neprecetl. Cenou je, ze prejmenovani parametru sablonu * rozbije - proto se to hlida a hlasi jako nedodelek, ne jako tichá chyba. * + * Odkaz muze byt i **cesta do struktury**: `{{data.order.billingAddress.city}}` + * nebo `{{data.order.items[0].name}}`. Bez toho by slo pracovat jen s tim, co + * nekdo predem vypsal jako ploche parametry - a cely model objednavky ze + * Shoptetu se vypsat neda. + * * POZOR: stejna logika je na klientovi v web/src/lib/flow.ts. * Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI. */ -/** `{{nazev}}`, mezery kolem nazvu jsou povolene. */ -const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; +import { getPath } from '../scripts/mapping.js'; -/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit, v poradi vyskytu. */ +/** + * `{{nazev}}` nebo `{{cesta.do.struktury}}` i s indexy `{{items[0].name}}`. + * Mezery kolem jsou povolene. + */ +const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+|\[\d+\])*)\s*\}\}/g; + +/** Odkazy, na ktere sablona ukazuje. Bez duplicit, v poradi vyskytu. */ export function referencedFields(template: string): string[] { const found: string[] = []; for (const match of template.matchAll(REFERENCE)) { @@ -27,16 +37,49 @@ export function referencedFields(template: string): string[] { } /** - * Dosadi hodnoty za odkazy. Chybejici parametr se nahradi prazdnym retezcem - * a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi. + * Prvni cast odkazu, tedy jmeno parametru nebo kroku. + * + * Podle nej se pozna, jestli odkaz vubec ma o co se oprit. Zbytek je cesta + * do struktury a tu predem overit nejde - co presne prijde v tele, vime az + * pri behu. */ -export function renderTemplate(template: string, values: Record): string { - return template.replace(REFERENCE, (_match, name: string) => { - const value = values[name]; +export function rootOf(reference: string): string { + const dot = reference.indexOf('.'); + const bracket = reference.indexOf('['); + const end = Math.min(dot === -1 ? reference.length : dot, bracket === -1 ? reference.length : bracket); + return reference.slice(0, end); +} + +function asText(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value === null || value === undefined) return ''; + // Struktura se preda jako JSON. Vstup typu object nebo list si ji rozparsuje zpet. + return JSON.stringify(value); +} + +/** + * Dosadi hodnoty za odkazy. Chybejici odkaz se nahradi prazdnym retezcem + * a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi. + * + * Hleda se **nejdriv presny klic**, teprve pak cesta. Runtime uklada vystupy + * kroku i pod klic s teckou (`st_faktura.invoiceId`), takze presna shoda musi + * mit prednost - jinak by se hledal krok `st_faktura` a v nem pole `invoiceId`, + * coz je sice totez, ale jen nahodou. + */ +export function renderTemplate(template: string, source: unknown): string { + return template.replace(REFERENCE, (_match, reference: string) => { + const direct = + source !== null && typeof source === 'object' + ? (source as Record)[reference] + : undefined; + + const value = direct === undefined ? getPath(source, reference) : direct; + if (value === undefined) { - console.warn(`[template] odkaz na neznamy parametr "${name}", dosazuji prazdno`); + console.warn(`[template] odkaz na neznamy parametr "${reference}", dosazuji prazdno`); return ''; } - return value; + return asText(value); }); } diff --git a/src/data/ticketTypes.ts b/src/data/ticketTypes.ts index 41dd705..984234a 100644 --- a/src/data/ticketTypes.ts +++ b/src/data/ticketTypes.ts @@ -166,7 +166,7 @@ export function validateTicketFields( } for (const key of Object.keys(values)) { - if (!known.has(key)) problems.push(`Typ ${type.name} pole „${key}" nemá.`); + if (!known.has(key)) problems.push(`Typ ${type.name} pole "${key}" nemá.`); } return problems; diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 7ba0cdc..2991762 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -798,6 +798,13 @@ const stepSchema: z.ZodType = z.lazy(() => // Klic je ID pole z katalogu, hodnota sablona. Overuje se nize. inputs: z.record(z.string()).optional(), }), + z.object({ + id: z.string().min(1), + kind: z.literal('foreach'), + /** Cesta k seznamu v datech, napr. `data.order.items`. */ + path: z.string().trim().min(1, 'Smyčka musí mít cestu k seznamu.').max(200), + steps: z.array(stepSchema), + }), z.object({ id: z.string().min(1), kind: z.literal('condition'), @@ -849,6 +856,19 @@ const flowSchema = z.object({ serviceId: z.string().min(1), operationId: z.string().min(1), fields: z.array(fieldSchema), + /** + * Ukazka skutecneho tela. Odvozuje se z ni model, viz data/model.ts. + * + * Strop je kvuli tomu, ze se uklada s automatizaci a cte pri kazdem + * nacteni - cele katalogy sem nepatri. + */ + sample: z + .unknown() + .optional() + .refine( + (value) => value === undefined || JSON.stringify(value).length <= 100_000, + 'Ukázka těla je moc velká, vejde se 100 kB.', + ), /** Jak casto se ma sluzba obvolavat. Plati jen u spoustecu, co se ptaji. */ intervalSec: z.number().int().min(10).max(86_400).optional(), // Token generuje server. Cokoliv od klienta se ignoruje. @@ -910,7 +930,7 @@ async function validateFlowReferences( const names = new Set(); for (const field of flow.trigger.fields) { if (names.has(field.name)) { - problems.push(`Parametr „${field.name}" je uvedený dvakrát.`); + problems.push(`Parametr "${field.name}" je uvedený dvakrát.`); } names.add(field.name); } @@ -927,13 +947,22 @@ async function validateFlowReferences( problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`); } else if (!operatorAllowedForType(step.operator, field.type)) { problems.push( - `Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`, + `Operátor "${step.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`, ); } await walk(step.yes); await walk(step.no); continue; } + + if (step.kind === 'foreach') { + if (step.path.trim().length === 0) { + problems.push('Smyčka musí mít cestu k seznamu, například data.order.items.'); + } + await walk(step.steps); + continue; + } + const action = findOperation(step.serviceId, step.operationId, 'action'); if (!action) { problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`); @@ -946,7 +975,7 @@ async function validateFlowReferences( for (const key of Object.keys(step.inputs ?? {})) { if (!allowed.has(key)) { problems.push( - `Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole „${key}".`, + `Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole "${key}".`, ); } } @@ -965,12 +994,12 @@ async function validateFlowReferences( parsed = JSON.parse(raw); } catch (err) { const detail = err instanceof Error ? err.message : 'neplatný JSON'; - issues.push(`Pole „${input.label}" není platný JSON: ${detail}`); + issues.push(`Pole "${input.label}" není platný JSON: ${detail}`); continue; } if (input.kind === 'mapping') { - issues.push(...validateRules(parsed, `pole „${input.label}"`)); + issues.push(...validateRules(parsed, `pole "${input.label}"`)); } } diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 2ca3daa..e8eb492 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -207,7 +207,13 @@ webhookRouter.post('/:token', (req, res) => { * pod `_body`. Diky tomu se v sablonach pise `{{orderId}}` bez ohledu na * to, jak hluboko to odesilatel schoval, a zaroven se nic neztrati. */ - payload: { ...values, _body: body }, + /* + * Krome pojmenovanych hodnot i **cele telo v puvodnim tvaru**, takze + * `{{data.order.billingAddress.city}}` funguje, aniz by to nekdo predem + * vypsal jako parametr. Deklarovane hodnoty maji prednost: kdyz si nekdo + * pojmenuje parametr stejne jako klic v tele, plati jeho. + */ + payload: { ...body, ...values, _body: body }, }) .then((item) => { console.info( @@ -283,13 +289,13 @@ function readPayload( if (value === undefined || value === null) { if (field.required) { const where = field.path && field.path !== field.name ? ` (cesta ${field.path})` : ''; - problems.push(`Chybí povinný parametr „${field.name}"${where}.`); + problems.push(`Chybí povinný parametr "${field.name}"${where}.`); } continue; } if (!matchesType(value, field.type)) { - problems.push(`Parametr „${field.name}" má mít typ ${field.type}.`); + problems.push(`Parametr "${field.name}" má mít typ ${field.type}.`); continue; } diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 3646a9c..ca1094f 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -25,6 +25,7 @@ import { defaultConnectorFor, getConnector, type Connector } from '../data/conne import type { FlowStep } from '../data/automationStore.js'; import { isUnary, type ConditionOperator } from '../data/conditions.js'; import { renderTemplate } from '../data/templates.js'; +import { getPath } from '../scripts/mapping.js'; import { appendTrace, type TraceInput } from '../data/ticketStore.js'; import { scriptIdFor } from '../scripts/lookup.js'; import { runScript } from '../scripts/runner.js'; @@ -58,7 +59,7 @@ export interface RunOptions { export interface StepResult { stepId: string; - kind: 'action' | 'condition'; + kind: 'action' | 'condition' | 'foreach'; label: string; ok: boolean; /** true = do logu ticketu se nezapisuje. Nic se nestalo, viz StepOutcome. */ @@ -94,6 +95,15 @@ export interface RunResult { /** Vychozi strop kroku. Vic uz neni strom, ale program. */ const MAX_STEPS = 50; +/** + * Kolik polozek smycka nejvys projde. + * + * Objednavka s tisicem radku znamena tisic volani cizi sluzby. To uz neni + * automatizace, to je zatez, kterou nikdo necekal - proto strop a jasna hlaska + * misto tichého useknuti. + */ +const MAX_LOOP_ITEMS = 200; + /** * Vykona strom. * @@ -140,6 +150,114 @@ export async function runFlow( continue; } + if (step.kind === 'foreach') { + const startedAt = Date.now(); + const source = getPath(working, step.path); + + if (!Array.isArray(source)) { + const result: StepResult = { + stepId: step.id, + kind: 'foreach', + label: `pro každou položku: ${step.path}`, + ok: false, + summary: `na cestě ${step.path} není seznam`, + detail: + `Očekával se seznam, přišlo ${describeValue(source)}. ` + + 'Zkontrolujte cestu proti ukázce těla u spouštěče.', + // Spatna cesta za minutu spravna nebude. + retryable: false, + durationMs: Date.now() - startedAt, + }; + results.push(result); + error = result.summary; + return false; + } + + if (source.length > MAX_LOOP_ITEMS) { + const result: StepResult = { + stepId: step.id, + kind: 'foreach', + label: `pro každou položku: ${step.path}`, + ok: false, + summary: `seznam má ${source.length} položek, strop je ${MAX_LOOP_ITEMS}`, + detail: + `Smyčka by udělala ${source.length} průchodů. Tolik volání cizí služby ` + + 'nikdo nečeká, takže se běh zastavil. Rozdělte data, nebo použijte ' + + 'krok Transformace, který zpracuje celý seznam jedním voláním.', + retryable: false, + durationMs: Date.now() - startedAt, + }; + results.push(result); + error = result.summary; + return false; + } + + /* + * Vysledky za kazdou polozku. Krome vystupu kroku nese kazdy zaznam + * i **puvodni polozku** - radek objednavky potrebuje jak ID z CRM, + * tak mnozstvi, ktere prislo v puvodnich datech. + */ + const collected: Array> = []; + const outer = { item: working.item, index: working.index }; + let failed = false; + + /* + * Radek smycky se zapisuje **pred** jejimi kroky a dopise se az potom. + * Kdyby se pridal na konec, cetl by se log pozpatku: nejdriv co se delo + * uvnitr a teprve pak, ze slo o smycku. + */ + const loopResult: StepResult = { + stepId: step.id, + kind: 'foreach', + label: `pro každou položku: ${step.path}`, + ok: true, + summary: `${source.length}x`, + detail: null, + durationMs: 0, + }; + results.push(loopResult); + + for (const [index, item] of source.entries()) { + working.item = item; + working.index = index; + + const before = results.length; + const okRun = await walk(step.steps); + + const merged: Record = { index, item }; + for (const inner of results.slice(before)) { + const outputs = working[inner.stepId]; + if (outputs !== null && typeof outputs === 'object' && !Array.isArray(outputs)) { + Object.assign(merged, outputs as Record); + } + } + collected.push(merged); + + if (!okRun) { + failed = true; + error = `${step.path}[${index}]: ${error ?? 'krok selhal'}`; + break; + } + } + + // Polozka a poradi patri jen dovnitr smycky. Venku by matly. + working.item = outer.item; + working.index = outer.index; + + working[step.id] = { results: collected, count: collected.length }; + working[`${step.id}.results`] = collected; + working[`${step.id}.count`] = collected.length; + + loopResult.ok = !failed; + loopResult.summary = failed + ? `zastaveno na položce ${collected.length} z ${source.length}` + : `${source.length}x`; + loopResult.durationMs = Date.now() - startedAt; + + if (failed) return false; + continue; + } + const result = await runAction(step, working, options); results.push(result); if (!result.ok) { @@ -314,18 +432,27 @@ function fillTemplates( inputs: Record, context: RunContext, ): Record { - const values: Record = {}; - for (const [key, value] of Object.entries(context)) { - values[key] = value === null || value === undefined ? '' : stringify(value); - } - + /* + * Kontext se predava **syrovy**, ne predem prevedeny na retezce. Odkaz + * muze byt cesta do struktury (`{{data.order.items[0].name}}`) a do plocheho + * seznamu retezcu se zanorit neda. + */ const filled: Record = {}; for (const [key, template] of Object.entries(inputs)) { - filled[key] = renderTemplate(template, values); + filled[key] = renderTemplate(template, context); } return filled; } +/** Cim to teda je, kdyz to neni seznam. Do hlasky, ktera ma pomoct. */ +function describeValue(value: unknown): string { + if (value === undefined) return 'nic (cesta neexistuje)'; + if (value === null) return 'null'; + if (Array.isArray(value)) return 'seznam'; + if (typeof value === 'object') return 'objekt'; + return `${typeof value} (${String(value).slice(0, 40)})`; +} + function stringify(value: unknown): string { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index 97a807f..1a6ee94 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -4,10 +4,11 @@ import { GitBranch, MousePointerClick, Plus, + Repeat, Trash2, Zap, } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { StepInputs } from '@/components/dashboard/flow/StepInputs'; import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig'; import { Badge } from '@/components/ui/Badge'; @@ -26,6 +27,7 @@ import { import type { AutomationFlow, ConditionOperator, + ModelNode, Service, FlowStep, TriggerField, @@ -38,6 +40,19 @@ import type { * Struktura: karta spoustece -> sekvence kroku. Podminka rozdeluje beh * na vetev ANO a NE, kazda ma vlastni sekvenci a vlastni "+". */ +/** + * Co kroky potrebuji vedet krome svych vlastnich dat. + * + * Chodi to skrz celý strom, proto pohromade - jinak by kazde zanoreni pridalo + * dalsi dva parametry a signatury by prestaly byt citelne. + */ +interface CanvasContext { + /** Cesty z ukazky tela u spoustece. Daji se kliknout do sablony. */ + model: ModelNode[]; + /** Koreny odkazu, ktere jsou v poradku i mimo deklarovane parametry. */ + extraRoots: Set; +} + interface CanvasCallbacks { onAddStep: (path: FlowPath, index: number) => void; onRemoveStep: (stepId: string) => void; @@ -46,6 +61,8 @@ interface CanvasCallbacks { patch: { fieldId?: string; operator?: ConditionOperator; value?: string }, ) => void; onUpdateInputs: (stepId: string, inputs: Record) => void; + /** Zmena cesty k seznamu u smycky. */ + onUpdateForeach: (stepId: string, path: string) => void; onMoveStep: (stepId: string, offset: number) => void; } @@ -55,8 +72,10 @@ export function FlowCanvas({ webhookBaseUrl, regenerating, start, + model = [], onPickTrigger, onChangeFields, + onChangeSample, onRegenerateToken, ...callbacks }: CanvasCallbacks & { @@ -64,6 +83,8 @@ export function FlowCanvas({ connectors: Service[]; webhookBaseUrl: string; regenerating: boolean; + /** Cesty odvozene z ukazky tela. Pocita je server, viz src/data/model.ts. */ + model?: ModelNode[]; /** * Cim strom zacina, kdyz to neni spoustec. * @@ -74,11 +95,38 @@ export function FlowCanvas({ start?: { label: string; hint: string; fields: TriggerField[] }; onPickTrigger: () => void; onChangeFields: (fields: TriggerField[]) => void; + /** Vlepena ukazka tela. Undefined ji zahodi. */ + onChangeSample?: (sample: unknown) => void; onRegenerateToken: () => void; }) { // Bez spoustece se kroky kresli jen v rezimu akce, jinak neni na cem stavet. const fallbackFields = start ? start.fields : flow.trigger?.fields; + /** + * Co smi stat na zacatku odkazu krome deklarovanych parametru: klice + * z ukazky tela, ID kroku (`{{st_faktura.invoiceId}}`) a to, co pridava + * smycka. Bez toho by builder hlasil kazdou cestu jako rozbitou. + */ + const context = useMemo(() => { + const roots = new Set(['_body', 'item', 'index']); + for (const node of model) roots.add(node.path.split('.')[0].replace(/\[\d*\]$/, '')); + + const collect = (steps: FlowStep[]): void => { + for (const step of steps) { + roots.add(step.id); + if (step.kind === 'condition') { + collect(step.yes); + collect(step.no); + } else if (step.kind === 'foreach') { + collect(step.steps); + } + } + }; + collect(flow.steps); + + return { model, extraRoots: roots }; + }, [model, flow.steps]); + return (
{start ? ( @@ -91,6 +139,8 @@ export function FlowCanvas({ regenerating={regenerating} onPick={onPickTrigger} onChangeFields={onChangeFields} + onChangeSample={onChangeSample} + model={model} onRegenerateToken={onRegenerateToken} /> )} @@ -102,6 +152,7 @@ export function FlowCanvas({ connectors={connectors} scopes={collectScopes({ ...flow, trigger: startTrigger(flow, start) }, connectors)} fallbackFields={fallbackFields} + context={context} {...callbacks} /> )} @@ -164,16 +215,20 @@ function TriggerCard({ connectors, webhookBaseUrl, regenerating, + model, onPick, onChangeFields, + onChangeSample, onRegenerateToken, }: { flow: AutomationFlow; connectors: Service[]; webhookBaseUrl: string; regenerating: boolean; + model: ModelNode[]; onPick: () => void; onChangeFields: (fields: TriggerField[]) => void; + onChangeSample?: (sample: unknown) => void; onRegenerateToken: () => void; }) { if (!flow.trigger) { @@ -245,7 +300,9 @@ function TriggerCard({ trigger={flow.trigger} connector={resolved.connector} webhookBaseUrl={webhookBaseUrl} + model={model} onChangeFields={onChangeFields} + onChangeSample={onChangeSample} onRegenerateToken={onRegenerateToken} regenerating={regenerating} /> @@ -260,6 +317,7 @@ function StepSequence({ connectors, scopes, fallbackFields, + context, compact = false, ...callbacks }: CanvasCallbacks & { @@ -270,6 +328,7 @@ function StepSequence({ scopes: Map; /** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */ fallbackFields: TriggerField[]; + context: CanvasContext; compact?: boolean; }) { const { onAddStep, onRemoveStep, onMoveStep, onUpdateInputs } = callbacks; @@ -285,12 +344,25 @@ function StepSequence({ step={step} connectors={connectors} available={scopes.get(step.id) ?? fallbackFields} + context={context} canMoveUp={index > 0} canMoveDown={index < steps.length - 1} onRemove={() => onRemoveStep(step.id)} onMove={(offset) => onMoveStep(step.id, offset)} onUpdateInputs={(inputs) => onUpdateInputs(step.id, inputs)} /> + ) : step.kind === 'foreach' ? ( + 0} + canMoveDown={index < steps.length - 1} + {...callbacks} + /> ) : ( 0} canMoveDown={index < steps.length - 1} @@ -346,6 +419,7 @@ function ActionCard({ step, connectors, available, + context, canMoveUp, canMoveDown, onRemove, @@ -356,6 +430,7 @@ function ActionCard({ connectors: Service[]; /** Parametry viditelne prave v tomto miste stromu. */ available: TriggerField[]; + context: CanvasContext; canMoveUp: boolean; canMoveDown: boolean; onRemove: () => void; @@ -462,6 +537,8 @@ function ActionCard({ fields={inputs} values={step.inputs ?? {}} available={available} + model={context.model} + extraRoots={context.extraRoots} onChange={onUpdateInputs} /> ) : ( @@ -484,6 +561,7 @@ function ConditionCard({ connectors, scopes, fallbackFields, + context, available: fields, canMoveUp, canMoveDown, @@ -494,6 +572,7 @@ function ConditionCard({ connectors: Service[]; scopes: Map; fallbackFields: TriggerField[]; + context: CanvasContext; available: TriggerField[]; canMoveUp: boolean; canMoveDown: boolean; @@ -643,6 +722,7 @@ function ConditionCard({ connectors={connectors} scopes={scopes} fallbackFields={fallbackFields} + context={context} compact {...callbacks} /> @@ -653,6 +733,102 @@ function ConditionCard({ ); } +/** + * Smycka nad seznamem. + * + * Uvnitr je podstrom, ktery se vykona za kazdou polozku. Vypada jako vetev + * podminky zamerne - je to totez z pohledu ovladani: sekvence kroku, ktera + * patri dovnitr neceho. + */ +function ForeachCard({ + step, + path, + connectors, + scopes, + fallbackFields, + context, + canMoveUp, + canMoveDown, + ...callbacks +}: CanvasCallbacks & { + step: Extract; + path: FlowPath; + connectors: Service[]; + scopes: Map; + fallbackFields: TriggerField[]; + context: CanvasContext; + canMoveUp: boolean; + canMoveDown: boolean; +}) { + const { onRemoveStep, onMoveStep, onUpdateForeach } = callbacks; + const missing = step.path.trim().length === 0; + + return ( +
+
+ + + +
+
+

Pro každou položku

+ {missing && Doplňte cestu} +
+ +
+ onUpdateForeach(step.id, event.target.value)} + placeholder="data.order.items" + aria-label="Cesta k seznamu" + className={cn( + 'min-w-56 flex-1 rounded-lg border bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white placeholder:text-white/25 focus:outline-none', + missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70', + )} + /> +
+ +

+ Uvnitř je k dispozici {'{{item}}'} s celou + položkou, například {'{{item.name}}'}, a{' '} + {'{{index}}'} s pořadím. Po smyčce je{' '} + {`{{${step.id}.results}}`} se seznamem + výsledků za všechny položky. +

+
+ + onMoveStep(step.id, offset)} + onRemove={() => onRemoveStep(step.id)} + /> +
+ +
+

+ Za každou položku +

+ {step.steps.length === 0 && ( +

+ Zatím prázdná smyčka - přidejte krok tlačítkem níž. +

+ )} + +
+
+ ); +} + function StepControls({ canMoveUp, canMoveDown, diff --git a/web/src/components/dashboard/flow/MappingEditor.tsx b/web/src/components/dashboard/flow/MappingEditor.tsx index e5c943f..e3b458b 100644 --- a/web/src/components/dashboard/flow/MappingEditor.tsx +++ b/web/src/components/dashboard/flow/MappingEditor.tsx @@ -110,6 +110,12 @@ const opCatalog: Array<{ op: string; label: string; params?: OpParam[] }> = [ params: [{ key: 'path', label: 'Cesta v položce', kind: 'text', placeholder: 'total' }], }, { op: 'count', label: 'Počet položek' }, + /* + * Prevod nad seznamem. Kazda polozka se prevede podle vlastnich pravidel, + * takze `data.order.items` -> radky faktury je jedno pravidlo, ne rucne psany + * JSON. Parametry nema, misto nich se vnori dalsi editor. + */ + { op: 'map', label: 'Za každou položku seznamu' }, ]; function opLabel(op: string): string { @@ -146,18 +152,6 @@ export function MappingEditor({ onChange(JSON.stringify(next, null, 2)); } - function updateRule(index: number, patch: Partial) { - write(rules.map((rule, at) => (at === index ? { ...rule, ...patch } : rule))); - } - - function removeRule(index: number) { - write(rules.filter((_rule, at) => at !== index)); - } - - function addRule() { - write([...rules, { to: '', from: '' }]); - } - if (rawMode) { return (
@@ -187,6 +181,37 @@ export function MappingEditor({ return (
+ + + +
+ ); +} + +/** + * Seznam pravidel s pridavanim a mazanim. + * + * Zvlast proto, ze se pouziva dvakrat: nahore pro cely prevod a znovu uvnitr + * prevodu nad seznamem, kde se pravidla pisou vuci jedne polozce. + */ +function RuleList({ + rules, + sourceHints, + onChange, +}: { + rules: MappingRule[]; + sourceHints: string[]; + onChange: (rules: MappingRule[]) => void; +}) { + return ( +
{rules.length === 0 && (

Žádné pravidlo. Bez pravidel by transformace nic nevytvořila. @@ -198,29 +223,21 @@ export function MappingEditor({ key={index} rule={rule} sourceHints={sourceHints} - onChange={(patch) => updateRule(index, patch)} - onRemove={() => removeRule(index)} + onChange={(patch) => + onChange(rules.map((item, at) => (at === index ? { ...item, ...patch } : item))) + } + onRemove={() => onChange(rules.filter((_item, at) => at !== index))} /> ))} -

- - -
+
); } @@ -392,6 +409,39 @@ function OpRow({ const inputClass = 'rounded-md border border-ink-600/70 bg-ink-900/70 px-2 py-0.5 text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none'; + /* + * Prevod nad seznamem ma misto parametru vlastni pravidla, tedy dalsi editor + * uvnitr. Cesty v nem se pisou **vuci polozce**, ne vuci celemu telu. + */ + if (operation.op === 'map') { + const inner = Array.isArray(operation.rules) ? (operation.rules as MappingRule[]) : []; + + return ( +
+
+ Za každou položku seznamu + cesty se píší vůči jedné položce + +
+ +
+ onChange({ rules })} + /> +
+
+ ); + } + return (
{opLabel(operation.op)} diff --git a/web/src/components/dashboard/flow/StepInputs.tsx b/web/src/components/dashboard/flow/StepInputs.tsx index 3a4df4f..ee8f30b 100644 --- a/web/src/components/dashboard/flow/StepInputs.tsx +++ b/web/src/components/dashboard/flow/StepInputs.tsx @@ -1,10 +1,10 @@ -import { AlertTriangle, Braces } from 'lucide-react'; +import { AlertTriangle, Braces, ChevronRight } from 'lucide-react'; import { useRef, useState } from 'react'; import { MappingEditor } from '@/components/dashboard/flow/MappingEditor'; import { Badge } from '@/components/ui/Badge'; import { brokenReferences } from '@/lib/flow'; import { cn } from '@/lib/cn'; -import type { OperationField, TriggerField } from '@/types/dashboard'; +import type { ModelNode, OperationField, TriggerField } from '@/types/dashboard'; /** Parametry, ktere nesou celou strukturu. Jen ty jde predat do pole typu objekt. */ function structuredFields(available: TriggerField[]): TriggerField[] { @@ -37,12 +37,23 @@ export function StepInputs({ fields, values, available, + model, + extraRoots, onChange, }: { fields: OperationField[]; values: Record; /** Parametry viditelne prave tady: spoustec plus vystupy predchozich kroku. */ available: TriggerField[]; + /** + * Model prichozich dat z ukazky u spoustece. + * + * Cesty se daji kliknout stejne jako parametry. Bez toho by se `{{data.order + * .billingAddress.city}}` musela opsat rucne a preklep by se poznal az z behu. + */ + model?: ModelNode[]; + /** Koreny, ktere jsou v poradku, i kdyz nejsou parametrem: ID kroku, item. */ + extraRoots?: Set; onChange: (values: Record) => void; }) { /** Do ktereho pole vlozi kliknuti na parametr. Vyber nema kam vkladat. */ @@ -94,7 +105,7 @@ export function StepInputs({ field.kind !== 'mapping' && field.kind !== 'json' && field.kind !== 'object'; - const broken = plainTemplate ? brokenReferences(value, available) : []; + const broken = plainTemplate ? brokenReferences(value, available, extraRoots) : []; return (
@@ -208,6 +219,7 @@ export function StepInputs({ @@ -218,14 +230,16 @@ export function StepInputs({ /** Nabidka parametru spoustece. Kliknuti je vlozi do rozepsaneho pole. */ function ParameterChips({ available, + model, enabled, onInsert, }: { available: TriggerField[]; + model: ModelNode[]; enabled: boolean; onInsert: (name: string) => void; }) { - if (available.length === 0) { + if (available.length === 0 && model.length === 0) { return (

Sem zatím nedoteče žádný parametr, takže do polí jde psát jen pevný text. @@ -260,6 +274,81 @@ function ParameterChips({ ))}

+ + {model.length > 0 && } +
+ ); +} + +/** + * Cesty z ukazky tela. + * + * Ukazuje se sbalene: model objednavky ma desitky cest a rozbalený seznam by + * schoval vlastni nastaveni kroku. Kdo hleda konkretni cestu, si ho otevre. + * + * U seznamu je videt, ze je to seznam - prave na nej se dava krok + * Pro kazdou polozku, ne odkaz do sablony. + */ +function ModelTree({ + model, + enabled, + onInsert, +}: { + model: ModelNode[]; + enabled: boolean; + onInsert: (name: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ {model.map((node) => { + // Do sablony patri hodnota, ne cely objekt nebo seznam. + const insertable = node.type !== 'object' && node.type !== 'list'; + const depth = (node.path.match(/\./g) ?? []).length; + + return ( +
+ + + {node.type === 'list' ? ( + seznam, {node.count ?? 0} polozek + ) : node.sample ? ( + {node.sample} + ) : null} +
+ ); + })} +
+ )}
); } diff --git a/web/src/components/dashboard/flow/StepPicker.tsx b/web/src/components/dashboard/flow/StepPicker.tsx index 4fd25f0..89b8edd 100644 --- a/web/src/components/dashboard/flow/StepPicker.tsx +++ b/web/src/components/dashboard/flow/StepPicker.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ChevronRight, GitBranch, Search } from 'lucide-react'; +import { ArrowLeft, ChevronRight, GitBranch, Repeat, Search } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/Badge'; @@ -24,6 +24,7 @@ export function StepPicker({ onClose, onPickOperation, onPickCondition, + onPickForeach, canAddCondition, }: { open: boolean; @@ -33,6 +34,8 @@ export function StepPicker({ onClose: () => void; onPickOperation: (serviceId: string, operationId: string) => void; onPickCondition?: () => void; + /** Prida smycku nad seznamem. Chybi tam, kde smycka nedava smysl. */ + onPickForeach?: () => void; /** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */ canAddCondition?: boolean; }) { @@ -139,7 +142,7 @@ export function StepPicker({ <> {searchResults.length === 0 ? (

- Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor „HTTP požadavek". + Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor "HTTP požadavek".

) : (
    @@ -218,7 +221,31 @@ export function StepPicker({ {canAddCondition === false ? 'Nejdřív u spouštěče nadeklarujte vstupní parametry - bez nich není podle čeho se rozhodovat.' - : 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'} + : 'Porovná hodnotu vstupního parametru (např. score >= 15) a rozdělí běh na větev ANO a NE.'} + + + + + )} + + {mode === 'action' && onPickForeach && ( +
); } @@ -432,3 +463,139 @@ ${exampleBody(fields)}`}
); } + +/** + * Ukazka skutecneho tela. + * + * Vlepi se to, co odesilatel opravdu posila. Z toho se odvodi model, tedy + * seznam cest - v krocich se pak klikaji misto opisovani a kontrola vi, ze + * `data.order.code` neni preklep. + * + * Neni to kontrakt, na kterem stoji beh: telo se nezahazuje, kdyz se lisi. + * Je to napoveda pro cloveka a to je presne to, co u modelu o padesati cestach + * chybelo. + */ +function SampleBody({ + sample, + model, + fields, + onChange, + onChangeFields, +}: { + sample: unknown; + model: ModelNode[]; + fields: TriggerField[]; + onChange: (sample: unknown) => void; + onChangeFields: (fields: TriggerField[]) => void; +}) { + const [text, setText] = useState(sample === undefined ? '' : JSON.stringify(sample, null, 2)); + const [problem, setProblem] = useState(null); + const [open, setOpen] = useState(sample === undefined); + + function save() { + const trimmed = text.trim(); + if (trimmed === '') { + setProblem(null); + onChange(undefined); + return; + } + try { + onChange(JSON.parse(trimmed)); + setProblem(null); + } catch (err) { + setProblem(err instanceof Error ? err.message : 'Neplatný JSON.'); + } + } + + /** + * Z cest udela deklarovane parametry pro podminky. + * + * Podminka se odkazuje na parametr, ne na cestu - proto tohle tlacitko. + * Bere jen hodnoty, ne objekty a seznamy: na ty se podminka ptat neumi. + * Jmeno vznikne z posledni casti cesty, kolize dostane cislo. + */ + function deriveFields() { + const used = new Set(fields.map((field) => field.name)); + const added: TriggerField[] = []; + + for (const node of model) { + if (node.type === 'object' || node.type === 'list') continue; + if (node.path.includes('[]')) continue; + if (fields.some((field) => field.path === node.path)) continue; + + const base = node.path.split('.').pop() ?? node.path; + let name = base.replace(/[^A-Za-z0-9_]/g, '_'); + if (/^[0-9]/.test(name)) name = `f_${name}`; + let unique = name; + let counter = 2; + while (used.has(unique)) unique = `${name}${counter++}`; + used.add(unique); + + added.push({ + id: newFieldId(), + name: unique, + type: node.type, + required: false, + path: node.path, + }); + } + + if (added.length > 0) onChangeFields([...fields, ...added]); + } + + return ( +
+ + + {open && ( + <> +

+ Vložte tělo z reálného volání. Odvodí se z něj cesty, které pak v krocích jen + kliknete: {'{{data.order.code}}'} i seznamy jako data.order.items. +

+ +