| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import type { Cond } from "./automationApi"; |
| import { groupParts, isCondGroup } from "./automationApi"; |
|
|
| interface Field { |
| key: string; |
| label: string; |
| type?: string; |
| } |
|
|
| interface Props { |
| |
| cond: Cond | null; |
| onChange: (next: Cond | null) => void; |
| fields: Field[]; |
| |
| ops: string[]; |
| |
| nullaryOps: string[]; |
| maxDepth: number; |
| maxChildren: number; |
| |
| lead?: string; |
| disabled?: boolean; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const OP_WORDS: Record<string, string> = { |
| "=": "is", |
| "!=": "is not", |
| ">": "is greater than", |
| ">=": "is at least", |
| "<": "is less than", |
| "<=": "is at most", |
| includes: "contains", |
| not_includes: "does not contain", |
| is_empty: "is empty", |
| is_not_empty: "is not empty", |
| }; |
|
|
| export function opWord(op: string): string { |
| return OP_WORDS[op] || op; |
| } |
|
|
| |
| function newLeaf(ops: string[]): Cond { |
| return { field: "", op: ops[0] || "", value: "" }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function pack(join: "all" | "any", children: Cond[]): Cond | null { |
| if (!children.length) return null; |
| if (children.length === 1 && !isCondGroup(children[0])) return children[0]; |
| return join === "all" ? { all: children } : { any: children }; |
| } |
|
|
| |
| export function condComplete(cond: Cond | null | undefined, nullaryOps: string[]): boolean { |
| if (!cond) return true; |
| if (isCondGroup(cond)) { |
| const { children } = groupParts(cond); |
| return children.length > 0 && children.every((c) => condComplete(c, nullaryOps)); |
| } |
| const leaf = cond as { field: string; op: string; value?: string | number }; |
| if (!leaf.field || !leaf.op) return false; |
| if (nullaryOps.includes(leaf.op)) return true; |
| return leaf.value !== undefined && String(leaf.value).trim() !== ""; |
| } |
|
|
| export default function CondBuilder({ |
| cond, |
| onChange, |
| fields, |
| ops, |
| nullaryOps, |
| maxDepth, |
| maxChildren, |
| lead = "When", |
| disabled, |
| }: Props) { |
| |
| |
| const { join, children } = isCondGroup(cond) |
| ? groupParts(cond) |
| : { join: "all" as const, children: cond ? [cond] : [] }; |
|
|
| const emit = (nextJoin: "all" | "any", next: Cond[]) => onChange(pack(nextJoin, next)); |
| const replace = (i: number, next: Cond) => |
| emit(join, children.map((c, j) => (j === i ? next : c))); |
|
|
| return ( |
| <div className="autoc-tree"> |
| {children.map((child, i) => ( |
| /* |
| ⚠ THE KEY CARRIES THE ROW COUNT, and that is what makes the uncontrolled value input |
| above safe. Keyed by index alone, removing row 0 would REUSE row 0's DOM node for what |
| used to be row 1 — and an uncontrolled input keeps its own DOM value, so the reader |
| would be looking at the deleted row's text under the surviving row's field. Changing |
| the count remounts the whole list, which discards every stale DOM value at exactly the |
| moment the structure changes. |
| */ |
| <div className="autoc-row" key={`${children.length}:${i}`}> |
| <button |
| type="button" |
| className="autoc-drop" |
| disabled={disabled} |
| aria-label="Remove this condition" |
| title="Remove this condition" |
| onClick={() => emit(join, children.filter((_c, j) => j !== i))} |
| > |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| <path |
| d="M4 4l8 8M12 4l-8 8" |
| stroke="currentColor" |
| strokeWidth="1.5" |
| strokeLinecap="round" |
| /> |
| </svg> |
| </button> |
| |
| {/* |
| THE JOIN IS THE GROUP'S, AND IT IS EDITED IN EXACTLY ONE PLACE. Row 2 carries the |
| dropdown; every later row prints the word it chose. Giving each row its own |
| and/or select would offer a mixed tree the shape cannot express — `{all: […]}` has |
| one join — and the user would discover that only when their third row silently |
| behaved like the second's. |
| */} |
| <span className="autoc-join"> |
| {i === 0 ? ( |
| lead |
| ) : i === 1 ? ( |
| <select |
| className="auto-input is-tiny" |
| value={join} |
| disabled={disabled} |
| aria-label="Match all or any of these conditions" |
| onChange={(e) => emit(e.target.value === "any" ? "any" : "all", children)} |
| > |
| <option value="all">and</option> |
| <option value="any">or</option> |
| </select> |
| ) : ( |
| <span className="autoc-join-word">{join === "all" ? "and" : "or"}</span> |
| )} |
| </span> |
| |
| {isCondGroup(child) ? ( |
| <div className="autoc-nest"> |
| <CondBuilder |
| cond={child} |
| onChange={(next) => |
| next |
| ? replace(i, next) |
| : emit(join, children.filter((_c, j) => j !== i)) |
| } |
| fields={fields} |
| ops={ops} |
| nullaryOps={nullaryOps} |
| maxDepth={maxDepth - 1} |
| maxChildren={maxChildren} |
| lead="" |
| {...(disabled ? { disabled: true } : {})} |
| /> |
| </div> |
| ) : ( |
| <Leaf |
| leaf={child as { field: string; op: string; value?: string | number }} |
| fields={fields} |
| ops={ops} |
| nullaryOps={nullaryOps} |
| {...(disabled ? { disabled: true } : {})} |
| onChange={(next) => replace(i, next)} |
| /> |
| )} |
| </div> |
| ))} |
| |
| <div className="autoc-adds"> |
| <button |
| type="button" |
| className="autoc-add" |
| disabled={disabled || children.length >= maxChildren} |
| title={ |
| children.length >= maxChildren |
| ? `This group holds at most ${maxChildren} conditions.` |
| : undefined |
| } |
| onClick={() => emit(join, [...children, newLeaf(ops)])} |
| > |
| + Add condition |
| </button> |
| {/* Nesting is offered only while the SERVER's depth allows it — the ceiling rides the |
| payload, so this control disappears at the same depth `clean_cond` refuses. */} |
| {maxDepth > 1 ? ( |
| <button |
| type="button" |
| className="autoc-add" |
| disabled={disabled || children.length >= maxChildren} |
| onClick={() => emit(join, [...children, { all: [newLeaf(ops)] }])} |
| > |
| + Add condition group |
| </button> |
| ) : null} |
| </div> |
| |
| {/* THE RED LINE (image 4). A statement about the tree, not a wall in front of Save. */} |
| {!condComplete(pack(join, children), nullaryOps) ? ( |
| <p className="autoc-bad">A condition is incomplete or invalid.</p> |
| ) : null} |
| </div> |
| ); |
| } |
|
|
| function Leaf({ |
| leaf, |
| fields, |
| ops, |
| nullaryOps, |
| disabled, |
| onChange, |
| }: { |
| leaf: { field: string; op: string; value?: string | number }; |
| fields: Field[]; |
| ops: string[]; |
| nullaryOps: string[]; |
| disabled?: boolean; |
| onChange: (next: Cond) => void; |
| }) { |
| const nullary = nullaryOps.includes(leaf.op); |
| return ( |
| <> |
| <select |
| className="auto-input is-tiny" |
| value={leaf.field} |
| disabled={disabled} |
| aria-label="Field" |
| onChange={(e) => onChange({ ...leaf, field: e.target.value })} |
| > |
| <option value="">Choose a field…</option> |
| {/* |
| ⚠ THE STORED VALUE IS ALWAYS AN OPTION. A <select> whose `value` matches no <option> |
| renders the FIRST one, so a condition on a column this list has not caught up with |
| would LOOK like a condition on a different column — and the next Save would write that |
| different column without anybody choosing it. This repo has paid for that twice |
| ([[cg-condition-builder-items]]). |
| */} |
| {leaf.field && !fields.some((f) => f.key === leaf.field) ? ( |
| <option value={leaf.field}>{leaf.field} (not in this database)</option> |
| ) : null} |
| {fields.map((f) => ( |
| <option key={f.key} value={f.key}> |
| {f.label} |
| </option> |
| ))} |
| </select> |
|
|
| <select |
| className="auto-input is-tiny" |
| value={leaf.op} |
| disabled={disabled} |
| aria-label="Comparison" |
| onChange={(e) => { |
| const op = e.target.value; |
| // Moving to a value-free comparison DROPS the value rather than keeping it out of |
| // sight: a stored `value` under `is_empty` is a fact the sentence does not show and |
| // the next operator change would silently resurrect. |
| onChange(nullaryOps.includes(op) ? { field: leaf.field, op } : { ...leaf, op }); |
| }} |
| > |
| {leaf.op && !ops.includes(leaf.op) ? ( |
| <option value={leaf.op}>{opWord(leaf.op)} (not offered here)</option> |
| ) : null} |
| {ops.map((op) => ( |
| <option key={op} value={op}> |
| {opWord(op)} |
| </option> |
| ))} |
| </select> |
|
|
| {nullary ? null : ( |
| /* |
| ⛔ FREE TEXT COMMITS ON BLUR, like every other free-text field in this builder — and |
| the controlled version this replaced was worse than merely chatty. `onChange` reached |
| `patchTrigger` → PATCH, so typing "New" fired THREE requests; the displayed value |
| could not advance until each round-trip landed; and `disabled={busy}` disabled the |
| input under the typist's fingers. The two selects beside it stay controlled because |
| each is ONE discrete decision, which is exactly the distinction the cron field made |
| (AutomationTrigger's note) and the reason this input is not one. |
| */ |
| <input |
| className="auto-input is-tiny autoc-value" |
| defaultValue={leaf.value === undefined ? "" : String(leaf.value)} |
| disabled={disabled} |
| aria-label="Value" |
| placeholder="Value" |
| onBlur={(e) => { |
| if (e.target.value !== String(leaf.value ?? "")) onChange({ ...leaf, value: e.target.value }); |
| }} |
| /> |
| )} |
| </> |
| ); |
| } |
|
|