diff --git a/.changeset/11894-typed-empty-ops.md b/.changeset/11894-typed-empty-ops.md new file mode 100644 index 0000000000..b1094a3e80 --- /dev/null +++ b/.changeset/11894-typed-empty-ops.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio's condition builder no longer writes `!field` or a bare `field` for a field that is not a boolean, when it knows the field's type (objectui#11894). + +Its two operators that take no value compiled to a bare field and to `!` plus the field, whatever the field was. CEL's `!` and a bare `&&` operand take a boolean, so on a date, text or lookup field the condition could not be evaluated, and a server-evaluated condition refused the write it guarded. A validation rule built on the Studio Rules tab as *status equals done AND due date is empty / false* saved as `record.status == 'done' && !record.due_date`, and every write to a done record was then refused (`no such overload: !null`, or `!string` when a due date was set). + +The two operators now follow the field's declared type. On a boolean field they read *is true* / *is false* and compile as before. On any other field they read *is not empty* / *is empty* and compile to a null-or-empty check, by the row of the platform's "is empty" table the field takes (`expandEmptyOperator` from `@objectstack/spec`): null only (`== null`) for a date, number or single-value lookup; null or `''` for a text field; null or an empty list (`size(...) == 0`) for a tags, multi-select or checkboxes field, and for a lookup, user, select or file field declared `multiple` where the editor's field list carries that flag. A subject the builder cannot type (a context value such as `user.isAdmin`, a flow variable, or a formula field, whose value takes its return type) keeps the words and the form it had. A new row starts on *equals* rather than on *is set / true*. + +The Studio Rules tab now hands the builder each field's type and `multiple` flag, so the rule above is saved as `record.status == 'done' && record.due_date == null`. The other editors that mount the builder (hook conditions, action visibility, page-block visibility, flow conditions and schema-form condition fields) already passed the type, but not `multiple`, so there a lookup declared `multiple` is checked as a single value. + +A condition stored by the old builder as `!field` on a typed non-boolean field is not rewritten: it opens in the expression editor as written. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 95ede5c57a..36e90ae5f3 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -634,6 +634,12 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.condition.op.lessThan': 'less than', 'engine.inspector.condition.op.truthy': 'is set / true', 'engine.inspector.condition.op.falsy': 'is empty / false', + // objectui#11894 — the value-less pair, worded by the subject's declared + // type; the two rows above stay for a subject the catalog does not type. + 'engine.inspector.condition.op.isTrue': 'is true', + 'engine.inspector.condition.op.isFalse': 'is false', + 'engine.inspector.condition.op.isNotEmpty': 'is not empty', + 'engine.inspector.condition.op.isEmpty': 'is empty', // objectui#10748 — the lookup filter's operator words the four above do not // cover (`ObjectFieldInspector` `LOOKUP_OPERATORS`, which reads the four as // well; the symbol a label leads with stays outside the word). @@ -3942,6 +3948,10 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.condition.op.lessThan': '小于', 'engine.inspector.condition.op.truthy': '已设置 / 为真', 'engine.inspector.condition.op.falsy': '为空 / 为假', + 'engine.inspector.condition.op.isTrue': '为真', + 'engine.inspector.condition.op.isFalse': '为假', + 'engine.inspector.condition.op.isNotEmpty': '不为空', + 'engine.inspector.condition.op.isEmpty': '为空', 'engine.inspector.condition.op.atLeast': '至少', 'engine.inspector.condition.op.atMost': '至多', 'engine.inspector.condition.op.contains': '包含', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx index 95b0d6b811..6121d1e240 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx @@ -13,6 +13,12 @@ * parses AND round-trips byte-for-byte (whitespace-normalised). Anything it * can't round-trip cleanly opens in a raw expression textarea, so hand-authored * complex CEL is never silently rewritten. + * + * The two operators that take no value are TYPED by the field catalog + * (objectui#11894): on a boolean field they are "is true" / "is false" and + * compile to the bare subject and its negation, as they always did; on any + * other declared field they are "is not empty" / "is empty" and compile to a + * null-or-empty check — see {@link compileRow}. */ import * as React from 'react'; @@ -21,6 +27,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from '@object-ui/components'; import { Plus, X, Code2, ListFilter } from 'lucide-react'; +import { BOOLEAN_VALUE_TYPES, expandEmptyOperator, type EmptyOperatorArm } from '@objectstack/spec/data'; import { useObjectFields } from '../previews/useObjectFields.js'; import { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; @@ -35,19 +42,127 @@ type Quote = '"' | "'"; interface Row { subject: string; op: Op; value: string; quote?: Quote } -/** Operator choices: a catalogue key for the worded ones, a bare symbol for - * `≥` / `≤`, which read the same in every locale (objectui#10586). */ -const COMPARE_OPS: ReadonlyArray<{ value: Op; labelKey: string } | { value: Op; symbol: string }> = [ +type OpChoice = { value: Op; labelKey: string } | { value: Op; symbol: string }; + +/** The operators that compare with a value: a catalogue key for the worded + * ones, a bare symbol for `≥` / `≤`, which read the same in every locale + * (objectui#10586). */ +const COMPARE_OPS: ReadonlyArray = [ { value: '==', labelKey: 'engine.inspector.condition.op.equals' }, { value: '!=', labelKey: 'engine.inspector.condition.op.notEquals' }, { value: '>', labelKey: 'engine.inspector.condition.op.greaterThan' }, { value: '<', labelKey: 'engine.inspector.condition.op.lessThan' }, { value: '>=', symbol: '≥' }, { value: '<=', symbol: '≤' }, - { value: 'truthy', labelKey: 'engine.inspector.condition.op.truthy' }, - { value: 'falsy', labelKey: 'engine.inspector.condition.op.falsy' }, ]; +/** + * What the field catalog DECLARES about a subject's value (objectui#11894): + * `'boolean'`, or the row of the spec's ruled 「is empty」 table the field + * takes. A subject the catalog does not declare has no answer — `undefined`, + * never a guess. See {@link declaredSubjectValue}. + */ +type SubjectValue = 'boolean' | EmptyOperatorArm; +type SubjectValueOf = (subject: string) => SubjectValue | undefined; +const NOTHING_DECLARED: SubjectValueOf = () => undefined; + +/** + * The words the two value-less operators take, by what the catalog declares + * about the row's subject (objectui#11894). The operator VALUES stay `truthy` + * / `falsy` whatever the subject, so a row keeps its operator when its subject + * changes and only the wording and the emitted CEL follow the new subject. + * + * - `boolean` — "is true" / "is false", compiled to the bare subject and `!`. + * - `emptiable` — every other declared field: "is not empty" / "is empty". + * - `undeclared` — a subject the catalog does not type (a context subject, a + * flow variable, a field whose catalog row carries no type): the wording it + * always had, because which of the two it means cannot be read off anything + * this component holds. + */ +const VALUELESS_OP_KEYS: Readonly> = { + boolean: { + truthy: 'engine.inspector.condition.op.isTrue', + falsy: 'engine.inspector.condition.op.isFalse', + }, + emptiable: { + truthy: 'engine.inspector.condition.op.isNotEmpty', + falsy: 'engine.inspector.condition.op.isEmpty', + }, + undeclared: { + truthy: 'engine.inspector.condition.op.truthy', + falsy: 'engine.inspector.condition.op.falsy', + }, +}; + +/** The operator list one row offers: the compare operators, then the + * value-less pair worded for that row's subject (objectui#11894). */ +function opChoicesFor(declared: SubjectValue | undefined): ReadonlyArray { + const keys = VALUELESS_OP_KEYS[ + declared === undefined ? 'undeclared' : declared === 'boolean' ? 'boolean' : 'emptiable' + ]; + return [ + ...COMPARE_OPS, + { value: 'truthy', labelKey: keys.truthy }, + { value: 'falsy', labelKey: keys.falsy }, + ]; +} + +/** One entry of the field catalog the builder reads its subjects from. */ +interface CatalogField { + name: string; + label?: string; + hidden?: boolean; + /** The field's declared type — the value-less operators are worded and + * compiled by it (objectui#11894). Absent ⇒ the subject is undeclared. */ + type?: string; + /** `multiple: true` — a multi-capable type whose stored value is a list. */ + multiple?: boolean; +} + +/** + * What one catalog field declares about its value (objectui#11894). + * + * - A boolean type is a member of the spec's `BOOLEAN_VALUE_TYPES`, read by + * reference — the one set CEL's `!` and a bare `&&` operand are well typed + * for. + * - Every other type takes the row of the ruled 「is empty」 table that the + * spec's `expandEmptyOperator` assigns it, keyed on the type and `multiple` + * — the one function the list filter builder and every server face expand + * `$empty` with. ⛔ No local copy of that table. + * - A `formula` is undeclared: its value takes its `returnType`, which no + * catalog row carries. That is the spec's own reading — its boolean comparand + * door judges a `formula` by `returnType` and defers one without a readable + * one — and it keeps "is true" on a boolean formula compiling as it did. + * - A row with no type is undeclared too. + */ +function declaredSubjectValue(field: CatalogField): SubjectValue | undefined { + const { type } = field; + if (typeof type !== 'string' || type === '' || type === 'formula') return undefined; + if (BOOLEAN_VALUE_TYPES.has(type)) return 'boolean'; + return expandEmptyOperator({ type, multiple: field.multiple === true }).arm; +} + +/** + * Every subject the catalog types, keyed by the spelling the subject dropdown + * emits for it — `FIELDPREFIX + name`, and `previous.` + name where the mount + * offers `previous` — so a stored subject is typed exactly when it names a + * catalog field. Hidden fields are typed too: a stored row may name one. + */ +function declaredSubjectValues( + fields: ReadonlyArray, + fieldPrefix: string, + includePrevious: boolean, +): Map { + const out = new Map(); + for (const f of fields) { + const declared = declaredSubjectValue(f); + if (declared === undefined) continue; + out.set(`${fieldPrefix}${f.name}`, declared); + if (includePrevious) out.set(`previous.${f.name}`, declared); + } + return out; +} + /** * The context subjects a record-scoped mount site binds. This is the DEFAULT * vocabulary — a caller that binds something else declares it via @@ -475,47 +590,185 @@ function unfmtValue(raw: string): { value: string; quote?: Quote } { return { value: t }; } +/** + * The clauses of a null-or-empty check on `subject`, by the row of the ruled + * 「is empty」 table its field takes (objectui#11894): `empty` ⇒ the clauses + * of "is empty", OR-joined; otherwise those of "is not empty", AND-joined — + * the exact complement. + * + * - `null_only` — null only: `S == null`. + * - `text` — null or `''`. + * - `multi_value` — null or an empty list: `size(S) == 0`. + * + * `== null` and not `has()`: the server evaluates a validation rule or a hook + * condition against a record whose DECLARED fields are all present (a field + * with no value holds `null`), so `has()` is true for an empty field — the + * platform's own contract tests emptiness with `!= null` and keeps `has()` for + * an undeclared key. Each clause is well typed for every value it can meet: + * CEL's `==` against `null` or `''` is false across types rather than a fault, + * and `size()` is reached only past the null clause. + */ +function emptyCheckClauses(subject: string, arm: EmptyOperatorArm, empty: boolean): string[] { + const nullClause = `${subject} ${empty ? '==' : '!='} null`; + if (arm === 'text') return [nullClause, `${subject} ${empty ? '==' : '!='} ''`]; + if (arm === 'multi_value') return [nullClause, `size(${subject}) ${empty ? '==' : '!='} 0`]; + return [nullClause]; +} + +/** One row's CEL, and whether it is more than one clause — such a row is + * parenthesised when it is joined with others. */ +function compileRow(r: Row, valueOf: SubjectValueOf): { cel: string; compound: boolean } { + if (r.op === 'truthy' || r.op === 'falsy') { + const declared = valueOf(r.subject); + // A boolean compiles as it always did, and so does an undeclared subject: + // nothing here says which check it needs, and a guessed one is worse than + // the form the author can read in the compiled line below the rows. + if (declared === undefined || declared === 'boolean') { + return { cel: r.op === 'truthy' ? r.subject : `!${r.subject}`, compound: false }; + } + // Any other declared field: `!S` and a bare `S` are not well typed — + // CEL's `!` and a bare `&&` operand take a bool, so a date, text or lookup + // field faults (`no such overload: !null` / `!string`) and the server + // refuses the write. A null-or-empty check is well typed for every value. + const empty = r.op === 'falsy'; + const clauses = emptyCheckClauses(r.subject, declared, empty); + return { cel: clauses.join(empty ? ' || ' : ' && '), compound: clauses.length > 1 }; + } + return { cel: `${r.subject} ${r.op} ${fmtValue(r.value, r.quote)}`, compound: false }; +} + /** Compile rows → CEL. Rows without a subject are skipped (in-progress). */ -function compile(rows: Row[], join: '&&' | '||'): string { - return rows - .filter((r) => r.subject) - .map((r) => { - if (r.op === 'truthy') return r.subject; - if (r.op === 'falsy') return `!${r.subject}`; - return `${r.subject} ${r.op} ${fmtValue(r.value, r.quote)}`; - }) +function compile(rows: Row[], join: '&&' | '||', valueOf: SubjectValueOf = NOTHING_DECLARED): string { + const terms = rows.filter((r) => r.subject).map((r) => compileRow(r, valueOf)); + return terms + .map((term) => (term.compound && terms.length > 1 ? `(${term.cel})` : term.cel)) .join(` ${join} `); } +/** + * Walk `s` outside its string literals, handing each character and the + * parenthesis depth it sits at to `visit`; `visit` returning `false` stops the + * walk. Returns `false` when the quotes or parentheses do not balance. + */ +function walkCode(s: string, visit: (i: number, depth: number) => boolean | void): boolean { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = null; + continue; + } + if (c === "'" || c === '"') { quote = c; continue; } + if (c === '(') depth++; + if (visit(i, depth) === false) return true; + if (c === ')' && --depth < 0) return false; + } + return quote === null && depth === 0; +} + +/** + * Split at the AND/OR joins that sit outside parentheses and string literals. + * `null` for mixed joins, or for text whose quotes or parentheses do not + * balance — neither is a shape the rows can hold. + */ +function splitTopLevel(s: string): { parts: string[]; join: '&&' | '||' } | null { + const parts: string[] = []; + let join: '&&' | '||' | null = null; + let start = 0; + let mixed = false; + const balanced = walkCode(s, (i, depth) => { + if (depth !== 0) return; + const two = s.slice(i, i + 2); + if (two !== '&&' && two !== '||') return; + if (join && join !== two) { mixed = true; return false; } + join = two; + parts.push(s.slice(start, i).trim()); + start = i + 2; + }); + if (mixed || !balanced) return null; + parts.push(s.slice(start).trim()); + return { parts, join: join ?? '&&' }; +} + +/** The inside of `(…)` when one pair of parentheses encloses all of `p`. */ +function enclosedGroup(p: string): string | null { + if (!p.startsWith('(') || !p.endsWith(')')) return null; + let whole = true; + walkCode(p, (i, depth) => { + if (p[i] === ')' && depth === 1 && i !== p.length - 1) { whole = false; return false; } + }); + return whole ? p.slice(1, -1).trim() : null; +} + +/** + * Read `text` back as the null-or-empty check row it compiles from, or `null` + * (objectui#11894). Recognised by compiling the candidate row for the + * subject's DECLARED value and comparing, so it accepts exactly the emitted + * text: a check on an undeclared or boolean subject, or one spelled for a + * different row of the table than the field takes, is not this row. + */ +function parseEmptyCheck(text: string, valueOf: SubjectValueOf): Row | null { + const head = /^([a-zA-Z_][\w.]*)\s*(==|!=)\s*null\b/.exec(text); + if (!head) return null; + const declared = valueOf(head[1]); + if (declared === undefined || declared === 'boolean') return null; + const row: Row = { subject: head[1], op: head[2] === '==' ? 'falsy' : 'truthy', value: '' }; + return compileRow(row, valueOf).cel === text ? row : null; +} + +/** Read one AND/OR term back as a row, or `null` when it is not a row's shape. */ +function parseTerm(p: string, valueOf: SubjectValueOf): Row | null { + const group = enclosedGroup(p); + if (group !== null) return parseEmptyCheck(group, valueOf); + const check = parseEmptyCheck(p, valueOf); + if (check) return check; + const cmp = /^([a-zA-Z_][\w.]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/.exec(p); + if (cmp) { + const v = unfmtValue(cmp[3]); + return { subject: cmp[1], op: cmp[2] as Op, value: v.value, quote: v.quote }; + } + const neg = /^!\s*([a-zA-Z_][\w.]*)$/.exec(p); + if (neg) return { subject: neg[1], op: 'falsy', value: '' }; + const truthy = /^([a-zA-Z_][\w.]*)$/.exec(p); + if (truthy) return { subject: truthy[1], op: 'truthy', value: '' }; + return null; // unrecognised term +} + /** Parse a simple AND/OR predicate. Returns null if it isn't the simple shape. */ -function parse(expr: string): { rows: Row[]; join: '&&' | '||' } | null { +function parse( + expr: string, + valueOf: SubjectValueOf = NOTHING_DECLARED, +): { rows: Row[]; join: '&&' | '||' } | null { const s = norm(expr); if (!s) return { rows: [], join: '&&' }; - const hasAnd = s.includes('&&'); - const hasOr = s.includes('||'); - if (hasAnd && hasOr) return null; // mixed joins → too complex - const join: '&&' | '||' = hasOr ? '||' : '&&'; - const parts = s.split(hasOr ? '||' : '&&').map((p) => p.trim()); + // A lone row whose check is several clauses compiles without parentheses, + // so the whole text is tried as that one row before it is split. + const whole = parseEmptyCheck(s, valueOf); + if (whole) return { rows: [whole], join: '&&' }; + const split = splitTopLevel(s); + if (!split) return null; const rows: Row[] = []; - for (const p of parts) { - const cmp = /^([a-zA-Z_][\w.]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/.exec(p); - if (cmp) { - const v = unfmtValue(cmp[3]); - rows.push({ subject: cmp[1], op: cmp[2] as Op, value: v.value, quote: v.quote }); - continue; - } - const neg = /^!\s*([a-zA-Z_][\w.]*)$/.exec(p); - if (neg) { rows.push({ subject: neg[1], op: 'falsy', value: '' }); continue; } - const truthy = /^([a-zA-Z_][\w.]*)$/.exec(p); - if (truthy) { rows.push({ subject: truthy[1], op: 'truthy', value: '' }); continue; } - return null; // unrecognised term + for (const p of split.parts) { + const row = parseTerm(p, valueOf); + if (!row) return null; + rows.push(row); } - return { rows, join }; + return { rows, join: split.join }; } -function initFrom(value: string): { rows: Row[]; join: '&&' | '||'; raw: boolean } { - const p = parse(value || ''); - if (p && norm(compile(p.rows, p.join)) === norm(value || '')) { +/** + * Rows for `value` when it round-trips under what the catalog declares, else + * the raw editor. `!record.due_date` on a date field reads as an "is empty" + * row that now compiles to `record.due_date == null`; that is not the text the + * author stored, so it opens in the raw editor verbatim rather than being + * rewritten on their next edit (objectui#11894) — the rule this component's + * header states for every expression it cannot reproduce. + */ +function initFrom(value: string, valueOf: SubjectValueOf): { rows: Row[]; join: '&&' | '||'; raw: boolean } { + const p = parse(value || '', valueOf); + if (p && norm(compile(p.rows, p.join, valueOf)) === norm(value || '')) { return { rows: p.rows, join: p.join, raw: false }; } return { rows: [], join: '&&', raw: !!value }; @@ -527,8 +780,10 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f onCommit: (cel: string) => void; objectName?: string; /** Pre-fetched field catalog (e.g. from the generic form's widget context); - * when omitted, fields are loaded from `objectName`. */ - fields?: Array<{ name: string; label?: string; hidden?: boolean }>; + * when omitted, fields are loaded from `objectName`. A field's `type` (and + * `multiple`) is what words and compiles its value-less operators + * (objectui#11894); a field without one keeps the untyped pair. */ + fields?: ReadonlyArray; disabled?: boolean; /** * Report how many BLOCKING author-time issues this editor is showing — a CEL @@ -646,11 +901,30 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f () => fields.filter((f) => !f.hidden).map((f) => f.name), [fields], ); + /** + * What the catalog declares about each subject (objectui#11894), and the + * same answer as a string. The re-read below keys on the STRING, never on + * the memoised map's identity (AGENTS.md #10). + */ + const subjectValues = React.useMemo( + () => declaredSubjectValues(fields, fieldPrefix, includePrevious), + [fields, fieldPrefix, includePrevious], + ); + const valueOf: SubjectValueOf = (subject) => subjectValues.get(subject); + const declaredKey = [...subjectValues].map(([s, v]) => `${s}=${v}`).sort().join('|'); + // Held in a ref so the effects below read this render's catalog without + // listing a function that is new every render. + const valueOfRef = React.useRef(valueOf); + React.useEffect(() => { + valueOfRef.current = valueOf; + }); - const init = React.useMemo(() => initFrom(value), []); // first mount only + const init = React.useMemo(() => initFrom(value, valueOf), []); // first mount only const [rows, setRowsState] = React.useState(init.rows); const [join, setJoin] = React.useState<'&&' | '||'>(init.join); const [raw, setRaw] = React.useState(init.raw); + /** The author has edited since the value was last read into rows. */ + const editedSinceRead = React.useRef(false); // Adopt an externally-changed value (e.g. switching records, or a raw edit // from elsewhere) when it isn't the CEL we just emitted. @@ -659,18 +933,40 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f const v = value || ''; if (v === lastEmitted.current) return; lastEmitted.current = v; - const next = initFrom(v); + editedSinceRead.current = false; + const next = initFrom(v, valueOfRef.current); setRowsState(next.rows); setJoin(next.join); setRaw(next.raw); }, [value]); + // Re-read the value when what the catalog declares changes — above all when + // a catalog fetched by `objectName` lands after the first render, which read + // every subject as undeclared (objectui#11894). Without this, how a stored + // condition opens would turn on network timing: a row built here as "is + // empty" on a text field would reopen in the raw editor at every mount that + // fetches its catalog, and a stored `!record.due_date` would open as a row + // there but in the raw editor at a mount handed its fields. Only while the + // author has not edited since the last read: after an edit the rows are + // theirs, including a row they have not given a subject yet. + const readDeclaredKey = React.useRef(declaredKey); + React.useEffect(() => { + if (readDeclaredKey.current === declaredKey) return; + readDeclaredKey.current = declaredKey; + if (editedSinceRead.current) return; + const next = initFrom(lastEmitted.current, valueOfRef.current); + setRowsState(next.rows); + setJoin(next.join); + setRaw(next.raw); + }, [declaredKey]); + const emit = (nextRows: Row[], nextJoin: '&&' | '||') => { - const cel = compile(nextRows, nextJoin); + const cel = compile(nextRows, nextJoin, valueOf); lastEmitted.current = cel; onCommit(cel); }; const update = (nextRows: Row[], nextJoin: '&&' | '||' = join) => { + editedSinceRead.current = true; setRowsState(nextRows); setJoin(nextJoin); emit(nextRows, nextJoin); @@ -707,7 +1003,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f onBlockingIssuesChangeRef.current?.(blockingIssues); }, [blockingIssues]); - const compiled = compile(rows, join); + const compiled = compile(rows, join, valueOf); if (raw) { return ( @@ -715,7 +1011,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
{label ? : } @@ -726,7 +1022,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f { lastEmitted.current = v; onCommit(v); }} + onChange={(v) => { lastEmitted.current = v; editedSinceRead.current = true; onCommit(v); }} onLintChange={reportCel} disabled={disabled} /* objectui#9952 — DERIVED from the same offered set the autocomplete @@ -750,7 +1046,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f scopeIssue={scopeIssue} t={tLocal} /> - {value && !parse(value) && ( + {value && !parse(value, valueOf) && (
{tLocal('engine.condition.advancedHint')}
)}
@@ -806,7 +1102,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f @@ -820,8 +1116,13 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f )} + {/* objectui#11894 — a new row starts on `equals`, an operator that + compiles to well-typed CEL whatever subject is then picked. It + started on `truthy`, so a row whose operator the author never + touched compiled to the bare subject — not a bool on a date, text + or lookup field. */} {!disabled && ( - )} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.typedEmptyOps-11894.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.typedEmptyOps-11894.test.tsx new file mode 100644 index 0000000000..79e4648574 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.typedEmptyOps-11894.test.tsx @@ -0,0 +1,332 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ConditionBuilder's two value-less operators are TYPED by the field catalog + * (objectui#11894). + * + * The defect, as the Studio benchmark measured it: a validation rule built as + * `record.status` *equals* `done` AND `record.due_date` *is false / empty* + * saved as `record.status == 'done' && !record.due_date`. CEL's `!` takes a + * bool, so every matching write was refused with `no such overload: !null` + * (no due date) or `!string` (with one). + * + * The verdicts here are read off the CEL the builder EMITS, evaluated with the + * engine the server evaluates a validation rule with — `@objectstack/formula`'s + * `ExpressionEngine.evaluate`, called with `{ record, previous }`, the binding + * objectql's `checkPredicate` hands it — so a pin goes red if the emission is + * ill typed, not only if its spelling moves. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ExpressionEngine } from '@objectstack/formula'; + +// objectui#4697 — ConditionBuilder calls useObjectFields(objectName) +// unconditionally even when `fields` is supplied, so stub the shared client to +// keep the mount-time fetch off the network. The fetched-catalog case below +// answers it. +const state = vi.hoisted(() => ({ + metadataClient: { + get: vi.fn(async (): Promise => undefined), + list: vi.fn(async () => [] as unknown[]), + // `useObjectFields` reads the draft-overlaid object (objectui#11895). + withPreviewDrafts() { return this; }, + }, +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); + +import { ConditionBuilder } from './ConditionBuilder'; + +afterEach(() => { + cleanup(); + state.metadataClient.get.mockReset(); + state.metadataClient.get.mockImplementation(async () => undefined); +}); + +beforeAll(() => { + // Radix Select probes pointer-capture APIs the test DOM lacks. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!Element.prototype[m]) { + // @ts-expect-error test shim + Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } +}); + +/** A catalog that types every field, the way the hook-, action-, page-block-, + * flow- and schema-form mounts hand it over. */ +const TYPED_FIELDS = [ + { name: 'status', label: 'Status', type: 'select' }, + { name: 'due_date', label: 'Due date', type: 'date' }, + { name: 'title', label: 'Title', type: 'text' }, + { name: 'tags', label: 'Tags', type: 'tags' }, + { name: 'owner', label: 'Owner', type: 'lookup' }, + { name: 'watchers', label: 'Watchers', type: 'lookup', multiple: true }, + { name: 'flag', label: 'Flag', type: 'boolean' }, + { name: 'score', label: 'Score', type: 'formula' }, +]; + +/** The same catalog with no types — what a mount that carries none passes. */ +const UNTYPED_FIELDS = TYPED_FIELDS.map(({ name, label }) => ({ name, label })); + +/** Evaluate `source` exactly as objectql's validation-rule evaluator does. */ +function evaluate(source: string, record: Record): { ok: true; value: unknown } | { ok: false; error: string } { + const res = ExpressionEngine.evaluate({ dialect: 'cel', source }, { record, previous: undefined }); + return res.ok ? { ok: true, value: res.value } : { ok: false, error: String(res.error?.message ?? res.error) }; +} + +type CatalogRow = { name: string; label?: string; type?: string; multiple?: boolean }; + +function Harness({ initial, fields }: { initial: string; fields: ReadonlyArray }) { + const [v, setV] = React.useState(initial); + return ( +
+ +
{v}
+
+ ); +} + +const committed = () => screen.getByTestId('committed').textContent ?? ''; +const comboboxes = (c: HTMLElement) => Array.from(c.querySelectorAll('[role="combobox"]')) as HTMLElement[]; +/** The LAST row's operator trigger: its controls are subject, operator. */ +const lastOpTrigger = (c: HTMLElement) => comboboxes(c).at(-1)!; + +/** Open the last row's operator dropdown and read the operators it offers. */ +async function offeredOps(c: HTMLElement): Promise { + await userEvent.click(lastOpTrigger(c)); + return (await screen.findAllByRole('option')).map((o) => o.textContent ?? ''); +} + +/** Pick `label` from the last row's operator dropdown. */ +async function pickOp(c: HTMLElement, label: string) { + await userEvent.click(lastOpTrigger(c)); + await userEvent.click(await screen.findByRole('option', { name: label })); +} + +/** A two-row builder whose second row names `subject`, ready for an operator. */ +function mountBenchmark(subject: string, fields: ReadonlyArray = TYPED_FIELDS) { + return render(); +} + +describe('the benchmark rule on a DATE field evaluates both ways (objectui#11894)', () => { + it('"is empty" compiles to a null check, true with no due date and false with one', async () => { + const { container } = mountBenchmark('record.due_date'); + await pickOp(container, 'is empty'); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && record.due_date == null"); + expect(evaluate(cel, { status: 'done', due_date: null })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', due_date: '2026-10-20' })).toEqual({ ok: true, value: false }); + }); + + it('"is not empty" is the exact complement', async () => { + const { container } = mountBenchmark('record.due_date'); + await pickOp(container, 'is not empty'); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && record.due_date != null"); + expect(evaluate(cel, { status: 'done', due_date: null })).toEqual({ ok: true, value: false }); + expect(evaluate(cel, { status: 'done', due_date: '2026-10-20' })).toEqual({ ok: true, value: true }); + }); + + it('INSTRUMENT CONTROL — the emission this replaces faults in the same engine, both ways', () => { + // If this ever evaluated, the evaluator above would not be the one that + // refused the benchmark's writes, and the two pins above would prove less. + const old = "record.status == 'done' && !record.due_date"; + expect(evaluate(old, { status: 'done', due_date: null })).toMatchObject({ ok: false, error: expect.stringContaining('no such overload') }); + expect(evaluate(old, { status: 'done', due_date: '2026-10-20' })).toMatchObject({ ok: false, error: expect.stringContaining('no such overload') }); + }); +}); + +describe('a TEXT field: "is empty" is null or the empty string', () => { + it('compiles to a parenthesised null-or-empty check that is true for null and for \'\'', async () => { + const { container } = mountBenchmark('record.title'); + await pickOp(container, 'is empty'); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && (record.title == null || record.title == '')"); + expect(evaluate(cel, { status: 'done', title: null })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', title: '' })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', title: 'Leaky tap' })).toEqual({ ok: true, value: false }); + }); + + it('"is not empty" is the complement', async () => { + const { container } = mountBenchmark('record.title'); + await pickOp(container, 'is not empty'); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && (record.title != null && record.title != '')"); + expect(evaluate(cel, { status: 'done', title: null })).toEqual({ ok: true, value: false }); + expect(evaluate(cel, { status: 'done', title: '' })).toEqual({ ok: true, value: false }); + expect(evaluate(cel, { status: 'done', title: 'Leaky tap' })).toEqual({ ok: true, value: true }); + }); +}); + +describe('a MULTI-VALUE field: "is empty" is null or the empty list', () => { + it('`tags`: true for null and for [], false for a member', async () => { + const { container } = mountBenchmark('record.tags'); + await pickOp(container, 'is empty'); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && (record.tags == null || size(record.tags) == 0)"); + expect(evaluate(cel, { status: 'done', tags: null })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', tags: [] })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', tags: ['urgent'] })).toEqual({ ok: true, value: false }); + }); + + it('a lookup is multi-value only when declared `multiple: true` — the spec reads both keys', async () => { + const multi = mountBenchmark('record.watchers'); + await pickOp(multi.container, 'is not empty'); + const multiCel = committed(); + expect(multiCel).toBe("record.status == 'done' && (record.watchers != null && size(record.watchers) != 0)"); + expect(evaluate(multiCel, { status: 'done', watchers: [] })).toEqual({ ok: true, value: false }); + expect(evaluate(multiCel, { status: 'done', watchers: ['u1'] })).toEqual({ ok: true, value: true }); + cleanup(); + + const single = mountBenchmark('record.owner'); + await pickOp(single.container, 'is not empty'); + expect(committed()).toBe("record.status == 'done' && record.owner != null"); + }); +}); + +describe('CONTROL — a boolean field compiles as it always did', () => { + it('is worded "is true" / "is false" and emits the bare subject and its negation', async () => { + const { container } = mountBenchmark('record.flag'); + const ops = await offeredOps(container); + expect(ops).toEqual(expect.arrayContaining(['is true', 'is false'])); + expect(ops).not.toContain('is empty'); + await userEvent.click(screen.getByRole('option', { name: 'is false' })); + const cel = committed(); + expect(cel).toBe("record.status == 'done' && !record.flag"); + expect(evaluate(cel, { status: 'done', flag: false })).toEqual({ ok: true, value: true }); + + await pickOp(container, 'is true'); + expect(committed()).toBe("record.status == 'done' && record.flag"); + }); +}); + +describe('an UNDECLARED subject keeps the form and the words it had', () => { + it('a field the catalog carries no type for: "is empty / false" still emits `!S`', async () => { + const { container } = mountBenchmark('record.due_date', UNTYPED_FIELDS); + const ops = await offeredOps(container); + expect(ops).toEqual(expect.arrayContaining(['is set / true', 'is empty / false'])); + await userEvent.click(screen.getByRole('option', { name: 'is empty / false' })); + expect(committed()).toBe("record.status == 'done' && !record.due_date"); + }); + + it('a `formula` is undeclared — its value takes a `returnType` no catalog row carries', async () => { + const { container } = mountBenchmark('record.score'); + await pickOp(container, 'is set / true'); + expect(committed()).toBe("record.status == 'done' && record.score"); + }); +}); + +describe('the operator list follows the field type', () => { + it('a declared non-boolean field offers "is empty" / "is not empty", not the boolean or untyped words', async () => { + const { container } = mountBenchmark('record.due_date'); + const ops = await offeredOps(container); + expect(ops).toEqual(expect.arrayContaining(['equals', 'not equals', 'is not empty', 'is empty'])); + for (const gone of ['is true', 'is false', 'is set / true', 'is empty / false']) expect(ops).not.toContain(gone); + }); +}); + +describe('a round trip through parse is lossless', () => { + const cases: Array<[string, string]> = [ + ['date, is empty', "record.status == 'done' && record.due_date == null"], + ['date, is not empty', "record.status == 'done' || record.due_date != null"], + ['text, is empty, joined', "record.status == 'done' && (record.title == null || record.title == '')"], + ['text, is not empty, joined', "record.status == 'done' || (record.title != null && record.title != '')"], + ['text, is empty, alone', "record.title == null || record.title == ''"], + ['multi-value, is empty, joined', "record.status == 'done' && (record.tags == null || size(record.tags) == 0)"], + ['multi-value, is not empty, alone', 'record.tags != null && size(record.tags) != 0'], + ['boolean, is false', "record.status == 'done' && !record.flag"], + ]; + for (const [name, cel] of cases) { + it(`${name}: reopens as rows, and an edit to another row re-emits the check unchanged`, () => { + const { container } = render(); + expect(container.querySelector('textarea'), 'opened in the raw editor — it did not round-trip').toBeNull(); + expect(screen.getAllByText(cel).length).toBeGreaterThanOrEqual(1); + // Every row is recompiled on any edit, so editing the `status` row's + // value proves the check row compiles back to its own text. + const valueBox = container.querySelector('input') as HTMLInputElement | null; + if (cel.startsWith("record.status == 'done'")) { + fireEvent.change(valueBox!, { target: { value: 'closed' } }); + expect(committed()).toBe(cel.replace("'done'", "'closed'")); + } + }); + } + + it('a lone text check reads back as ONE "is empty" row, not two comparisons', async () => { + const { container } = render(); + expect(screen.getAllByLabelText('Remove condition')).toHaveLength(1); + expect(lastOpTrigger(container).textContent).toBe('is empty'); + }); + + it('a removed sibling leaves the check unparenthesised, and still well typed', () => { + render(); + fireEvent.click(screen.getAllByLabelText('Remove condition')[0]); + const cel = committed(); + expect(cel).toBe("record.title == null || record.title == ''"); + expect(evaluate(cel, { title: '' })).toEqual({ ok: true, value: true }); + }); +}); + +describe('a stored `!field` on a non-boolean field is never silently rewritten', () => { + it('opens in the raw editor verbatim (it no longer round-trips), and nothing is emitted', () => { + const stored = "record.status == 'done' && !record.due_date"; + const { container } = render(); + const ta = container.querySelector('textarea') as HTMLTextAreaElement | null; + expect(ta, 'expected the raw CEL editor').not.toBeNull(); + expect(ta!.value).toBe(stored); + expect(committed()).toBe(stored); + }); + + it('CONTROL — the same text on an untyped catalog still opens as rows', () => { + const stored = "record.status == 'done' && !record.due_date"; + const { container } = render(); + expect(container.querySelector('textarea')).toBeNull(); + }); +}); + +describe('a new row starts on a typed operator, not `truthy`', () => { + it('"Add condition" seeds `equals`; picking a subject emits a well-typed comparison', async () => { + const { container } = render(); + fireEvent.click(screen.getByText('Add condition')); + expect(lastOpTrigger(container).textContent).toBe('equals'); + await userEvent.click(comboboxes(container)[0]); + await userEvent.click(await screen.findByRole('option', { name: 'record.due_date' })); + const cel = committed(); + expect(cel).toBe("record.due_date == ''"); + expect(evaluate(cel, { due_date: null })).toEqual({ ok: true, value: false }); + expect(evaluate(cel, { due_date: '2026-10-20' })).toEqual({ ok: true, value: false }); + }); +}); + +describe('a catalog FETCHED by objectName types the rows once it lands', () => { + function mountFetched(initial: string) { + state.metadataClient.get.mockImplementation(async () => ({ + name: 'repair_ticket', + fields: { + status: { type: 'select' }, + title: { type: 'text' }, + due_date: { type: 'date' }, + }, + })); + return render( + {}} objectName="repair_ticket" />, + ); + } + + it('a builder-made text check reopens as rows, not in the raw editor', async () => { + const { container } = mountFetched("record.status == 'done' && (record.title == null || record.title == '')"); + await waitFor(() => expect(container.querySelector('textarea')).toBeNull()); + expect(lastOpTrigger(container).textContent).toBe('is empty'); + }); + + it('a stored `!record.due_date` ends in the raw editor, as at a mount handed its fields', async () => { + const stored = "record.status == 'done' && !record.due_date"; + const { container } = mountFetched(stored); + await waitFor(() => expect(container.querySelector('textarea')).not.toBeNull()); + expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(stored); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx index 21757a5a4e..bf10cb2cf7 100644 --- a/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx @@ -124,6 +124,10 @@ interface FieldOpt { name: string; label?: string; hidden?: boolean; + /** The field's declared type and `multiple` flag — what `ConditionBuilder` + * words and compiles its value-less operators by (objectui#11894). */ + type?: string; + multiple?: boolean; } /** Rule types, in menu order. `json_schema` matches the spec literal (not `json`). */ @@ -640,6 +644,14 @@ export function ObjectValidationsPanel({ name: e.name, label: typeof e.def.label === 'string' ? (e.def.label as string) : undefined, hidden: e.def.hidden === true, + // objectui#11894 — the type the condition builder compiles "is empty" + // by. Without it every field here read as undeclared, and a rule built + // as "due date is empty" saved as `!record.due_date`, which the server + // cannot evaluate on a date, so it refused every matching write. + // `multiple` puts a multi-capable field declared `multiple` on the + // null-or-empty-list check. + type: typeof e.def.type === 'string' ? (e.def.type as string) : undefined, + multiple: e.def.multiple === true, })), [draft.fields], ); diff --git a/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.typedEmptyOps-11894.test.tsx b/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.typedEmptyOps-11894.test.tsx new file mode 100644 index 0000000000..620c2e4b4b --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.typedEmptyOps-11894.test.tsx @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Studio Rules tab hands the condition builder each field's declared type + * and `multiple` flag, so the benchmark's validation rule compiles to CEL the + * server can evaluate (objectui#11894). + * + * The measured failure: a rule built here as `record.status` *equals* `done` + * AND `record.due_date` *is false / empty* saved as + * `record.status == 'done' && !record.due_date`, and every write to a done + * record was refused (`no such overload: !null` / `!string`). The builder + * types its value-less operators by the field list it is given, and this panel + * then gave it names, labels and hidden flags only — so every field here read + * as undeclared and kept the `!` form. The panel now copies `type` and + * `multiple`. + * + * Each verdict is read off what the panel WRITES through `onPatch` and + * evaluated with `@objectstack/formula`'s `ExpressionEngine.evaluate`, called + * with `{ record, previous }` as objectql's `checkPredicate` calls it. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ExpressionEngine } from '@objectstack/formula'; + +import { ObjectValidationsPanel } from './ObjectValidationsPanel'; + +afterEach(() => cleanup()); + +beforeAll(() => { + // Radix Select probes pointer-capture APIs the test DOM lacks. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!Element.prototype[m]) { + // @ts-expect-error test shim + Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } +}); + +/** The benchmark's object, as its draft declares it. */ +function draftWith(condition: string) { + return { + name: 'repair_ticket', + label: 'Repair ticket', + fields: { + status: { type: 'select', label: 'Status' }, + due_date: { type: 'date', label: 'Due date' }, + urgent: { type: 'boolean', label: 'Urgent' }, + watchers: { type: 'lookup', label: 'Watchers', reference: 'sys_user', multiple: true }, + }, + validations: [ + { type: 'script', name: 'done_needs_due_date', message: 'A done ticket needs a due date', condition, severity: 'error' }, + ], + }; +} + +/** Evaluate `source` exactly as objectql's validation-rule evaluator does. */ +function evaluate(source: string, record: Record): { ok: true; value: unknown } | { ok: false; error: string } { + const res = ExpressionEngine.evaluate({ dialect: 'cel', source }, { record, previous: undefined }); + return res.ok ? { ok: true, value: res.value } : { ok: false, error: String(res.error?.message ?? res.error) }; +} + +/** The condition-builder row whose subject trigger reads `subject`. */ +function rowOf(subject: string): HTMLElement { + const trigger = screen.getAllByRole('combobox').find((el) => el.textContent === subject); + expect(trigger, `no condition row names ${subject}`).toBeTruthy(); + // The row box, not the trigger: a Select trigger is `rounded-md` itself. + return trigger!.parentElement!.closest('.space-y-1.rounded-md') as HTMLElement; +} + +/** Pick `label` from the operator dropdown of the row naming `subject`. */ +async function pickOp(subject: string, label: string) { + const controls = within(rowOf(subject)).getAllByRole('combobox'); + await userEvent.click(controls[controls.length - 1]); + await userEvent.click(await screen.findByRole('option', { name: label })); +} + +/** The guard the panel last wrote for the one rule. */ +function writtenCondition(onPatch: ReturnType): unknown { + const patch = onPatch.mock.calls.at(-1)?.[0] as { validations: Array<{ condition?: unknown }> }; + return patch.validations[0].condition; +} + +describe('the Rules tab types the condition builder (objectui#11894)', () => { + it('BUILDING the benchmark rule: "due date is empty" is written as a null check that evaluates both ways', async () => { + const onPatch = vi.fn(); + render(); + await pickOp('record.due_date', 'is empty'); + const cel = writtenCondition(onPatch); + expect(cel).toBe("record.status == 'done' && record.due_date == null"); + expect(evaluate(cel as string, { status: 'done', due_date: null })).toEqual({ ok: true, value: true }); + expect(evaluate(cel as string, { status: 'done', due_date: '2026-10-20' })).toEqual({ ok: true, value: false }); + }); + + it('LOADING the rule: the null check reopens as the "is empty" row it was built as', () => { + render(); + const controls = within(rowOf('record.due_date')).getAllByRole('combobox'); + expect(controls[controls.length - 1]).toHaveTextContent('is empty'); + }); + + it('the rule the old builder stored opens in the raw editor as written, not rewritten', () => { + const onPatch = vi.fn(); + const stored = "record.status == 'done' && !record.due_date"; + render(); + const raw = screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement | undefined; + expect(raw, 'expected the raw CEL editor').toBeTruthy(); + expect(raw!.value).toBe(stored); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it('a lookup declared `multiple` builds "is empty" as null or an empty list — the flag reaches the builder', async () => { + const onPatch = vi.fn(); + render(); + await pickOp('record.watchers', 'is empty'); + const cel = writtenCondition(onPatch) as string; + expect(cel).toBe("record.status == 'done' && (record.watchers == null || size(record.watchers) == 0)"); + expect(evaluate(cel, { status: 'done', watchers: [] })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', watchers: null })).toEqual({ ok: true, value: true }); + expect(evaluate(cel, { status: 'done', watchers: ['u1'] })).toEqual({ ok: true, value: false }); + }); + + it('CONTROL — a boolean field on the same draft still compiles to the bare field and its negation', async () => { + const onPatch = vi.fn(); + render(); + await pickOp('record.urgent', 'is false'); + expect(writtenCondition(onPatch)).toBe("record.status == 'done' && !record.urgent"); + expect(evaluate(writtenCondition(onPatch) as string, { status: 'done', urgent: false })).toEqual({ ok: true, value: true }); + await pickOp('record.urgent', 'is true'); + expect(writtenCondition(onPatch)).toBe("record.status == 'done' && record.urgent"); + }); +});