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:
JiriUhlir
2026-08-20 06:53:10 +02:00
co-authored by Claude Opus 5
parent fc0c2ff6c7
commit 9b18531d3e
25 changed files with 1299 additions and 124 deletions
@@ -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
View File
@@ -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 {
+10
View File
@@ -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>
+34 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
)}
+28
View File
@@ -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;
}