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
@@ -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