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 (
+ 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.
+
+ 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 (
+
);
}
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 && (
+
);
}
+
+/**
+ * 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.
+
+
+
+ );
+}
diff --git a/web/src/components/dashboard/widgets/WidgetCard.tsx b/web/src/components/dashboard/widgets/WidgetCard.tsx
index f98569e..f5d5aae 100644
--- a/web/src/components/dashboard/widgets/WidgetCard.tsx
+++ b/web/src/components/dashboard/widgets/WidgetCard.tsx
@@ -230,7 +230,7 @@ function Panel({
function Broken({ name }: { name: string }) {
return (
- Widget „{name}" se nepodařilo zobrazit.
+ Widget "{name}" se nepodařilo zobrazit.
);
}
diff --git a/web/src/lib/flow.ts b/web/src/lib/flow.ts
index 732f2c0..5cf24f6 100644
--- a/web/src/lib/flow.ts
+++ b/web/src/lib/flow.ts
@@ -20,7 +20,7 @@ import type {
* Prazdne pole = hlavni (korenova) sekvence.
* `[{ stepId: 'st_3', branch: 'yes' }]` = vetev ANO podminky st_3.
*/
-export type FlowPath = Array<{ stepId: string; branch: 'yes' | 'no' }>;
+export type FlowPath = Array<{ stepId: string; branch: 'yes' | 'no' | 'loop' }>;
let idCounter = 0;
@@ -34,6 +34,11 @@ export function createActionStep(serviceId: string, operationId: string): FlowSt
return { id: newStepId(), kind: 'action', serviceId, operationId };
}
+/** Smycka nad seznamem. Cesta se dovyplni v nastaveni kroku. */
+export function createForeachStep(path = ''): FlowStep {
+ return { id: newStepId(), kind: 'foreach', path, steps: [] };
+}
+
export function createConditionStep(field: TriggerField): FlowStep {
return {
id: newStepId(),
@@ -120,7 +125,7 @@ export const fieldTypeLabels: Record = {
list: 'seznam',
};
-/** Lidsky citelny zapis podminky, napr. „score je větší nebo rovno 15". */
+/** Lidsky citelny zapis podminky, napr. "score je větší nebo rovno 15". */
export function describeCondition(
field: TriggerField | undefined,
operator: ConditionOperator,
@@ -172,6 +177,21 @@ export function collectScopes(
continue;
}
+ if (step.kind === 'foreach') {
+ // Musi sedet se serverem, viz src/data/flowScope.ts.
+ walk(step.steps, [
+ ...available,
+ { id: `${step.id}.item`, name: 'item', type: 'object', required: true },
+ { id: `${step.id}.index`, name: 'index', type: 'number', required: true },
+ ]);
+ available = [
+ ...available,
+ { id: `${step.id}.results`, name: `${step.id}.results`, type: 'list', required: true },
+ { id: `${step.id}.count`, name: `${step.id}.count`, type: 'number', required: true },
+ ];
+ continue;
+ }
+
const outputs = outputsOf(step);
if (outputs.length > 0) {
// Novejsi vystup prekryje starsi se stejnym jmenem, stejne jako to
@@ -193,7 +213,18 @@ export function collectScopes(
* `{{nazev}}` v hodnote pole akce se pri behu nahradi parametrem spoustece.
* POZOR: stejna logika je na serveru v src/data/templates.ts, server je autorita.
*/
-const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
+const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+|\[\d+\])*)\s*\}\}/g;
+
+/** Prvni cast odkazu. Zbytek je cesta do struktury a ta se predem overit neda. */
+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);
+}
/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit. */
export function referencedFields(template: string): string[] {
@@ -206,9 +237,16 @@ export function referencedFields(template: string): string[] {
}
/** Odkazy na parametry, ktere u spoustece neexistuji. Typicky po prejmenovani. */
-export function brokenReferences(template: string, fields: TriggerField[]): string[] {
+export function brokenReferences(
+ template: string,
+ fields: TriggerField[],
+ /** Koreny navic: klice z ukazky tela, ID kroku, `item` a `index` ve smycce. */
+ extraRoots: Set = new Set(),
+): string[] {
const known = new Set(fields.map((field) => field.name));
- return referencedFields(template).filter((name) => !known.has(name));
+ return referencedFields(template)
+ .map(rootOf)
+ .filter((root) => !known.has(root) && !extraRoots.has(root));
}
/** Zmena nastaveni akce kdekoliv ve strome. */
@@ -225,6 +263,9 @@ export function updateStepInputs(
no: updateStepInputs(step.no, stepId, inputs),
};
}
+ if (step.kind === 'foreach') {
+ return { ...step, steps: updateStepInputs(step.steps, stepId, inputs) };
+ }
return step.id === stepId ? { ...step, inputs } : step;
});
}
@@ -236,6 +277,9 @@ export function updateCondition(
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
): FlowStep[] {
return steps.map((step) => {
+ if (step.kind === 'foreach') {
+ return { ...step, steps: updateCondition(step.steps, stepId, patch) };
+ }
if (step.kind !== 'condition') return step;
if (step.id === stepId) return { ...step, ...patch };
return {
@@ -246,6 +290,22 @@ export function updateCondition(
});
}
+/** Zmena cesty u smycky kdekoliv ve strome. */
+export function updateForeachPath(steps: FlowStep[], stepId: string, path: string): FlowStep[] {
+ return steps.map((step) => {
+ if (step.kind === 'condition') {
+ return {
+ ...step,
+ yes: updateForeachPath(step.yes, stepId, path),
+ no: updateForeachPath(step.no, stepId, path),
+ };
+ }
+ if (step.kind !== 'foreach') return step;
+ if (step.id === stepId) return { ...step, path };
+ return { ...step, steps: updateForeachPath(step.steps, stepId, path) };
+ });
+}
+
/** Vloz krok do sekvence na dane cesta+pozice. */
export function insertStep(
steps: FlowStep[],
@@ -262,6 +322,15 @@ export function insertStep(
const [head, ...rest] = path;
return steps.map((current) => {
if (current.id !== head.stepId) return current;
+
+ if (head.branch === 'loop') {
+ if (current.kind !== 'foreach') {
+ console.warn(`[flow] cesta vede pres krok ${current.id}, ktery neni smycka`);
+ return current;
+ }
+ return { ...current, steps: insertStep(current.steps, rest, index, step) };
+ }
+
if (current.kind !== 'condition') {
console.warn(`[flow] cesta vede pres krok ${current.id}, ktery neni podminka`);
return current;
@@ -277,11 +346,13 @@ export function insertStep(
export function removeStep(steps: FlowStep[], stepId: string): FlowStep[] {
return steps
.filter((step) => step.id !== stepId)
- .map((step) =>
- step.kind === 'condition'
- ? { ...step, yes: removeStep(step.yes, stepId), no: removeStep(step.no, stepId) }
- : step,
- );
+ .map((step) => {
+ if (step.kind === 'condition') {
+ return { ...step, yes: removeStep(step.yes, stepId), no: removeStep(step.no, stepId) };
+ }
+ if (step.kind === 'foreach') return { ...step, steps: removeStep(step.steps, stepId) };
+ return step;
+ });
}
/** Posun krok v ramci jeho sekvence o `offset` pozic. */
@@ -297,22 +368,26 @@ export function moveStep(steps: FlowStep[], stepId: string, offset: number): Flo
return next;
}
- return steps.map((step) =>
- step.kind === 'condition'
- ? { ...step, yes: moveStep(step.yes, stepId, offset), no: moveStep(step.no, stepId, offset) }
- : step,
- );
+ return steps.map((step) => {
+ if (step.kind === 'condition') {
+ return {
+ ...step,
+ yes: moveStep(step.yes, stepId, offset),
+ no: moveStep(step.no, stepId, offset),
+ };
+ }
+ if (step.kind === 'foreach') return { ...step, steps: moveStep(step.steps, stepId, offset) };
+ return step;
+ });
}
/** Pocet vsech kroku vcetne vnorenych vetvi. */
export function countSteps(steps: FlowStep[]): number {
- return steps.reduce(
- (sum, step) =>
- step.kind === 'condition'
- ? sum + 1 + countSteps(step.yes) + countSteps(step.no)
- : sum + 1,
- 0,
- );
+ return steps.reduce((sum, step) => {
+ 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);
}
interface ResolvedOperation {
diff --git a/web/src/pages/dashboard/ActionDetail.tsx b/web/src/pages/dashboard/ActionDetail.tsx
index c5c961c..40a0956 100644
--- a/web/src/pages/dashboard/ActionDetail.tsx
+++ b/web/src/pages/dashboard/ActionDetail.tsx
@@ -11,10 +11,12 @@ import {
countSteps,
createActionStep,
createConditionStep,
+ createForeachStep,
insertStep,
moveStep,
removeStep,
updateCondition,
+ updateForeachPath,
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
@@ -196,6 +198,9 @@ export default function ActionDetail() {
change(updateCondition(steps, stepId, patch))
}
onUpdateInputs={(stepId, inputs) => change(updateStepInputs(steps, stepId, inputs))}
+ onUpdateForeach={(stepId, path) =>
+ change(updateForeachPath(steps, stepId, path))
+ }
onMoveStep={(stepId, offset) => change(moveStep(steps, stepId, offset))}
/>
@@ -247,6 +252,11 @@ export default function ActionDetail() {
change(insertStep(steps, picker.path, picker.index, createConditionStep(first)));
setPicker(null);
}}
+ onPickForeach={() => {
+ if (!picker) return;
+ change(insertStep(steps, picker.path, picker.index, createForeachStep()));
+ setPicker(null);
+ }}
canAddCondition={startFields.length > 0}
/>
diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx
index d31efc4..3380192 100644
--- a/web/src/pages/dashboard/AutomationDetail.tsx
+++ b/web/src/pages/dashboard/AutomationDetail.tsx
@@ -11,10 +11,12 @@ import {
countSteps,
createActionStep,
createConditionStep,
+ createForeachStep,
insertStep,
moveStep,
removeStep,
updateCondition,
+ updateForeachPath,
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
@@ -150,6 +152,31 @@ export default function AutomationDetail() {
setPicker(null);
}
+ /** Smycka nad seznamem. Cestu si clovek dovyplni na karte kroku. */
+ function handlePickForeach() {
+ if (!picker || picker.mode !== 'action') {
+ console.warn('[builder] smycku lze vlozit jen jako krok');
+ return;
+ }
+ changeFlow({
+ ...flow,
+ steps: insertStep(flow.steps, picker.path, picker.index, createForeachStep()),
+ });
+ setPicker(null);
+ }
+
+ /**
+ * Ukazka tela od odesilatele. Model se z ni odvodi na serveru, takze se
+ * po ulozeni nacte automatizace znovu a cesty jsou hned k dispozici.
+ */
+ function handleChangeSample(sample: unknown) {
+ if (!flow.trigger) {
+ console.warn('[builder] ukazka tela bez spoustece');
+ return;
+ }
+ changeFlow({ ...flow, trigger: { ...flow.trigger, sample } });
+ }
+
function handleChangeFields(fields: TriggerField[]) {
if (!flow.trigger) {
console.warn('[builder] zmena parametru bez spoustece');
@@ -218,7 +245,7 @@ export default function AutomationDetail() {
}
async function handleDelete() {
- if (!window.confirm(`Smazat automatizaci „${name}"? Tuto akci nelze vzít zpět.`)) return;
+ if (!window.confirm(`Smazat automatizaci "${name}"? Tuto akci nelze vzít zpět.`)) return;
try {
await apiFetch(`/api/dashboard/automations/${id}`, { method: 'DELETE' });
navigate('/dashboard/automatizace', { replace: true });
@@ -352,8 +379,10 @@ export default function AutomationDetail() {
connectors={connectors}
webhookBaseUrl={catalog.data?.webhookBaseUrl ?? ''}
regenerating={regenerating}
+ model={automation.data?.model ?? []}
onPickTrigger={() => setPicker({ mode: 'trigger', path: [], index: 0 })}
onChangeFields={handleChangeFields}
+ onChangeSample={handleChangeSample}
onRegenerateToken={() => void handleRegenerateToken()}
onAddStep={(path, index) => setPicker({ mode: 'action', path, index })}
onRemoveStep={(stepId) =>
@@ -365,6 +394,9 @@ export default function AutomationDetail() {
onUpdateInputs={(stepId, inputs) =>
changeFlow({ ...flow, steps: updateStepInputs(flow.steps, stepId, inputs) })
}
+ onUpdateForeach={(stepId, path) =>
+ changeFlow({ ...flow, steps: updateForeachPath(flow.steps, stepId, path) })
+ }
onMoveStep={(stepId, offset) =>
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
}
@@ -428,6 +460,7 @@ export default function AutomationDetail() {
onClose={() => setPicker(null)}
onPickOperation={handlePick}
onPickCondition={handlePickCondition}
+ onPickForeach={handlePickForeach}
canAddCondition={(flow.trigger?.fields.length ?? 0) > 0}
/>
diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx
index 980d46e..fff7aa6 100644
--- a/web/src/pages/dashboard/TicketDetail.tsx
+++ b/web/src/pages/dashboard/TicketDetail.tsx
@@ -191,7 +191,7 @@ export default function TicketDetail() {
) : (
- Prázdné. Krok „Založit ticket" neměl nastavené pole Obsah, takže se text
+ Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text
původního požadavku nikam neuložil.
)}
diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts
index 9e55002..39c61da 100644
--- a/web/src/types/dashboard.ts
+++ b/web/src/types/dashboard.ts
@@ -447,10 +447,25 @@ export interface FlowTrigger {
serviceId: string;
operationId: string;
fields: TriggerField[];
+ /**
+ * Ukazka skutecneho tela od odesilatele. Odvozuje se z ni model, tedy strom
+ * cest, ze ktereho se klikanim vklada odkaz do sablony.
+ */
+ sample?: unknown;
/** Generuje vyhradne server, klient ho jen zobrazuje. */
webhookToken?: string;
}
+/** Jedna cesta v modelu prichozich dat. Pocita ji server z ukazky. */
+export interface ModelNode {
+ path: string;
+ type: FieldType;
+ /** Ukazkova hodnota, zkracena. */
+ sample: string | null;
+ /** Kolik polozek melo pole v ukazce. Jen u typu list. */
+ count?: number;
+}
+
/** Krok stromu: bud akce nad konektorem, nebo podminka se dvema vetvemi. */
export type FlowStep =
| {
@@ -470,6 +485,17 @@ export type FlowStep =
value?: string;
yes: FlowStep[];
no: FlowStep[];
+ }
+ | {
+ id: string;
+ /**
+ * Podstrom nad kazdou polozkou seznamu. Uvnitr je `item` a `index`,
+ * po skonceni `krok.results` se seznamem vysledku.
+ */
+ kind: 'foreach';
+ /** Cesta k seznamu, napr. `data.order.items`. */
+ path: string;
+ steps: FlowStep[];
};
export interface AutomationFlow {
@@ -479,6 +505,8 @@ export interface AutomationFlow {
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
+ /** Model prichozich dat z ukazky u spoustece. Pocita server pri cteni. */
+ model: ModelNode[];
createdAt: string;
updatedAt: string;
}