@object-ui/core 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +1436 -0
  2. package/README.md +31 -12
  3. package/dist/actions/ActionEngine.d.ts +53 -31
  4. package/dist/actions/ActionEngine.js +87 -66
  5. package/dist/actions/ActionRunner.d.ts +215 -24
  6. package/dist/actions/ActionRunner.js +334 -17
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +96 -20
  10. package/dist/actions/actionKeys.js +168 -18
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  18. package/dist/data-scope/ViewDataProvider.js +83 -5
  19. package/dist/data-scope/element-data-source.d.ts +113 -0
  20. package/dist/data-scope/element-data-source.js +216 -0
  21. package/dist/data-scope/index.d.ts +2 -1
  22. package/dist/data-scope/index.js +1 -0
  23. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  24. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  25. package/dist/evaluator/declaredPredicate.js +152 -0
  26. package/dist/evaluator/fieldRules.d.ts +42 -2
  27. package/dist/evaluator/fieldRules.js +101 -11
  28. package/dist/evaluator/index.d.ts +2 -0
  29. package/dist/evaluator/index.js +2 -0
  30. package/dist/evaluator/listConditional.d.ts +18 -3
  31. package/dist/evaluator/listConditional.js +0 -0
  32. package/dist/evaluator/optionRules.js +5 -1
  33. package/dist/evaluator/predicateInput.d.ts +132 -0
  34. package/dist/evaluator/predicateInput.js +129 -0
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +26 -1
  37. package/dist/protocols/DndProtocol.d.ts +42 -1
  38. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  39. package/dist/protocols/SharingProtocol.d.ts +11 -1
  40. package/dist/protocols/index.d.ts +0 -1
  41. package/dist/protocols/index.js +8 -1
  42. package/dist/registry/Registry.d.ts +24 -0
  43. package/dist/theme/ThemeEngine.d.ts +0 -8
  44. package/dist/theme/ThemeEngine.js +22 -103
  45. package/dist/theme/index.d.ts +1 -1
  46. package/dist/theme/index.js +7 -1
  47. package/dist/types/index.d.ts +42 -8
  48. package/dist/utils/chart-series.d.ts +258 -5
  49. package/dist/utils/chart-series.js +423 -20
  50. package/dist/utils/compare-to.d.ts +40 -13
  51. package/dist/utils/compare-to.js +4 -32
  52. package/dist/utils/dashboard-filters.d.ts +54 -8
  53. package/dist/utils/dashboard-filters.js +261 -12
  54. package/dist/utils/dataset-format.d.ts +79 -15
  55. package/dist/utils/dataset-format.js +130 -9
  56. package/dist/utils/dataset-pivot.d.ts +78 -0
  57. package/dist/utils/dataset-pivot.js +78 -0
  58. package/dist/utils/dom-props.d.ts +137 -0
  59. package/dist/utils/dom-props.js +153 -0
  60. package/dist/utils/filter-converter.d.ts +39 -11
  61. package/dist/utils/filter-converter.js +87 -7
  62. package/dist/utils/normalize-list-view.d.ts +26 -6
  63. package/dist/utils/normalize-list-view.js +30 -8
  64. package/dist/utils/number-display.d.ts +151 -0
  65. package/dist/utils/number-display.js +71 -0
  66. package/dist/utils/predicate-fields.d.ts +96 -0
  67. package/dist/utils/predicate-fields.js +163 -0
  68. package/dist/utils/predicate-record.d.ts +32 -0
  69. package/dist/utils/predicate-record.js +120 -0
  70. package/dist/utils/record-title.js +0 -0
  71. package/dist/utils/resolve-view-id.d.ts +31 -0
  72. package/dist/utils/resolve-view-id.js +45 -0
  73. package/dist/utils/sort-query.d.ts +55 -0
  74. package/dist/utils/sort-query.js +35 -0
  75. package/package.json +5 -5
  76. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  77. package/dist/protocols/NotificationProtocol.js +0 -89
  78. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  79. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -22,8 +22,46 @@
22
22
  * CEL (mirrors the server's `toExpression`). Evaluation is *fail-open* for
23
23
  * visibility/required (a broken predicate must not hide a field or wrongly
24
24
  * block submit) and *fail-open* for readonly (a broken predicate leaves the
25
- * field editable) — matching the server, which logs and allows the change
26
- * through.
25
+ * field editable).
26
+ *
27
+ * Fail-open agrees with the server for MOST faults — but not for all, and the
28
+ * exception is the half worth knowing. Since objectstack#4889 a `readonlyWhen`
29
+ * predicate that faults because it names a scope ROOT the write never bound —
30
+ * `parent.status == 'paid'` with no master-detail header in hand — is
31
+ * fail-CLOSED on the server: `isReadonlyWhenLocked` warns and then returns
32
+ * TRUE, i.e. LOCKED (a declared lock is not waived merely because it could not
33
+ * be evaluated), and `stripReadonlyWhenFields` — plus
34
+ * `stripReadonlyWhenFieldsMulti` on the bulk path — DELETES that key from the
35
+ * UPDATE payload. This file keeps the same fault fail-OPEN. For that one class
36
+ * the two ends therefore point in OPPOSITE directions, deliberately: the
37
+ * framework's ADR-0057 D10 — server enforces, client is courtesy (framework
38
+ * numbering; this repo's own ADR-0057 is an unrelated document) — makes the
39
+ * server the authority, so the courtesy layer does not get to guess "locked"
40
+ * and grey out a field the server might have accepted.
41
+ *
42
+ * The caller-visible consequence, spelled out because the symptom is silent:
43
+ * the form renders the field EDITABLE, the user edits it, the save reports
44
+ * SUCCESS, and the new value never lands — the server dropped the key and kept
45
+ * the persisted one. Debug that toward the SERVER-side lock (its `… treating
46
+ * the field as LOCKED` warning, and the write response's `droppedFields`), not
47
+ * toward the client predicate, which did exactly what it was asked to. The
48
+ * other two halves carry no such carve-out: `requiredWhen` binds the same
49
+ * `parent` scope but deliberately kept fail-open semantics (objectstack#4977),
50
+ * so an unevaluable requirement is skipped on BOTH ends, and field-level
51
+ * `visibleWhen` is not a server concept at all.
52
+ *
53
+ * Fail-open is **loud**, not silent (objectstack#5149): a predicate that
54
+ * cannot be evaluated — parse error, unbound identifier, engine fault — logs
55
+ * one `console.warn` per predicate text with the source and the failure
56
+ * reason, then still returns the caller's fallback. Without the warning a
57
+ * broken predicate is indistinguishable from an absent one, so
58
+ * conditional-visibility bugs survive inspection indefinitely. This is the
59
+ * client half of the server's "log and allow" convention for the faults where
60
+ * the two ends DO agree (`readonlyWhen for 'x' failed to evaluate — change
61
+ * allowed through` — the GENERIC-fault message; the unbound-root fault above
62
+ * logs `treating the field as LOCKED` instead). The *default* stays
63
+ * fail-open on purpose — flipping it is a shipped-behavior change tracked
64
+ * separately in objectstack#5149 (appeal 1, undecided).
27
65
  */
28
66
  import { ExpressionEngine } from '@objectstack/formula';
29
67
  /** Normalize a predicate into the `Expression` shape the engine expects. */
@@ -32,6 +70,26 @@ function toExpression(pred) {
32
70
  return { dialect: 'cel', source: pred };
33
71
  return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
34
72
  }
73
+ const warnedPredicates = new Set();
74
+ /**
75
+ * One-time warning for a predicate that could not be evaluated. Deduped per
76
+ * predicate TEXT (dialect + source): a broken predicate is re-evaluated on
77
+ * every render/keystroke, and the point is one loud line, not a scrolling
78
+ * wall. The dedupe key is JSON-encoded — never a control-character separator
79
+ * (objectstack#5450 made a sibling of this file binary to grep that way).
80
+ */
81
+ function warnPredicateFailure(expr, fallback, reason, context) {
82
+ const key = JSON.stringify([expr.dialect, expr.source]);
83
+ if (warnedPredicates.has(key))
84
+ return;
85
+ warnedPredicates.add(key);
86
+ console.warn('[object-ui] A conditional predicate failed to evaluate' +
87
+ (context ? ` (${context})` : '') +
88
+ ` and was treated as its safe default (${String(fallback)}): ` +
89
+ JSON.stringify(expr.source) +
90
+ `. Reason: ${reason}. ` +
91
+ "Values are bound under 'record.' (e.g. record.status) — check the field names and CEL syntax.");
92
+ }
35
93
  /**
36
94
  * Evaluate a field-rule CEL predicate against a record.
37
95
  *
@@ -46,23 +104,47 @@ function toExpression(pred) {
46
104
  * e.g. `{ parent }` so an inline line-item cell can reference
47
105
  * its header (`parent.status == 'paid'`) as well as its own
48
106
  * row (`record.quantity`). Bound via the engine's `extra`.
107
+ * @param diagnostic Failure-warning options: a `context` locator for the
108
+ * one-time warning, and `warn: false` for callers that probe
109
+ * for faults and report them themselves. The returned value
110
+ * is unaffected either way.
49
111
  */
50
- export function evalFieldPredicate(pred, record, fallback, previous, scope) {
112
+ export function evalFieldPredicate(pred, record, fallback, previous, scope, diagnostic) {
51
113
  if (pred == null || (typeof pred === 'string' && !pred.trim()))
52
114
  return fallback;
115
+ const expr = toExpression(pred);
116
+ // The two fault sources — a not-ok verdict and a throw that slipped past the
117
+ // engine's "never throws" contract — converge on one reason string and one
118
+ // reporting site below, so the built-in warning and the `onFault` passback
119
+ // can never describe the failure differently.
120
+ let reason;
121
+ let value = fallback;
53
122
  try {
54
- const res = ExpressionEngine.evaluate(toExpression(pred), {
123
+ const res = ExpressionEngine.evaluate(expr, {
55
124
  record,
56
125
  previous,
57
126
  ...(scope ? { extra: scope } : {}),
58
127
  });
128
+ // Parse error, type error, unbound identifier, engine fault … — every
129
+ // not-ok verdict resolves to the fallback, but never silently (#5149).
59
130
  if (!res.ok)
60
- return fallback;
61
- return res.value === true;
131
+ reason = `[${res.error.kind}] ${res.error.message}`;
132
+ else
133
+ value = res.value === true;
62
134
  }
63
- catch {
135
+ catch (err) {
136
+ reason = `[throw] ${err instanceof Error ? err.message : String(err)}`;
137
+ }
138
+ if (reason !== undefined) {
139
+ if (diagnostic?.warn !== false) {
140
+ warnPredicateFailure(expr, fallback, reason, diagnostic?.context);
141
+ }
142
+ // Outside the try on purpose: a throwing callback is a caller bug and must
143
+ // surface as one, not be swallowed and re-reported as an engine fault.
144
+ diagnostic?.onFault?.(reason);
64
145
  return fallback;
65
146
  }
147
+ return value;
66
148
  }
67
149
  /**
68
150
  * Resolve the effective `{ visible, readonly, required }` state for a field
@@ -70,18 +152,26 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope) {
70
152
  * present, *overrides* the static flag. A static `true` is never weakened by a
71
153
  * `false` predicate result for required/readonly — but `visibleWhen` is
72
154
  * authoritative when present (so a field can be conditionally shown/hidden).
155
+ *
156
+ * @param fieldContext Optional locator for failure warnings — e.g.
157
+ * `"field 'amount'"`. The warning then reads
158
+ * `visibleWhen of field 'amount' …`; without it, the rule
159
+ * kind alone is reported.
73
160
  */
74
- export function resolveFieldRuleState(rules, record, statics, previous, scope) {
161
+ export function resolveFieldRuleState(rules, record, statics, previous, scope, fieldContext) {
162
+ const diag = (rule) => ({
163
+ context: fieldContext ? `${rule} of ${fieldContext}` : rule,
164
+ });
75
165
  const visible = rules.visibleWhen != null
76
- ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope)
166
+ ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope, diag('visibleWhen'))
77
167
  : true;
78
168
  const readonly = statics.readonly === true ||
79
169
  (rules.readonlyWhen != null
80
- ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope)
170
+ ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
81
171
  : false);
82
172
  const required = statics.required === true ||
83
173
  (rules.requiredWhen != null
84
- ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope)
174
+ ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
85
175
  : false);
86
176
  return { visible, readonly, required };
87
177
  }
@@ -7,6 +7,8 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './predicateInput.js';
11
+ export * from './declaredPredicate.js';
10
12
  export * from './fieldRules.js';
11
13
  export * from './listConditional.js';
12
14
  export * from './optionRules.js';
@@ -7,6 +7,8 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './predicateInput.js';
11
+ export * from './declaredPredicate.js';
10
12
  export * from './fieldRules.js';
11
13
  export * from './listConditional.js';
12
14
  export * from './optionRules.js';
@@ -34,6 +34,7 @@
34
34
  * (or nothing) to the server's CEL engine.
35
35
  */
36
36
  import { type FieldRulePredicate } from './fieldRules.js';
37
+ import { type FieldContainerLike } from '../utils/predicate-record.js';
37
38
  /** True when a *string* predicate uses legacy-only (non-CEL) syntax. */
38
39
  export declare function isLegacyDialectSource(source: string): boolean;
39
40
  /** Options for {@link evalRowPredicate}. */
@@ -43,12 +44,21 @@ export interface RowPredicateOptions {
43
44
  * row-action `visible`/`disabled`; `false` (no style) for formatting. */
44
45
  fallback?: boolean;
45
46
  /** Extra top-level scope merged alongside the row — e.g. the global predicate
46
- * scope (`features` / `user` / `app`) a host shell provides. */
47
+ * scope (`features` / `user` / `app`) a host shell provides. The row wins on
48
+ * collision: a `record` or `data` key here never shadows the row. */
47
49
  scope?: Record<string, unknown>;
48
50
  /** When true, log a one-time warning if a *present* predicate faults. */
49
51
  warnOnError?: boolean;
50
52
  /** Label used in warning messages (e.g. an action name or rule index). */
51
53
  label?: string;
54
+ /**
55
+ * The object's field definitions (`objectSchema.fields`). Supplying them lets
56
+ * a relation field be bound as the FOREIGN KEY the server stores, rather than
57
+ * as whatever `$expand` happened to substitute for it on this surface — see
58
+ * {@link toPredicateRecord}. Omitting them keeps the payload verbatim, so a
59
+ * caller with no schema at hand never guesses which fields are relations.
60
+ */
61
+ fields?: FieldContainerLike;
52
62
  }
53
63
  /**
54
64
  * Evaluate a single boolean predicate against a row record on the canonical CEL
@@ -56,7 +66,9 @@ export interface RowPredicateOptions {
56
66
  * fields are bound three ways so every authoring convention resolves:
57
67
  * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
58
68
  * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
59
- * alongside so `features.*` / `user.*` predicates keep working.
69
+ * alongside so `features.*` / `user.*` predicates keep working — but the row is
70
+ * the subject: `record` and `data` always name THIS row, on both dialect paths,
71
+ * even when the host scope carries keys of those names (objectui#3796).
60
72
  */
61
73
  export declare function evalRowPredicate(pred: FieldRulePredicate | undefined | null, row: Record<string, unknown> | null | undefined, opts?: RowPredicateOptions): boolean;
62
74
  /** A conditional-formatting rule in any of the three historical shapes. */
@@ -89,5 +101,8 @@ export type ConditionalFormattingRuleLike = {
89
101
  *
90
102
  * @param scope Extra top-level scope (host predicate scope) bound alongside
91
103
  * the row, so `features.*` predicates keep resolving.
104
+ * @param fields The object's field definitions, so a rule comparing a relation
105
+ * field sees the stored foreign key rather than the expanded
106
+ * record (see {@link RowPredicateOptions.fields}).
92
107
  */
93
- export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>): Record<string, string>;
108
+ export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>, fields?: FieldContainerLike): Record<string, string>;
Binary file
@@ -39,7 +39,11 @@ export function isOptionGroupGated(dependsOn, record) {
39
39
  export function resolveVisibleOptions(options, record, scope) {
40
40
  if (!options || options.length === 0)
41
41
  return [];
42
- return options.filter((o) => o?.visibleWhen == null ? true : evalFieldPredicate(o.visibleWhen, record, true, undefined, scope));
42
+ return options.filter((o) => o?.visibleWhen == null
43
+ ? true
44
+ : evalFieldPredicate(o.visibleWhen, record, true, undefined, scope, {
45
+ context: `visibleWhen of option '${String(o.value)}'`,
46
+ }));
43
47
  }
44
48
  /**
45
49
  * Whether a scalar field value is still valid given the currently-visible
@@ -0,0 +1,132 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The normalized shape {@link ExpressionEvaluator.evaluateCondition} consumes:
10
+ * a boolean (short-circuits), a `${…}` template string (legacy JS path), a
11
+ * `{ dialect: 'cel', source }` envelope (canonical `@objectstack/formula`
12
+ * path), or `undefined` for "no predicate declared".
13
+ *
14
+ * ## Why not `PredicateInput` (objectstack#4115 / objectui#3074)
15
+ *
16
+ * `@objectstack/spec` owns `PredicateInput` — and it is a genuinely DIFFERENT
17
+ * concept, so wearing that name would be the exact drift this repo's
18
+ * `check:spec-symbols` gate exists to stop. The spec's is
19
+ * `z.input<typeof PredicateInputSchema>`: what an author WRITES, before
20
+ * normalization — a bare string, or an envelope over the full dialect set
21
+ * (`cel` | `cron` | `template`) with optional `source` / `ast` / `meta`.
22
+ *
23
+ * This one is the OUTPUT of normalization: what the evaluator ACCEPTS. It
24
+ * admits `boolean` and the `${…}` template spelling (neither of which the spec
25
+ * models as a predicate), narrows `dialect` to `cel` alone (every other dialect
26
+ * has already been flattened onto the legacy path by `toPredicateInput`),
27
+ * requires a non-empty `source`, and carries no `ast` / `meta`. The two unions
28
+ * are not mutually assignable in either direction.
29
+ *
30
+ * The rename is pinned from both sides by the tripwire in
31
+ * `packages/core/src/utils/__tests__/spec-symbol-batch4.test.ts`: the spec must
32
+ * still own `PredicateInput` (else this rename has lost its reason), and must
33
+ * still NOT own `EvaluatorPredicateInput` (the objectui#3074 mistake of
34
+ * renaming onto another name the spec already holds).
35
+ */
36
+ export type EvaluatorPredicateInput = string | boolean | {
37
+ dialect: 'cel';
38
+ source: string;
39
+ } | undefined;
40
+ /**
41
+ * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
42
+ * `hidden`) into the form {@link ExpressionEvaluator.evaluateCondition}
43
+ * expects.
44
+ *
45
+ * Accepts:
46
+ * - `boolean` → returned as-is (predicate evaluation short-circuits).
47
+ * - `string` → a BARE expression is wrapped as `${string}` (legacy DX
48
+ * shorthand); a string that is already a `${…}` template is ALREADY in the
49
+ * normalized shape and is returned untouched (objectui#3871, below).
50
+ * - `Expression` envelope `{ dialect, source }` (the normalized form
51
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
52
+ * authored predicate, including bare strings) → a `cel` dialect keeps
53
+ * its envelope; every other dialect is unwrapped and wrapped as
54
+ * `${source}`.
55
+ * - `null` / `undefined` / empty / anything else → `undefined`
56
+ * (default visible/enabled).
57
+ *
58
+ * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
59
+ *
60
+ * `evaluateCondition` routes to the canonical `@objectstack/formula` engine —
61
+ * the one the SERVER enforces with — only while the argument is still a
62
+ * `{ dialect: 'cel' }` object. Collapse it to `${source}` first and the
63
+ * predicate silently falls to the legacy JS evaluator, whose semantics
64
+ * differ: CEL has no `<` overload for `null`, so `null < null` faults and
65
+ * fail-closed hides, while JS quietly yields `false`; CEL-only builtins
66
+ * (`today()`, objectstack#3205) do not exist on the JS path at all. Two
67
+ * normalizers that disagree on this one branch therefore make the SAME
68
+ * `visible:` predicate reach different verdicts depending on whether the
69
+ * action was surfaced by `ActionEngine.getActionsForLocation` or rendered
70
+ * standalone (#3314).
71
+ *
72
+ * This is THE implementation, not one of two — `@object-ui/core` is the common
73
+ * dependency of every consumer, so engine-side code and renderer-side code
74
+ * share one normalization instead of hand-rolling envelope unwrapping per call
75
+ * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
76
+ * (since #3367; it used to be an independent twin held in step by a 14-shape
77
+ * normalization parity table, which is exactly the arrangement the paragraph
78
+ * above describes the failure mode of).
79
+ *
80
+ * ## Why an already-`${…}` string is NOT wrapped again (objectui#3871)
81
+ *
82
+ * `${…}` is one of the spellings this repo documents for a predicate
83
+ * (AGENTS.md §4: `hidden?: string; // expression: "${data.role != 'admin'}"`)
84
+ * AND one of the shapes {@link EvaluatorPredicateInput} lists as a normalized
85
+ * OUTPUT. Wrapping unconditionally therefore corrupted every value that was
86
+ * already normalized: `'${x}'` became `'${${x}}'`, which no longer matches
87
+ * `ExpressionEvaluator`'s single-template fast path (`/^\$\{([^}]+)\}$/` —
88
+ * `[^}]+` cannot cross the inner `}`), so it fell to the global replace, whose
89
+ * inner expression `'${x'` does not parse. The verdict then stopped depending
90
+ * on the predicate at all, in a direction set by the CALLER's error policy:
91
+ *
92
+ * - fail-soft callers (every `disabled` / `enabled` leg, and the `visible`
93
+ * legs of `action:group` / `action:icon` / `RelatedList`) got the original
94
+ * non-empty string back from the evaluator's `catch { return match }`, so
95
+ * `Boolean(…)` was a constant `true` — `disabled` greyed out forever,
96
+ * `visible` shown forever, `enabled` never disabling.
97
+ * - fail-closed callers (`throwOnError: true` — `action:button` / `action:bar`
98
+ * / `action:menu` / `DeclaredActionsBar` `visible`, and
99
+ * `ActionEngine.getActionsForLocation`) got a THROW, so the constant was
100
+ * the other one: the action was hidden forever, including when its
101
+ * predicate held.
102
+ *
103
+ * Either way the author's expression was never consulted. The guard is
104
+ * `includes('${')` — "does this string already carry template syntax", the
105
+ * same question `evaluateCondition` itself asks before choosing the template
106
+ * path — not an anchored full match: a multi-part string like `'a ${x} b'` is
107
+ * not a predicate in any spelling, and wrapping it produced the same constant
108
+ * verdict, so the narrower guard would buy nothing and would still corrupt a
109
+ * value the evaluator can read.
110
+ *
111
+ * The same guard covers the envelope branch below, where the unwrapped
112
+ * `source` reaches the identical wrap one line later. It is one defect with two
113
+ * spellings, and `{ dialect: 'template', source: '${x}' }` is the shape a
114
+ * producer that compiles an authored template emits. A bare `source` (what
115
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces today, pinned in
116
+ * `packages/react/src/hooks/__tests__/useExpression.test.ts`) is unaffected.
117
+ *
118
+ * What this does NOT do is decide whether `${…}` SHOULD be authorable on an
119
+ * action predicate: `@objectstack/spec`'s `PredicateInput` models a bare string
120
+ * and a dialect envelope, not the template spelling. Rejecting it loudly at
121
+ * publish time is a spec-side ruling (deferred to the objectstack seat by the
122
+ * objectui#3871 dispatch); until then this normalizer must not silently invent
123
+ * a verdict for a spelling both the docs and this file's own output type bless.
124
+ *
125
+ * What pins the single implementation is the identity assertion in
126
+ * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
127
+ * react export must BE this function object — alongside the engine-path vs
128
+ * renderer-path verdict parity suite in the same file, which is a separate
129
+ * claim and still earns its keep: sharing a normalizer does not by itself
130
+ * prove the two call paths reach the same verdict.
131
+ */
132
+ export declare function toPredicateInput(value: unknown): EvaluatorPredicateInput;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Wrap a BARE expression as a `${…}` template, and leave a string that already
10
+ * carries template syntax alone (objectui#3871 — rationale in
11
+ * {@link toPredicateInput}'s doc below). One rule, so the two string spellings
12
+ * inside the normalizer cannot drift apart.
13
+ */
14
+ function wrapIfBare(expression) {
15
+ return expression.includes('${') ? expression : `\${${expression}}`;
16
+ }
17
+ /**
18
+ * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
19
+ * `hidden`) into the form {@link ExpressionEvaluator.evaluateCondition}
20
+ * expects.
21
+ *
22
+ * Accepts:
23
+ * - `boolean` → returned as-is (predicate evaluation short-circuits).
24
+ * - `string` → a BARE expression is wrapped as `${string}` (legacy DX
25
+ * shorthand); a string that is already a `${…}` template is ALREADY in the
26
+ * normalized shape and is returned untouched (objectui#3871, below).
27
+ * - `Expression` envelope `{ dialect, source }` (the normalized form
28
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
29
+ * authored predicate, including bare strings) → a `cel` dialect keeps
30
+ * its envelope; every other dialect is unwrapped and wrapped as
31
+ * `${source}`.
32
+ * - `null` / `undefined` / empty / anything else → `undefined`
33
+ * (default visible/enabled).
34
+ *
35
+ * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
36
+ *
37
+ * `evaluateCondition` routes to the canonical `@objectstack/formula` engine —
38
+ * the one the SERVER enforces with — only while the argument is still a
39
+ * `{ dialect: 'cel' }` object. Collapse it to `${source}` first and the
40
+ * predicate silently falls to the legacy JS evaluator, whose semantics
41
+ * differ: CEL has no `<` overload for `null`, so `null < null` faults and
42
+ * fail-closed hides, while JS quietly yields `false`; CEL-only builtins
43
+ * (`today()`, objectstack#3205) do not exist on the JS path at all. Two
44
+ * normalizers that disagree on this one branch therefore make the SAME
45
+ * `visible:` predicate reach different verdicts depending on whether the
46
+ * action was surfaced by `ActionEngine.getActionsForLocation` or rendered
47
+ * standalone (#3314).
48
+ *
49
+ * This is THE implementation, not one of two — `@object-ui/core` is the common
50
+ * dependency of every consumer, so engine-side code and renderer-side code
51
+ * share one normalization instead of hand-rolling envelope unwrapping per call
52
+ * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
53
+ * (since #3367; it used to be an independent twin held in step by a 14-shape
54
+ * normalization parity table, which is exactly the arrangement the paragraph
55
+ * above describes the failure mode of).
56
+ *
57
+ * ## Why an already-`${…}` string is NOT wrapped again (objectui#3871)
58
+ *
59
+ * `${…}` is one of the spellings this repo documents for a predicate
60
+ * (AGENTS.md §4: `hidden?: string; // expression: "${data.role != 'admin'}"`)
61
+ * AND one of the shapes {@link EvaluatorPredicateInput} lists as a normalized
62
+ * OUTPUT. Wrapping unconditionally therefore corrupted every value that was
63
+ * already normalized: `'${x}'` became `'${${x}}'`, which no longer matches
64
+ * `ExpressionEvaluator`'s single-template fast path (`/^\$\{([^}]+)\}$/` —
65
+ * `[^}]+` cannot cross the inner `}`), so it fell to the global replace, whose
66
+ * inner expression `'${x'` does not parse. The verdict then stopped depending
67
+ * on the predicate at all, in a direction set by the CALLER's error policy:
68
+ *
69
+ * - fail-soft callers (every `disabled` / `enabled` leg, and the `visible`
70
+ * legs of `action:group` / `action:icon` / `RelatedList`) got the original
71
+ * non-empty string back from the evaluator's `catch { return match }`, so
72
+ * `Boolean(…)` was a constant `true` — `disabled` greyed out forever,
73
+ * `visible` shown forever, `enabled` never disabling.
74
+ * - fail-closed callers (`throwOnError: true` — `action:button` / `action:bar`
75
+ * / `action:menu` / `DeclaredActionsBar` `visible`, and
76
+ * `ActionEngine.getActionsForLocation`) got a THROW, so the constant was
77
+ * the other one: the action was hidden forever, including when its
78
+ * predicate held.
79
+ *
80
+ * Either way the author's expression was never consulted. The guard is
81
+ * `includes('${')` — "does this string already carry template syntax", the
82
+ * same question `evaluateCondition` itself asks before choosing the template
83
+ * path — not an anchored full match: a multi-part string like `'a ${x} b'` is
84
+ * not a predicate in any spelling, and wrapping it produced the same constant
85
+ * verdict, so the narrower guard would buy nothing and would still corrupt a
86
+ * value the evaluator can read.
87
+ *
88
+ * The same guard covers the envelope branch below, where the unwrapped
89
+ * `source` reaches the identical wrap one line later. It is one defect with two
90
+ * spellings, and `{ dialect: 'template', source: '${x}' }` is the shape a
91
+ * producer that compiles an authored template emits. A bare `source` (what
92
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces today, pinned in
93
+ * `packages/react/src/hooks/__tests__/useExpression.test.ts`) is unaffected.
94
+ *
95
+ * What this does NOT do is decide whether `${…}` SHOULD be authorable on an
96
+ * action predicate: `@objectstack/spec`'s `PredicateInput` models a bare string
97
+ * and a dialect envelope, not the template spelling. Rejecting it loudly at
98
+ * publish time is a spec-side ruling (deferred to the objectstack seat by the
99
+ * objectui#3871 dispatch); until then this normalizer must not silently invent
100
+ * a verdict for a spelling both the docs and this file's own output type bless.
101
+ *
102
+ * What pins the single implementation is the identity assertion in
103
+ * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
104
+ * react export must BE this function object — alongside the engine-path vs
105
+ * renderer-path verdict parity suite in the same file, which is a separate
106
+ * claim and still earns its keep: sharing a normalizer does not by itself
107
+ * prove the two call paths reach the same verdict.
108
+ */
109
+ export function toPredicateInput(value) {
110
+ if (value === null || value === undefined || value === '')
111
+ return undefined;
112
+ if (typeof value === 'boolean')
113
+ return value;
114
+ if (typeof value === 'string')
115
+ return wrapIfBare(value);
116
+ if (typeof value === 'object' && typeof value.source === 'string') {
117
+ const src = value.source;
118
+ if (!src)
119
+ return undefined;
120
+ // Preserve a CEL-dialect envelope so `evaluateCondition` routes it to the
121
+ // canonical `@objectstack/formula` engine (identical verdict to the
122
+ // server) instead of collapsing it onto the legacy JS path.
123
+ if (value.dialect === 'cel')
124
+ return { dialect: 'cel', source: src };
125
+ // Every other dialect (template / unset) keeps the legacy `${…}` behavior.
126
+ return wrapIfBare(src);
127
+ }
128
+ return undefined;
129
+ }
package/dist/index.d.ts CHANGED
@@ -13,12 +13,15 @@ export * from './registry/PluginScopeImpl.js';
13
13
  export * from './registry/WidgetRegistry.js';
14
14
  export * from './validation/index.js';
15
15
  export * from './builder/schema-builder.js';
16
+ export * from './utils/dom-props.js';
16
17
  export * from './utils/filter-converter.js';
17
18
  export * from './utils/managedBy.js';
18
19
  export * from './utils/extract-records.js';
19
20
  export * from './utils/expand-fields.js';
20
21
  export * from './utils/column-identity.js';
21
22
  export * from './utils/sort-values.js';
23
+ export * from './utils/sort-query.js';
24
+ export * from './utils/resolve-view-id.js';
22
25
  export * from './evaluator/index.js';
23
26
  export * from './actions/index.js';
24
27
  export * from './query/index.js';
@@ -28,7 +31,6 @@ export * from './data-scope/index.js';
28
31
  export * from './errors/index.js';
29
32
  export * from './utils/debug.js';
30
33
  export * from './utils/debug-collector.js';
31
- export * from './utils/merge-views-into-objects.js';
32
34
  export * from './utils/freeze-schema.js';
33
35
  export * from './protocols/index.js';
34
36
  export * from './styling/scoped-styles.js';
@@ -47,8 +49,12 @@ export * from './utils/dashboard-filters.js';
47
49
  export * from './utils/merge-filters.js';
48
50
  export * from './utils/compare-to.js';
49
51
  export * from './utils/chart-series.js';
52
+ export * from './utils/number-display.js';
50
53
  export * from './utils/dataset-format.js';
54
+ export * from './utils/dataset-pivot.js';
51
55
  export * from './utils/record-title.js';
52
56
  export * from './utils/export-filename.js';
53
57
  export * from './utils/reference-keys.js';
58
+ export * from './utils/predicate-record.js';
59
+ export * from './utils/predicate-fields.js';
54
60
  export * from './utils/normalize-list-view.js';
package/dist/index.js CHANGED
@@ -12,12 +12,20 @@ export * from './registry/PluginScopeImpl.js';
12
12
  export * from './registry/WidgetRegistry.js';
13
13
  export * from './validation/index.js';
14
14
  export * from './builder/schema-builder.js';
15
+ // The DOM pass-through whitelist of the SDUI widget prop contract
16
+ // (objectui#4425 phase 2): a registered widget's host element receives only
17
+ // what `toDomProps` passes — everything else is consumed or dropped.
18
+ // `@object-ui/fields` executes the SAME mechanism against its own declared key
19
+ // list (`pickDomProps`), so there is one judge, not two.
20
+ export * from './utils/dom-props.js';
15
21
  export * from './utils/filter-converter.js';
16
22
  export * from './utils/managedBy.js';
17
23
  export * from './utils/extract-records.js';
18
24
  export * from './utils/expand-fields.js';
19
25
  export * from './utils/column-identity.js';
20
26
  export * from './utils/sort-values.js';
27
+ export * from './utils/sort-query.js';
28
+ export * from './utils/resolve-view-id.js';
21
29
  export * from './evaluator/index.js';
22
30
  export * from './actions/index.js';
23
31
  export * from './query/index.js';
@@ -27,7 +35,6 @@ export * from './data-scope/index.js';
27
35
  export * from './errors/index.js';
28
36
  export * from './utils/debug.js';
29
37
  export * from './utils/debug-collector.js';
30
- export * from './utils/merge-views-into-objects.js';
31
38
  export * from './utils/freeze-schema.js';
32
39
  export * from './protocols/index.js';
33
40
  export * from './styling/scoped-styles.js';
@@ -49,8 +56,26 @@ export * from './utils/dashboard-filters.js';
49
56
  export * from './utils/merge-filters.js';
50
57
  export * from './utils/compare-to.js';
51
58
  export * from './utils/chart-series.js';
59
+ // The ONE number-display formatter (objectui#4033) — grouping policy, display
60
+ // locale and the percent convention. It lived in `@object-ui/i18n` until
61
+ // objectui#4576; it is pure, and living above `core` was what kept
62
+ // `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
63
+ // re-exports these names unchanged, so both import paths name the same symbol.
64
+ export * from './utils/number-display.js';
52
65
  export * from './utils/dataset-format.js';
66
+ // Pivot lookup-key encoders, shared by every cross-tab renderer so the
67
+ // dashboard widget and the report renderer key their buckets identically
68
+ // (objectstack#5473 / objectstack#5665).
69
+ export * from './utils/dataset-pivot.js';
53
70
  export * from './utils/record-title.js';
54
71
  export * from './utils/export-filename.js';
55
72
  export * from './utils/reference-keys.js';
73
+ // Binds a fetched record into an expression scope the way the SERVER binds it
74
+ // (a relation is its foreign key, never the expanded record) — see
75
+ // `toPredicateRecord` for why an unnormalized one gives the same predicate
76
+ // different verdicts on different surfaces.
77
+ export * from './utils/predicate-record.js';
78
+ // The other half of a view's field appetite: the fields its PREDICATES read,
79
+ // which the column-derived `$select` never asked the server for.
80
+ export * from './utils/predicate-fields.js';
56
81
  export * from './utils/normalize-list-view.js';
@@ -14,7 +14,48 @@
14
14
  * @module protocols/DndProtocol
15
15
  * @packageDocumentation
16
16
  */
17
- import type { DndConfig, DragItem, DropZone, DragConstraint } from '@object-ui/types';
17
+ /** How a drag is initiated. */
18
+ export type DragHandle = 'element' | 'handle' | 'grip_icon';
19
+ /** The `dropEffect` advertised to assistive technology and DnD libraries. */
20
+ export type DropEffect = 'none' | 'link' | 'move' | 'copy';
21
+ /** Movement constraints applied to a drag. */
22
+ export interface DragConstraint {
23
+ axis: 'x' | 'y' | 'both';
24
+ bounds: 'none' | 'parent' | 'viewport';
25
+ grid?: [number, number];
26
+ }
27
+ /** A draggable item's declaration. */
28
+ export interface DragItem {
29
+ type: string;
30
+ label?: string;
31
+ handle: DragHandle;
32
+ constraint?: DragConstraint;
33
+ preview: 'custom' | 'none' | 'element';
34
+ disabled: boolean;
35
+ ariaLabel?: string;
36
+ ariaDescribedBy?: string;
37
+ role?: string;
38
+ }
39
+ /** A drop target's declaration. */
40
+ export interface DropZone {
41
+ label?: string;
42
+ accept: string[];
43
+ maxItems?: number;
44
+ highlightOnDragOver: boolean;
45
+ dropEffect: DropEffect;
46
+ ariaLabel?: string;
47
+ ariaDescribedBy?: string;
48
+ role?: string;
49
+ }
50
+ /** Top-level drag-and-drop configuration. */
51
+ export interface DndConfig {
52
+ enabled: boolean;
53
+ dragItem?: DragItem;
54
+ dropZone?: DropZone;
55
+ sortable: boolean;
56
+ autoScroll: boolean;
57
+ touchDelay: number;
58
+ }
18
59
  /** Fully resolved DnD configuration with all defaults applied. */
19
60
  export interface ResolvedDndConfig {
20
61
  enabled: boolean;