From 9f4b8c05964e730f51a83dd6f94cbaed19f49aab Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:16:19 +0200 Subject: [PATCH] Podminka se muze ptat na vic veci naraz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- documentation/20-fronta-a-runtime.md | 19 +- documentation/99-zmeny.md | 69 +++++ src/data/automationStore.ts | 82 ++++-- src/data/conditions.ts | 24 ++ src/routes/dashboard.ts | 64 +++-- src/runtime/executor.ts | 78 ++++-- .../components/dashboard/flow/FlowCanvas.tsx | 246 +++++++++++++----- web/src/lib/flow.ts | 49 +++- web/src/types/dashboard.ts | 20 +- 9 files changed, 507 insertions(+), 144 deletions(-) diff --git a/documentation/20-fronta-a-runtime.md b/documentation/20-fronta-a-runtime.md index 7dbce46..74adbcb 100644 --- a/documentation/20-fronta-a-runtime.md +++ b/documentation/20-fronta-a-runtime.md @@ -222,7 +222,24 @@ result neq "Chybějící informace" znamena "vsechno ostatni **vcetne toho, co jeste nevime**". Prvni zprava hovoru vysledek nenese, podminka sedne a strom udela to, co mel udelat az na konci. -**Ptat se kladne a napred na existenci:** `isNotEmpty`, a teprve uvnitr `eq`. +**Ptat se kladne a napred na existenci:** `isNotEmpty`, a teprve pak `eq`. + +Od zari 2026 to nemusi byt dve vnorene podminky. Jedna podminka se muze ptat +na **vic veci naraz**: + +```ts +match: 'all' // a zaroven +rules: [ + { fieldId: 'f_result', operator: 'isNotEmpty' }, + { fieldId: 'f_result', operator: 'eq', value: 'Chybějící informace' }, +] +``` + +`any` je "nebo", tedy tri hodnoty, ktere maji dopadnout stejne, na jednom radku +misto tri urovni stromu. Je to **jedna uroven**, zavorky ne - viz zaznam zmen +k 7. 9. 2026. + +Stara podoba (`fieldId` primo na kroku) se dal cte, prevadi ji `rulesOf`. Radek podminky v logu proto nese i to, s cim se porovnavalo, a rozlisuje `nedorazilo` od `prázdné`: diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 6083655..e987fe8 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,75 @@ Nejnovejsi nahore. +## 2026-09-07 - 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, ve kterych se nikdo nevyzna. + +### Model + +```ts +rules: ConditionRule[] // otazky +match: 'all' | 'any' // a zaroven / 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`. + +Aby se na zadne cteni nezapomnelo, je `fieldId` v typu **nepovinne**. Prekladac +tim ukazal vsech pet mist, ktera podminku ctou: vyhodnoceni, obe hlasky do logu, +kontrola pri ukladani a 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 to podstatne: **odkaz na parametr pres ID**. Diky nemu prejmenovani +parametru podminku nerozbije a builder umi nabidnout jen operatory, ktere na dany +typ sedi, a rovnou rict, ze se odkazuje na parametr, ktery vznika az pozdeji. + +Az se ukaze, ze jedna uroven nestaci, da se textovy zapis pridat nad tentyz +vyhodnocovac. Opacne to nejde. + +### V builderu + +Radek na otazku, k tomu tlacitko "Přidat otázku" a od druhe otazky prepinac +**sedí všechny** nebo **sedí aspoň jedna**. U jedne otazky se prepinac nenabizi, +nema co spojovat. Posledni otazka nejde smazat: podminka bez otazky by tise +nevetvila, proto ji runtime rovnou povazuje za nesplnenou a zaloguje to. + +### V logu + +Radek podminky nese vsechny otazky i s tim, ktera rozhodla: + +```text +Podmínka: result isNotEmpty a zároveň result eq Chybějící informace: nesplněno + result = "Přesměrování": sedí + result = "Přesměrování", porovnáno s "Chybějící informace": nesedí +``` + +Bez rozpadu by u spojene podminky bylo videt jen "nesplneno" a ne to, ktera +otazka to zpusobila. + +### Overeno na bezici instanci + +Strom se dvema spojenymi podminkami, pet prichozich volani: + +| Co prislo | Prirazeno | Vyrizeny | +| --------------------------- | ------------- | -------- | +| vysledek nedorazil | ne | ne | +| Chybějící informace | ano | ne | +| Přesměrování | ne | ano | +| Mimo téma | ne | ano | +| Něco jiného | ne | ne | + +Prvni podminka je `all` (dorazil a zaroven je to Chybejici informace), druha +`any` (Přesměrování nebo Vyřešeno nebo Mimo téma). Strom ulozeny ve stare podobe +se nacte a ulozi beze zmeny. ## 2026-09-07 - Automatizace zavirala tickety uz pri zvoneni Na instanci nebyl ani jeden nevyrizeny ticket: **vsech 131 melo `closed: true`**, diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index edf761e..255a688 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -11,7 +11,13 @@ import { config } from '../config.js'; import { publish } from '../events/bus.js'; import { defineStore } from './store/index.js'; import { withMirror } from './store/mirror.js'; -import { isUnary, type ConditionOperator, type FieldType } from './conditions.js'; +import { + isUnary, + type ConditionMatch, + type ConditionOperator, + type ConditionRule, + type FieldType, +} from './conditions.js'; import { actionInputsFor, findService } from './services.js'; import { collectScopes, duplicateNames, scopeFor } from './flowScope.js'; import { referencedFields, rootOf } from './templates.js'; @@ -97,10 +103,24 @@ export type FlowStep = | { id: string; kind: 'condition'; - /** id parametru z trigger.fields */ - fieldId: string; - operator: ConditionOperator; - /** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */ + /** + * Otazky, ktere se ptaji naraz. Cte se **vzdycky pres `rulesOf`**. + * + * Driv byla podminka prave jedna otazka a slozitejsi vetveni se skladalo + * z vnorenych podminek: "vysledek dorazil" a v nem "vysledek je X" byly + * dve urovne stromu misto jedne vety. + */ + rules?: ConditionRule[]; + /** `all` = a zaroven, `any` = nebo. Chybi u jedne otazky, tam nema co spojovat. */ + match?: ConditionMatch; + /** + * Stara podoba: jedna otazka primo na kroku. + * + * Zaznamy ulozene driv ji maji misto `rules`. Prevadi ji `rulesOf`, + * zapisuje se uz vzdycky `rules`. + */ + fieldId?: string; + operator?: ConditionOperator; value?: string; yes: FlowStep[]; no: FlowStep[]; @@ -130,6 +150,24 @@ export interface AutomationFlow { steps: FlowStep[]; } +/** + * Otazky podminky, at uz je krok v jakekoliv podobe. + * + * **Jedine misto, kde se stara podoba prevadi.** 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. + */ +export function rulesOf(step: Extract): 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, bere se `all`. */ +export function matchOf(step: Extract): ConditionMatch { + return step.match ?? 'all'; +} + /** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */ export function generateWebhookToken(): string { return randomBytes(24).toString('base64url'); @@ -463,20 +501,26 @@ export function collectFlowIssues(flow: AutomationFlow): string[] { continue; } - const field = available.find((candidate) => candidate.id === step.fieldId); - if (!field) { - // Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane - // po presunuti kroku a chce jinou radu nez smazat podminku. - issues.push( - scopes.all.has(step.fieldId) - ? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.' - : 'Podmínka se odkazuje na parametr, který už neexistuje.', - ); - } else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) { - issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`); - } else if (field.type === 'number' && !isUnary(step.operator)) { - if (Number.isNaN(Number(step.value))) { - issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`); + // Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni. + const rules = rulesOf(step); + if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.'); + + for (const rule of rules) { + const field = available.find((candidate) => candidate.id === rule.fieldId); + if (!field) { + // Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane + // po presunuti kroku a chce jinou radu nez smazat podminku. + issues.push( + scopes.all.has(rule.fieldId) + ? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.' + : 'Podmínka se odkazuje na parametr, který už neexistuje.', + ); + } else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) { + issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`); + } else if (field.type === 'number' && !isUnary(rule.operator)) { + if (Number.isNaN(Number(rule.value))) { + issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`); + } } } diff --git a/src/data/conditions.ts b/src/data/conditions.ts index a87d379..fff04cd 100644 --- a/src/data/conditions.ts +++ b/src/data/conditions.ts @@ -70,6 +70,30 @@ export const operatorsByType: Record = { list: ['contains', 'isEmpty', 'isNotEmpty'], }; +/** + * Jedna otazka v podmince. + * + * Driv byla podminka **prave jedna otazka** a slozitejsi vetveni se muselo + * skladat z vnorenych podminek. U tri hodnot, ktere maji dopadnout stejne, to + * znamenalo tri urovne stromu, ve kterych se nikdo nevyzna. + */ +export interface ConditionRule { + /** ID parametru spoustece, nebo `krok.vystup`. */ + fieldId: string; + operator: ConditionOperator; + /** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */ + value?: string; +} + +/** + * Jak se otazky spoji. + * + * `all` = a zaroven, `any` = nebo. Zamerne **jedna uroven**, ne strom se + * zavorkami: dve treti podminek jsou "vsechny tohle" nebo "cokoliv z tohohle" + * a zavorky by v rozhrani znamenaly editor vyrazu, ktery uz nikdo neuklika. + */ +export type ConditionMatch = 'all' | 'any'; + /** Operatory, ktere nepotrebuji hodnotu k porovnani. */ export const unaryOperators: ConditionOperator[] = [ 'isEmpty', diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index d65cd3a..9cd3f62 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -14,6 +14,7 @@ import { getAutomation, listAutomations, regenerateWebhookToken, + rulesOf, updateAutomation, type FlowStep, } from '../data/automationStore.js'; @@ -1083,6 +1084,29 @@ dashboardRouter.get('/services', async (req, res) => { // ------------------------------------------------------------- automatizace +/** Operator podminky. Vyctem, ne volnym retezcem - neznamy by tise nevetvil. */ +const conditionOperatorSchema = z.enum([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', +]); + +/** Jedna otazka podminky. Podminka jich muze mit vic, spojene pres `match`. */ +const conditionRuleSchema = z.object({ + fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'), + operator: conditionOperatorSchema, + value: z.string().optional(), +}); + /** * Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje * dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje. @@ -1109,21 +1133,14 @@ const stepSchema: z.ZodType = z.lazy(() => z.object({ id: z.string().min(1), kind: z.literal('condition'), - fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'), - operator: z.enum([ - 'eq', - 'neq', - 'gt', - 'gte', - 'lt', - 'lte', - 'contains', - 'startsWith', - 'isEmpty', - 'isNotEmpty', - 'isTrue', - 'isFalse', - ]), + /* + * Otazky podminky. `rules` je dnesni podoba, `fieldId` a spol. stara - + * strom ulozeny driv musi jit ulozit znovu, aniz by se prepisoval. + */ + rules: z.array(conditionRuleSchema).max(10).optional(), + match: z.enum(['all', 'any']).optional(), + fieldId: z.string().min(1).optional(), + operator: conditionOperatorSchema.optional(), value: z.string().optional(), yes: z.array(stepSchema), no: z.array(stepSchema), @@ -1243,13 +1260,16 @@ async function validateFlowReferences( const walk = async (steps: FlowStep[]): Promise => { for (const step of steps) { if (step.kind === 'condition') { - const field = scopes.all.get(step.fieldId); - if (!field) { - problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`); - } else if (!operatorAllowedForType(step.operator, field.type)) { - problems.push( - `Operátor "${step.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`, - ); + // Kazda otazka zvlast, jinak by prvni spatna schovala ostatni. + for (const rule of rulesOf(step)) { + const field = scopes.all.get(rule.fieldId); + if (!field) { + problems.push(`Podmínka odkazuje na neexistující parametr (${rule.fieldId}).`); + } else if (!operatorAllowedForType(rule.operator, field.type)) { + problems.push( + `Operátor "${rule.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`, + ); + } } await walk(step.yes); await walk(step.no); diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 082312d..8cfaaa1 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -22,8 +22,8 @@ */ import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js'; -import type { FlowStep } from '../data/automationStore.js'; -import { isUnary, type ConditionOperator } from '../data/conditions.js'; +import { matchOf, rulesOf, type FlowStep } from '../data/automationStore.js'; +import { isUnary, type ConditionOperator, type ConditionRule } from '../data/conditions.js'; import { escapeHtml, renderTemplate } from '../data/templates.js'; import { actionInputsFor, type OperationField } from '../data/services.js'; import { getPath } from '../scripts/mapping.js'; @@ -543,33 +543,56 @@ function conditionDetail( context: RunContext, options: RunOptions, ): string { - const raw = conditionValue(step.fieldId, context, options); - const name = options.fieldNames?.[step.fieldId] ?? step.fieldId; + // Kazda otazka na svem radku, i s tim, jak dopadla. U spojenych podminek + // je jinak videt jen vysledek a ne to, ktera z nich rozhodla. + return rulesOf(step) + .map((rule) => { + const raw = conditionValue(rule.fieldId, context, options); + const name = options.fieldNames?.[rule.fieldId] ?? rule.fieldId; - const got = - raw === undefined - ? 'nedorazilo' - : raw === null - ? 'null' - : stringify(raw) === '' - ? 'prázdné' - : JSON.stringify(stringify(raw)); + const got = + raw === undefined + ? 'nedorazilo' + : raw === null + ? 'null' + : stringify(raw) === '' + ? 'prázdné' + : JSON.stringify(stringify(raw)); - if (isUnary(step.operator)) return `${name} = ${got}`; - return `${name} = ${got}, porovnáno s ${JSON.stringify(step.value ?? '')}`; + const sedi = matchRule(rule, context, options) ? 'sedí' : 'nesedí'; + if (isUnary(rule.operator)) return `${name} = ${got}: ${sedi}`; + return `${name} = ${got}, porovnáno s ${JSON.stringify(rule.value ?? '')}: ${sedi}`; + }) + .join(String.fromCharCode(10)); } -/** Vyhodnoti podminku nad kontextem. */ +/** Jedna otazka podminky. */ +function matchRule(rule: ConditionRule, context: RunContext, options: RunOptions): boolean { + const raw = conditionValue(rule.fieldId, context, options); + const value = raw === undefined || raw === null ? '' : stringify(raw); + return compare(rule.operator, value, rule.value ?? ''); +} + +/** + * Vyhodnoti podminku nad kontextem. + * + * `all` = musi sedet vsechny, `any` = staci jedna. Podminka **bez otazky** + * neplati: tise vetvit vzdycky stejne je horsi nez neudelat nic. + */ function evaluate( step: Extract, context: RunContext, options: RunOptions, ): boolean { - const raw = conditionValue(step.fieldId, context, options); - const value = raw === undefined || raw === null ? '' : stringify(raw); - const expected = step.value ?? ''; + const rules = rulesOf(step); + if (rules.length === 0) { + console.warn(`[runtime] podminka ${step.id} nema zadnou otazku, nesplnena`); + return false; + } - return compare(step.operator, value, expected); + return matchOf(step) === 'any' + ? rules.some((rule) => matchRule(rule, context, options)) + : rules.every((rule) => matchRule(rule, context, options)); } /** @@ -622,13 +645,18 @@ function describeCondition( context: RunContext, options: RunOptions, ): string { - const raw = conditionValue(step.fieldId, context, options); - const value = raw === undefined || raw === null ? '(prázdné)' : stringify(raw); - // V logu ma stat jmeno, ktere clovek zna z builderu, ne vnitrni ID. - const name = options.fieldNames?.[step.fieldId] ?? nameOf(step.fieldId); + const rules = rulesOf(step); + if (rules.length === 0) return 'Podmínka bez otázky'; - if (isUnary(step.operator)) return `Podmínka: ${name} ${step.operator} (${value})`; - return `Podmínka: ${name} ${step.operator} ${step.value ?? ''} (${value})`; + const parts = rules.map((rule) => { + // V logu ma stat jmeno, ktere clovek zna z builderu, ne vnitrni ID. + const name = options.fieldNames?.[rule.fieldId] ?? nameOf(rule.fieldId); + if (isUnary(rule.operator)) return `${name} ${rule.operator}`; + return `${name} ${rule.operator} ${rule.value ?? ''}`; + }); + + const spojka = matchOf(step) === 'any' ? ' nebo ' : ' a zároveň '; + return `Podmínka: ${parts.join(spojka)}`; } /** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */ diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index 1a6ee94..d88c9fa 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -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) => 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) { + 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 (
@@ -608,73 +651,132 @@ function ConditionCard({

Podmínka

- {valueMissing && Doplňte hodnotu} + {missing && Doplňte hodnotu}
- {!field ? ( -

- Parametr, na který se podmínka odkazovala, už neexistuje. Vyberte jiný, nebo - podmínku odeberte. -

- ) : ( -

- {describeCondition(field, step.operator, step.value)} -

+

+ {describeRules(step, fields)} +

+ + {/* + Spojka se nabizi az od druhe otazky. U jedne nema co spojovat + a prepinac, ktery nic nedela, jen mate. + */} + {rules.length > 1 && ( +
+ Platí, když + {(['all', 'any'] as const).map((option) => ( + + ))} +
)} - {/* Vlastni editor: parametr - operator - hodnota */} -
- + {/* Radek na otazku: parametr - operator - hodnota */} +
+ {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; - + return ( +
+ {index > 0 && ( + + {match === 'any' ? 'nebo' : 'a zároveň'} + + )} - {needsValue && field && ( - 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', - )} - /> - )} + + + + + {needsValue && field && ( + 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 && ( + parametr už neexistuje + )} + + {rules.length > 1 && ( + + )} +
+ ); + })} + +
diff --git a/web/src/lib/flow.ts b/web/src/lib/flow.ts index f418521..52bb6a0 100644 --- a/web/src/lib/flow.ts +++ b/web/src/lib/flow.ts @@ -1,6 +1,8 @@ import type { AutomationFlow, + ConditionMatch, ConditionOperator, + ConditionRule, Service, ServiceOperation, FieldType, @@ -125,7 +127,44 @@ export const fieldTypeLabels: Record = { 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): 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): ConditionMatch { + return step.match ?? 'all'; +} + +/** Lidsky citelny zapis cele podminky, vcetne spojek. */ +export function describeRules( + step: Extract, + 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') { diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 50753c6..0f6e7b5 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -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[];