Podminka se muze ptat na vic veci naraz

Podminka byla prave jedna otazka. Slozitejsi vetveni se muselo skladat
z vnorenych podminek, takze "vysledek dorazil" a v nem "vysledek je X" byly dve
urovne stromu misto jedne vety. U tri hodnot, ktere maji dopadnout stejne, to
byly tri urovne.

Model: rules (otazky) a match ('all' = a zaroven, 'any' = nebo). Stara podoba
fieldId/operator/value primo na kroku se dal cte, prevadi ji rulesOf - jedine
misto, kde se to deje. Kdyby se fieldId cetlo primo, krok ulozeny driv by po
zmene modelu prisel o svou otazku a vetvil by vzdycky stejne, tise a bez chyby.
Zapisuje se uz vzdycky rules.

fieldId je v typu nepovinne schvalne: prekladac tim ukazal vsech pet mist,
ktera podminku ctou (vyhodnoceni, obe hlasky do logu, kontrola pri ukladani,
kontrola nedodelku).

Proc jedna uroven a ne vyrazy se zavorkami: dve treti podminek jsou "vsechny
tohle" nebo "cokoliv z tohohle". Zavorky by v rozhrani znamenaly editor vyrazu,
ktery uz nikdo neuklika, a textovy zapis by navic zahodil odkaz na parametr pres
ID - diky nemu prejmenovani parametru podminku nerozbije a builder umi nabidnout
jen operatory, ktere na dany typ sedi. Az se ukaze, ze jedna uroven nestaci,
da se textovy zapis pridat nad tentyz vyhodnocovac. Opacne to nejde.

V builderu radek na otazku, tlacitko "Přidat otázku" a od druhe otazky prepinac
"sedí všechny" / "sedí aspoň jedna". Posledni otazka nejde smazat: podminka bez
otazky by tise nevetvila, runtime ji povazuje za nesplnenou a zaloguje to.

Radek podminky v logu nese vsechny otazky i s tim, ktera rozhodla - bez rozpadu
by u spojene podminky bylo videt jen "nesplneno".

Overeno na bezici instanci, strom se dvema spojenymi podminkami a peti volanimi:
vysledek nedorazil (nic), Chybějící informace (prirazeno), Přesměrování
a Mimo téma (zavreno pres any), Něco jiného (nic). Strom ulozeny ve stare
podobe se nacte a ulozi beze zmeny.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-07 09:16:19 +02:00
co-authored by Claude Opus 5
parent 1532ea0af7
commit 9f4b8c0596
9 changed files with 507 additions and 144 deletions
+174 -72
View File
@@ -17,16 +17,20 @@ import { serviceIcon } from '@/lib/serviceIcons';
import {
collectScopes,
defaultOperatorFor,
describeCondition,
describeRules,
isUnaryOperator,
matchOf,
operatorLabel,
operatorsByType,
resolveOperation,
rulesOf,
type FlowPath,
} from '@/lib/flow';
import type {
AutomationFlow,
ConditionMatch,
ConditionOperator,
ConditionRule,
ModelNode,
Service,
FlowStep,
@@ -58,7 +62,13 @@ interface CanvasCallbacks {
onRemoveStep: (stepId: string) => void;
onUpdateCondition: (
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
patch: {
rules?: ConditionRule[];
match?: ConditionMatch;
fieldId?: string;
operator?: ConditionOperator;
value?: string;
},
) => void;
onUpdateInputs: (stepId: string, inputs: Record<string, string>) => void;
/** Zmena cesty k seznamu u smycky. */
@@ -579,26 +589,59 @@ function ConditionCard({
}) {
const { onRemoveStep, onMoveStep, onUpdateCondition } = callbacks;
const field = fields.find((f) => f.id === step.fieldId);
const allowedOperators = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(step.operator);
const valueMissing = needsValue && (step.value ?? '').trim().length === 0;
/*
* Podminka se muze ptat na vic veci naraz.
*
* Zapisuje se vzdycky `rules`, i kdyz je otazka jedna - stara podoba se tim
* pri prvni uprave prevede a dal uz existuje jen jeden tvar.
*/
const rules = rulesOf(step);
const match = matchOf(step);
const missing = rules.some((rule) => {
const field = fields.find((f) => f.id === rule.fieldId);
return field && !isUnaryOperator(rule.operator) && (rule.value ?? '').trim().length === 0;
});
function writeRules(next: ConditionRule[], nextMatch: ConditionMatch = match) {
onUpdateCondition(step.id, { rules: next, match: nextMatch });
}
function changeRule(index: number, patch: Partial<ConditionRule>) {
writeRules(rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
}
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
function changeField(fieldId: string) {
function changeField(index: number, fieldId: string) {
const next = fields.find((f) => f.id === fieldId);
if (!next) {
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
return;
}
const operatorStillValid = operatorsByType[next.type].includes(step.operator);
onUpdateCondition(step.id, {
const rule = rules[index];
const stillValid = operatorsByType[next.type].includes(rule.operator);
changeRule(index, {
fieldId,
operator: operatorStillValid ? step.operator : defaultOperatorFor(next.type),
value: operatorStillValid ? step.value : '',
operator: stillValid ? rule.operator : defaultOperatorFor(next.type),
value: stillValid ? rule.value : '',
});
}
/** Nova otazka se pta na tentyz parametr jako ta prvni, aby nebyla prazdna. */
function addRule() {
const first = fields[0];
if (!first) return;
const base = fields.find((f) => f.id === rules[0]?.fieldId) ?? first;
writeRules([
...rules,
{ fieldId: base.id, operator: defaultOperatorFor(base.type), value: '' },
]);
}
function removeRule(index: number) {
// Posledni otazka se nemaze: podminka bez otazky by tise nevetvila.
if (rules.length <= 1) return;
writeRules(rules.filter((_, i) => i !== index));
}
return (
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
@@ -608,73 +651,132 @@ function ConditionCard({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Podmínka</p>
{valueMissing && <Badge tone="warn">Doplňte hodnotu</Badge>}
{missing && <Badge tone="warn">Doplňte hodnotu</Badge>}
</div>
{!field ? (
<p className="mt-1 text-sm text-danger-400">
Parametr, na který se podmínka odkazovala, už neexistuje. Vyberte jiný, nebo
podmínku odeberte.
</p>
) : (
<p className="mt-0.5 font-semibold break-words text-white">
{describeCondition(field, step.operator, step.value)}
</p>
<p className="mt-0.5 font-semibold break-words text-white">
{describeRules(step, fields)}
</p>
{/*
Spojka se nabizi az od druhe otazky. U jedne nema co spojovat
a prepinac, ktery nic nedela, jen mate.
*/}
{rules.length > 1 && (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-white/40">Platí, když</span>
{(['all', 'any'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => writeRules(rules, option)}
className={cn(
'rounded-lg px-2.5 py-1 text-xs font-medium transition-colors',
match === option
? 'bg-accent-500/20 text-accent-200'
: 'text-white/45 hover:text-white',
)}
>
{option === 'all' ? 'sedí všechny' : 'sedí aspoň jedna'}
</button>
))}
</div>
)}
{/* Vlastni editor: parametr - operator - hodnota */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<select
value={field ? step.fieldId : ''}
onChange={(event) => changeField(event.target.value)}
aria-label="Parametr"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white focus:border-accent-400/70 focus:outline-none"
>
{!field && (
<option value="" className="bg-ink-850">
- vyberte parametr -
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</select>
{/* Radek na otazku: parametr - operator - hodnota */}
<div className="mt-2.5 space-y-2">
{rules.map((rule, index) => {
const field = fields.find((f) => f.id === rule.fieldId);
const allowed = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(rule.operator);
const valueMissing = needsValue && (rule.value ?? '').trim().length === 0;
<select
value={step.operator}
onChange={(event) =>
onUpdateCondition(step.id, {
operator: event.target.value as ConditionOperator,
})
}
disabled={!field}
aria-label="Operátor"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-accent-400/70 focus:outline-none disabled:opacity-50"
>
{allowedOperators.map((operator) => (
<option key={operator} value={operator} className="bg-ink-850">
{field ? operatorLabel(operator, field.type) : operator}
</option>
))}
</select>
return (
<div key={index} className="flex flex-wrap items-center gap-2">
{index > 0 && (
<span className="w-16 shrink-0 text-xs text-white/35">
{match === 'any' ? 'nebo' : 'a zároveň'}
</span>
)}
{needsValue && field && (
<input
value={step.value ?? ''}
onChange={(event) => onUpdateCondition(step.id, { value: event.target.value })}
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
className={cn(
'w-32 rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none',
valueMissing
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-accent-400/70',
)}
/>
)}
<select
value={field ? rule.fieldId : ''}
onChange={(event) => changeField(index, event.target.value)}
aria-label="Parametr"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white focus:border-accent-400/70 focus:outline-none"
>
{!field && (
<option value="" className="bg-ink-850">
- vyberte parametr -
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</select>
<select
value={rule.operator}
onChange={(event) =>
changeRule(index, { operator: event.target.value as ConditionOperator })
}
disabled={!field}
aria-label="Operátor"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-accent-400/70 focus:outline-none disabled:opacity-50"
>
{allowed.map((operator) => (
<option key={operator} value={operator} className="bg-ink-850">
{field ? operatorLabel(operator, field.type) : operator}
</option>
))}
</select>
{needsValue && field && (
<input
value={rule.value ?? ''}
onChange={(event) => changeRule(index, { value: event.target.value })}
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
className={cn(
'w-32 rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none',
valueMissing
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-accent-400/70',
)}
/>
)}
{!field && (
<span className="text-xs text-danger-400">parametr už neexistuje</span>
)}
{rules.length > 1 && (
<button
type="button"
onClick={() => removeRule(index)}
aria-label="Odebrat otázku"
className="rounded-lg px-2 py-1.5 text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
);
})}
<button
type="button"
onClick={addRule}
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent-300 transition-colors hover:text-accent-200"
>
<Plus className="size-3.5" />
Přidat otázku
</button>
</div>
</div>
+47 -2
View File
@@ -1,6 +1,8 @@
import type {
AutomationFlow,
ConditionMatch,
ConditionOperator,
ConditionRule,
Service,
ServiceOperation,
FieldType,
@@ -125,7 +127,44 @@ export const fieldTypeLabels: Record<FieldType, string> = {
list: 'seznam',
};
/** Lidsky citelny zapis podminky, napr. "score je větší nebo rovno 15". */
/**
* Otazky podminky, at uz je krok v jakekoliv podobe.
*
* Jedine misto, kde se stara podoba prevadi. Zrcadli `rulesOf` na serveru,
* server je autorita.
*/
export function rulesOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionRule[] {
if (step.rules && step.rules.length > 0) return step.rules;
if (!step.fieldId || !step.operator) return [];
return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }];
}
/** Jak se otazky spoji. Jedna otazka nema co spojovat. */
export function matchOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionMatch {
return step.match ?? 'all';
}
/** Lidsky citelny zapis cele podminky, vcetne spojek. */
export function describeRules(
step: Extract<FlowStep, { kind: 'condition' }>,
fields: TriggerField[],
): string {
const rules = rulesOf(step);
if (rules.length === 0) return 'Bez otázky';
const spojka = matchOf(step) === 'any' ? ' nebo ' : ' a zároveň ';
return rules
.map((rule) =>
describeCondition(
fields.find((field) => field.id === rule.fieldId),
rule.operator,
rule.value,
),
)
.join(spojka);
}
/** Lidsky citelny zapis jedne otazky, napr. "score je větší nebo rovno 15". */
export function describeCondition(
field: TriggerField | undefined,
operator: ConditionOperator,
@@ -282,7 +321,13 @@ export function updateStepInputs(
export function updateCondition(
steps: FlowStep[],
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
patch: {
rules?: ConditionRule[];
match?: ConditionMatch;
fieldId?: string;
operator?: ConditionOperator;
value?: string;
},
): FlowStep[] {
return steps.map((step) => {
if (step.kind === 'foreach') {
+17 -3
View File
@@ -29,6 +29,16 @@ export interface Tenant {
name: string;
}
/** Jedna otazka podminky. Zrcadli src/data/conditions.ts. */
export interface ConditionRule {
fieldId: string;
operator: ConditionOperator;
value?: string;
}
/** Jak se otazky podminky spoji. `all` = a zaroven, `any` = nebo. */
export type ConditionMatch = 'all' | 'any';
/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */
export type TicketScope = 'all' | 'tenant' | 'mine';
@@ -597,9 +607,13 @@ export type FlowStep =
| {
id: string;
kind: 'condition';
/** id parametru z trigger.fields */
fieldId: string;
operator: ConditionOperator;
/** Otazky, ktere se ptaji naraz. Cte se vzdycky pres `rulesOf`. */
rules?: ConditionRule[];
/** `all` = a zaroven, `any` = nebo. */
match?: ConditionMatch;
/** Stara podoba: jedna otazka primo na kroku. Prevadi ji `rulesOf`. */
fieldId?: string;
operator?: ConditionOperator;
value?: string;
yes: FlowStep[];
no: FlowStep[];