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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc0c2ff6c7
commit
9b18531d3e
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+90
-14
@@ -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<FlowStep, { kind: 'action' }>,
|
||||
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>,
|
||||
): 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<string>) => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,16 +103,16 @@ export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): s
|
||||
|
||||
const seen = new Set<string>();
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>)) {
|
||||
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<string> {
|
||||
if (sample === null || typeof sample !== 'object' || Array.isArray(sample)) return new Set();
|
||||
return new Set(Object.keys(sample as Record<string, unknown>));
|
||||
}
|
||||
+57
-14
@@ -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, string>): 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<string, unknown>)[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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+34
-5
@@ -798,6 +798,13 @@ const stepSchema: z.ZodType<FlowStep> = 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<string>();
|
||||
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}"`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+134
-7
@@ -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<Record<string, unknown>> = [];
|
||||
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<string, unknown> = { 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<string, unknown>);
|
||||
}
|
||||
}
|
||||
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<string, string>,
|
||||
context: RunContext,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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);
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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<string, string>) => 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<CanvasContext>(() => {
|
||||
const roots = new Set<string>(['_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 (
|
||||
<div className="flex flex-col items-stretch">
|
||||
{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<string, TriggerField[]>;
|
||||
/** 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' ? (
|
||||
<ForeachCard
|
||||
step={step}
|
||||
path={path}
|
||||
connectors={connectors}
|
||||
scopes={scopes}
|
||||
fallbackFields={fallbackFields}
|
||||
context={context}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < steps.length - 1}
|
||||
{...callbacks}
|
||||
/>
|
||||
) : (
|
||||
<ConditionCard
|
||||
step={step}
|
||||
@@ -298,6 +370,7 @@ function StepSequence({
|
||||
connectors={connectors}
|
||||
scopes={scopes}
|
||||
fallbackFields={fallbackFields}
|
||||
context={context}
|
||||
available={scopes.get(step.id) ?? fallbackFields}
|
||||
canMoveUp={index > 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<string, TriggerField[]>;
|
||||
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<FlowStep, { kind: 'foreach' }>;
|
||||
path: FlowPath;
|
||||
connectors: Service[];
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
fallbackFields: TriggerField[];
|
||||
context: CanvasContext;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const { onRemoveStep, onMoveStep, onUpdateForeach } = callbacks;
|
||||
const missing = step.path.trim().length === 0;
|
||||
|
||||
return (
|
||||
<div className="@container rounded-card border border-brand-400/30 bg-brand-500/6 p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-300 @xs:size-10">
|
||||
<Repeat className="size-4 @xs:size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-white/40">Pro každou položku</p>
|
||||
{missing && <Badge tone="warn">Doplňte cestu</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={step.path}
|
||||
onChange={(event) => 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',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-white/35">
|
||||
Uvnitř je k dispozici <code className="text-brand-300/80">{'{{item}}'}</code> s celou
|
||||
položkou, například <code className="text-brand-300/80">{'{{item.name}}'}</code>, a{' '}
|
||||
<code className="text-brand-300/80">{'{{index}}'}</code> s pořadím. Po smyčce je{' '}
|
||||
<code className="text-brand-300/80">{`{{${step.id}.results}}`}</code> se seznamem
|
||||
výsledků za všechny položky.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMove={(offset) => onMoveStep(step.id, offset)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-w-0 rounded-xl border border-brand-400/25 bg-brand-500/5 p-3">
|
||||
<p className="mb-1 text-xs font-semibold tracking-wide text-brand-300 uppercase">
|
||||
Za každou položku
|
||||
</p>
|
||||
{step.steps.length === 0 && (
|
||||
<p className="mb-1 text-xs text-white/35">
|
||||
Zatím prázdná smyčka - přidejte krok tlačítkem níž.
|
||||
</p>
|
||||
)}
|
||||
<StepSequence
|
||||
steps={step.steps}
|
||||
path={[...path, { stepId: step.id, branch: 'loop' }]}
|
||||
connectors={connectors}
|
||||
scopes={scopes}
|
||||
fallbackFields={fallbackFields}
|
||||
context={context}
|
||||
compact
|
||||
{...callbacks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepControls({
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
|
||||
@@ -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<MappingRule>) {
|
||||
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 (
|
||||
<div className="mt-1 space-y-2">
|
||||
@@ -187,6 +181,37 @@ export function MappingEditor({
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
<RuleList rules={rules} sourceHints={sourceHints} onChange={write} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRawMode(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white"
|
||||
>
|
||||
<Braces className="size-3" />
|
||||
Upravit jako JSON
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="space-y-2">
|
||||
{rules.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2 text-xs text-white/35">
|
||||
Žá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))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-brand-400/40 px-2.5 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Přidat pravidlo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRawMode(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white"
|
||||
>
|
||||
<Braces className="size-3" />
|
||||
Upravit jako JSON
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...rules, { to: '', from: '' }])}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-brand-400/40 px-2.5 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Přidat pravidlo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-brand-400/30 bg-brand-500/6 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="brand">Za každou položku seznamu</Badge>
|
||||
<span className="text-xs text-white/35">cesty se píší vůči jedné položce</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Odebrat převod"
|
||||
className="ml-auto grid size-5 place-items-center rounded text-white/30 transition-colors hover:text-danger-400"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<RuleList
|
||||
rules={inner}
|
||||
sourceHints={[]}
|
||||
onChange={(rules) => onChange({ rules })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={meta ? 'neutral' : 'warn'}>{opLabel(operation.op)}</Badge>
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<string>;
|
||||
onChange: (values: Record<string, string>) => 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 (
|
||||
<div key={field.id}>
|
||||
@@ -208,6 +219,7 @@ export function StepInputs({
|
||||
|
||||
<ParameterChips
|
||||
available={available}
|
||||
model={model ?? []}
|
||||
enabled={focused !== null}
|
||||
onInsert={insert}
|
||||
/>
|
||||
@@ -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 (
|
||||
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2 text-xs text-white/35">
|
||||
Sem zatím nedoteče žádný parametr, takže do polí jde psát jen pevný text.
|
||||
@@ -260,6 +274,81 @@ function ParameterChips({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{model.length > 0 && <ModelTree model={model} enabled={enabled} onInsert={onInsert} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mt-2 border-t border-ink-600/40 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-1.5 text-left text-xs text-white/40 transition-colors hover:text-white/70"
|
||||
>
|
||||
<ChevronRight className={cn('size-3 transition-transform', open && 'rotate-90')} />
|
||||
Cesty z ukázky těla ({model.length})
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 max-h-64 space-y-0.5 overflow-auto pr-1">
|
||||
{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 (
|
||||
<div
|
||||
key={node.path}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ paddingLeft: `${Math.min(depth, 6) * 10}px` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!enabled || !insertable}
|
||||
onClick={() => onInsert(node.path)}
|
||||
title={insertable ? `Vložit {{${node.path}}}` : 'Struktura, ne hodnota'}
|
||||
className={cn(
|
||||
'truncate rounded px-1 py-0.5 font-mono transition-colors',
|
||||
enabled && insertable
|
||||
? 'text-brand-200 hover:bg-brand-500/15'
|
||||
: 'cursor-not-allowed text-white/30',
|
||||
)}
|
||||
>
|
||||
{node.path}
|
||||
</button>
|
||||
|
||||
{node.type === 'list' ? (
|
||||
<span className="shrink-0 text-white/30">seznam, {node.count ?? 0} polozek</span>
|
||||
) : node.sample ? (
|
||||
<span className="min-w-0 truncate text-white/25">{node.sample}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="py-8 text-center text-sm text-white/45">
|
||||
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".
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
@@ -218,7 +221,31 @@ export function StepPicker({
|
||||
<span className="mt-0.5 block text-sm text-white/55">
|
||||
{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.'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === 'action' && onPickForeach && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPickForeach();
|
||||
close();
|
||||
}}
|
||||
className="mb-5 flex w-full items-start gap-3 rounded-xl border border-brand-400/30 bg-brand-500/8 p-4 text-left transition-colors hover:border-brand-400/60 hover:bg-brand-500/14"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-300">
|
||||
<Repeat className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold text-white">Pro každou položku</span>
|
||||
<span className="mt-0.5 block text-sm text-white/55">
|
||||
Projde seznam v přijatých datech, například položky objednávky, a za každou
|
||||
položku vykoná vnořené kroky. Výsledky se posbírají, takže se dají použít
|
||||
v dalším kroku.
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { AlertTriangle, Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
|
||||
import type { Service, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
import type {
|
||||
Service,
|
||||
FieldType,
|
||||
FlowTrigger,
|
||||
ModelNode,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
@@ -15,14 +30,20 @@ export function TriggerConfig({
|
||||
trigger,
|
||||
connector,
|
||||
webhookBaseUrl,
|
||||
model = [],
|
||||
onChangeFields,
|
||||
onChangeSample,
|
||||
onRegenerateToken,
|
||||
regenerating,
|
||||
}: {
|
||||
trigger: FlowTrigger;
|
||||
connector: Service | undefined;
|
||||
webhookBaseUrl: string;
|
||||
/** Cesty odvozene z ukazky. Pocita je server pri cteni automatizace. */
|
||||
model?: ModelNode[];
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
/** Ulozi vlepenou ukazku tela. Chybi tam, kde ukazka nedava smysl. */
|
||||
onChangeSample?: (sample: unknown) => void;
|
||||
onRegenerateToken: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
@@ -53,6 +74,16 @@ export function TriggerConfig({
|
||||
) : (
|
||||
<CustomFields fields={trigger.fields} editable={editable} onChange={onChangeFields} />
|
||||
)}
|
||||
|
||||
{editable && onChangeSample && (
|
||||
<SampleBody
|
||||
sample={trigger.sample}
|
||||
model={model}
|
||||
fields={trigger.fields}
|
||||
onChange={onChangeSample}
|
||||
onChangeFields={onChangeFields}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -432,3 +463,139 @@ ${exampleBody(fields)}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(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 (
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-900/40 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold tracking-wide text-white/45 uppercase transition-colors hover:text-white/70"
|
||||
>
|
||||
<ChevronRight className={cn('size-3.5 transition-transform', open && 'rotate-90')} />
|
||||
Ukázka těla
|
||||
{model.length > 0 && (
|
||||
<span className="font-normal normal-case">{model.length} cest</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={save}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
placeholder={'{\n "data": {\n "order": { "code": "2026000123" }\n }\n}'}
|
||||
className={cn(
|
||||
'mt-2 w-full rounded-lg border bg-ink-950/60 px-3 py-2 font-mono text-xs text-white/80 placeholder:text-white/20 focus:outline-none',
|
||||
problem ? 'border-danger-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
/>
|
||||
|
||||
{problem && <p className="mt-1 text-xs text-danger-400">{problem}</p>}
|
||||
|
||||
{model.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={deriveFields}
|
||||
className="rounded-lg border border-brand-400/40 px-3 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
|
||||
>
|
||||
Doplnit parametry pro podmínky
|
||||
</button>
|
||||
<span className="text-xs text-white/30">
|
||||
Podmínka se ptá na parametr, ne na cestu. Tímhle se z hodnot v ukázce udělají
|
||||
parametry.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function Panel({
|
||||
function Broken({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="rounded-card border border-danger-400/40 bg-danger-500/8 p-5 text-sm text-danger-400">
|
||||
Widget „{name}" se nepodařilo zobrazit.
|
||||
Widget "{name}" se nepodařilo zobrazit.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+97
-22
@@ -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<FieldType, string> = {
|
||||
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<string> = 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 {
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
</section>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<void>(`/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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function TicketDetail() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user