@object-ui/core 14.0.0 → 14.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,142 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 14.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0890fa7: feat(core): build-time guardrail for cascading select option predicates (#1583)
8
+
9
+ `@object-ui/core` now exports `lintOptionPredicates(fields)` — a static,
10
+ conservative validator for the per-option `visibleWhen` CEL predicates that
11
+ drive cascading / role-gated `select` options (#2284). An option predicate fails
12
+ _closed_ — a wrong one makes its option silently never appear — so this catches
13
+ the class of bug runtime fail-open can't surface:
14
+
15
+ - `syntax` — invalid CEL, delegated to `@objectstack/formula`'s
16
+ `validateExpression` (no schema hint, so a legitimate `current_user.roles`
17
+ reference is never mistaken for an error);
18
+ - `unknown-field` — a `record.<name>` reference to a field the form never
19
+ declares (a sibling typo);
20
+ - `option-literal-not-in-domain` — a literal compared against an _enum_ sibling
21
+ that is outside its declared option values, e.g. `record.country == 'chna'`
22
+ when `country` is `cn`/`us` (the AI-authoring typo #2284 called out).
23
+
24
+ It only flags what it can statically prove — non-`record.` roots
25
+ (`current_user.*`), open-domain fields, and unrecognized shapes are left alone,
26
+ so there are no false positives. The schema catalog runs it over every shipped
27
+ example. Design recorded in ADR-0058.
28
+
29
+ - 5523fc4: Dashboard-level filters — the three #2578 item-5 enhancements (framework#2501):
30
+
31
+ - **react**: nested `PageVariablesProvider`s now MERGE instead of shadowing
32
+ wholesale. A filtered dashboard embedded in a Page with its own `variables`
33
+ keeps the outer page variables readable inside widget subtrees (`page.*`);
34
+ an inner definition shadows only the SAME name; writes route to the scope
35
+ that defines the variable (writing an outer-defined name from inside the
36
+ nested subtree updates the outer provider); `resetVariables` stays local.
37
+ Names defined nowhere still write locally, exactly as before.
38
+ - **core**: `buildWidgetScopedFilter` accepts an optional `knownFields` set —
39
+ a DEFAULT binding whose target field is not on the widget's object is
40
+ skipped with a console warning instead of emitting a query the backend
41
+ empty-matches. Explicit `filterBindings` strings are always honoured (a
42
+ typo surfaces as a visibly empty widget, never a silently dropped filter).
43
+ Omitting `knownFields` preserves the previous unchecked behaviour.
44
+ - **plugin-dashboard**: `DashboardRenderer` feeds `knownFields` from
45
+ `dataSource.getObjectSchema` for inline `object` widgets (best-effort —
46
+ unchecked while metadata loads or when the source can't describe objects).
47
+ `optionsFrom` dynamic filter options now resolve DISTINCT values
48
+ server-side via a dataset GROUP BY (`queryDataset` with an inline draft)
49
+ when the data source supports it, falling back to the previous client-side
50
+ top-200 dedupe otherwise.
51
+
52
+ - 887062c: feat(dashboard): dashboard-level filters (date / region) driving multiple charts (framework#2501)
53
+
54
+ A dashboard's `dateRange` + `globalFilters` declarations are now wired end to
55
+ end: the filter values live as dashboard-level variables (the page variables
56
+ primitive, so they're also readable as `page.<name>` in widget expressions),
57
+ a filter bar renders above the widgets, and at render time the dashboard
58
+ broadcasts the active values into every bound widget's inline query —
59
+ `AND`-merged with the widget's own `filter`. Charts stay inline and
60
+ self-contained; each widget maps a filter to **its own** field.
61
+
62
+ - **`@object-ui/types`** — `globalFilters[].name` (stable filter/variable key,
63
+ defaults to `field`) and `DashboardWidgetSchema.filterBindings`
64
+ (`Record<string, string | false>`: per-widget field override / `false`
65
+ opt-out). Zod mirrors included. **Pending paired `@objectstack/spec`
66
+ alignment (framework#2501)** — same precedent as `dataset` /
67
+ `categoryGranularity`.
68
+ - **`@object-ui/core`** — new pure `dashboard-filters` module
69
+ (`resolveDashboardFilterDefs`, `dashboardFilterVariableDefs`,
70
+ `buildFilterCondition`, `buildWidgetScopedFilter`); `mergeFilters` lifted
71
+ from plugin-report (re-exported there unchanged). Date presets emit
72
+ date-macro tokens (`{30_days_ago}` …) so widgets resolve them at query time
73
+ like hand-authored filters.
74
+ - **`@object-ui/plugin-dashboard`** — `DashboardFilterBar` (date presets +
75
+ custom range calendar, select with static `options` or `optionsFrom`,
76
+ text/number inputs, reset); `DashboardRenderer` mounts a
77
+ `PageVariablesProvider` when filters are declared and merges the
78
+ widget-scoped condition into inline widgets' `filter` and dataset widgets'
79
+ `runtimeFilter`. Dashboards without filters render exactly as before.
80
+
81
+ Binding precedence: explicit `filterBindings` string/`false` → legacy
82
+ `targetWidgets` allow-list → the filter's own `field` (dateRange defaults to
83
+ `created_at`). Static-data widgets are not filtered.
84
+
85
+ - dea65f7: Unify the list-view conditional tier onto the canonical CEL engine (#1584).
86
+
87
+ Conditional formatting (list / grid / kanban) and row-action `visible` /
88
+ `disabled` predicates are now evaluated by `@objectstack/formula`'s
89
+ `ExpressionEngine` — the same engine the server uses — instead of the legacy
90
+ JS-dialect `ExpressionEvaluator`, matching how `@objectstack/spec` already types
91
+ these surfaces (`ExpressionInputSchema` / CEL). The whole platform now speaks one
92
+ expression dialect (framework ADR-0058).
93
+
94
+ - `@object-ui/core`: new `evalRowPredicate` + `resolveConditionalFormatting`
95
+ helpers (next to `evalFieldPredicate`). One implementation of all three
96
+ formatting rule shapes; dialect routing (a `{ dialect: 'cel' }` envelope is
97
+ always CEL; a bare string is CEL unless it carries legacy-only syntax
98
+ (`${…}` / `===` / `?.` / `.includes()`), which routes to the old engine with a
99
+ one-time deprecation warning); the native `{ field, operator, value }` form is
100
+ translated to CEL.
101
+ - `@object-ui/react`: new `useRowPredicate` hook (canonical CEL, ambient
102
+ predicate scope merged).
103
+ - Consumers converged: `ListView.evaluateConditionalFormatting` (thin wrapper,
104
+ export kept), `ObjectGrid` row styling (inline copy removed), kanban card
105
+ styles, and the grid / data-table row-action menus. `plugin-view`'s kanban
106
+ branch now forwards top-level `conditionalFormatting` (previously dropped).
107
+ - Row-action `visible` fails **closed** (broken predicate → hidden + warn);
108
+ `disabled` fails soft. The CEL `in` operator (and list membership) now work in
109
+ row predicates — the legacy engine could not parse them.
110
+ - The legacy `FormField.condition: { field, equals/notEquals/in }` is retired to
111
+ a CEL translation (back-compat preserved); `FieldDesigner` migrated to
112
+ `visibleWhen`.
113
+
114
+ Fully back-compat: existing conditional-formatting rules, row-action predicates,
115
+ and form `condition` metadata keep working (translated / routed as needed).
116
+
117
+ ### Patch Changes
118
+
119
+ - 2ded18c: Fix: a dashboard filter declaring its static `options` in the
120
+ `@objectstack/spec` object form (`options: [{ value, label }]` — the shape
121
+ the spec validates and what framework-authored dashboards ship) crashed the
122
+ whole dashboard with "Objects are not valid as a React child". Caught driving
123
+ the showcase Revenue Pulse dashboard in a real browser.
124
+
125
+ `resolveDashboardFilterDefs` now normalizes both the spec object form and the
126
+ bare-string shorthand (`options: ['EMEA']`) to `{ value, label }` pairs —
127
+ `DashboardFilterDef.options` is typed accordingly — and the filter bar's
128
+ select renders labels (the trigger now shows the selected option's label, not
129
+ its raw value). `@object-ui/types` aligns the `GlobalFilterSchema.options`
130
+ shape with the spec union.
131
+
132
+ - Updated dependencies [2ded18c]
133
+ - Updated dependencies [e628d1f]
134
+ - Updated dependencies [887062c]
135
+ - Updated dependencies [9e2d58f]
136
+ - Updated dependencies [d5b1bc0]
137
+ - Updated dependencies [f0f10f5]
138
+ - @object-ui/types@14.1.0
139
+
3
140
  ## 14.0.0
4
141
 
5
142
  ### Patch Changes
@@ -39,7 +39,7 @@ function warnHiddenPredicate(name, raw, err) {
39
39
  const msg = err instanceof Error ? err.message : String(err);
40
40
  // eslint-disable-next-line no-console
41
41
  console.warn(`[ActionEngine] action "${String(name)}" hidden: its \`visible\` predicate threw — ${msg}. ` +
42
- `Predicate: ${src}. Action predicates evaluate against { record, recordId, objectName, user, … }; ` +
42
+ `Predicate: ${src}. Action predicates evaluate against { record, recordId, objectName, user, os.user, … }; ` +
43
43
  `use \`record.<field>\` (not a bare field).`);
44
44
  }
45
45
  export class ActionEngine {
@@ -15,6 +15,25 @@
15
15
  */
16
16
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
17
17
  import { globalUndoManager } from './UndoManager';
18
+ /**
19
+ * Bind the server-CEL-parity `os.user` identity alias into an action context
20
+ * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
21
+ * scope as `os.user.*` (server formula / validation / sharing), while client
22
+ * contexts historically only bound `user`. Deriving the alias here means an
23
+ * action `visible` predicate authored as `os.user.role == "admin"` evaluates
24
+ * identically on client and server instead of throwing (and being fail-closed
25
+ * hidden). `os.user` always tracks `context.user`; other consumer-provided
26
+ * keys under `os` are preserved.
27
+ */
28
+ function withIdentityAlias(context) {
29
+ const user = context.user;
30
+ if (user === undefined)
31
+ return context;
32
+ const os = context.os;
33
+ if (os && typeof os === 'object' && os.user === user)
34
+ return context;
35
+ return { ...context, os: { ...(os && typeof os === 'object' ? os : {}), user } };
36
+ }
18
37
  export class ActionRunner {
19
38
  constructor(context = {}) {
20
39
  Object.defineProperty(this, "handlers", {
@@ -77,8 +96,8 @@ export class ActionRunner {
77
96
  writable: true,
78
97
  value: void 0
79
98
  });
80
- this.context = context;
81
- this.evaluator = new ExpressionEvaluator(context);
99
+ this.context = withIdentityAlias(context);
100
+ this.evaluator = new ExpressionEvaluator(this.context);
82
101
  // Default confirmation: window.confirm (can be overridden)
83
102
  this.confirmHandler = async (message) => window.confirm(message);
84
103
  this.toastHandler = null;
@@ -337,7 +356,17 @@ export class ActionRunner {
337
356
  this.toastHandler(message, { type: 'success', duration, undo: result.undo ? {} : undefined });
338
357
  }
339
358
  if (!result.success && showToast.showOnError !== false && result.error) {
340
- const message = action.errorMessage || result.error;
359
+ // `result.error` is typed as a string, but a handler bug can leak an
360
+ // error OBJECT (e.g. the ObjectStack `{ code, message }` envelope)
361
+ // through here — and a non-string toast payload is rendered as a React
362
+ // child, crashing the page (React #31). Coerce defensively at this
363
+ // single sink so no handler can take the whole page down.
364
+ const raw = action.errorMessage || result.error;
365
+ const message = typeof raw === 'string'
366
+ ? raw
367
+ : (raw && typeof raw.message === 'string')
368
+ ? raw.message
369
+ : 'Action failed';
341
370
  this.toastHandler(message, { type: 'error', duration });
342
371
  }
343
372
  }
@@ -734,8 +763,10 @@ export class ActionRunner {
734
763
  return ctx;
735
764
  }
736
765
  updateContext(newContext) {
737
- this.context = { ...this.context, ...newContext };
738
- this.evaluator.updateContext(newContext);
766
+ this.context = withIdentityAlias({ ...this.context, ...newContext });
767
+ // Feed the merged (re-aliased) context so a refreshed `user` also
768
+ // refreshes the derived `os.user` binding in the evaluator scope.
769
+ this.evaluator.updateContext(this.context);
739
770
  }
740
771
  getContext() {
741
772
  return this.context;
@@ -8,7 +8,9 @@
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './fieldRules.js';
11
+ export * from './listConditional.js';
11
12
  export * from './optionRules.js';
13
+ export * from './optionLint.js';
12
14
  export * from './ExpressionCache.js';
13
15
  export * from './FormulaFunctions.js';
14
16
  export * from './SafeExpressionParser.js';
@@ -8,7 +8,9 @@
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './fieldRules.js';
11
+ export * from './listConditional.js';
11
12
  export * from './optionRules.js';
13
+ export * from './optionLint.js';
12
14
  export * from './ExpressionCache.js';
13
15
  export * from './FormulaFunctions.js';
14
16
  export * from './SafeExpressionParser.js';
@@ -0,0 +1,93 @@
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
+ * Client-side evaluation of the **list-view conditional tier** — conditional
10
+ * formatting and row-action visibility — on the canonical CEL engine.
11
+ *
12
+ * B2 (ADR-0036) moved *field-level* rules (`visibleWhen` / `readonlyWhen` /
13
+ * `requiredWhen`) onto `@objectstack/formula`'s `ExpressionEngine`, evaluated
14
+ * identically on the client and the server. The list tier historically stayed
15
+ * on the legacy `ExpressionEvaluator` (a JS-dialect recursive-descent parser),
16
+ * even though `@objectstack/spec` already types these surfaces as CEL
17
+ * (`ListViewSchema.conditionalFormatting[].condition` and `ActionSchema.visible`
18
+ * are `ExpressionInputSchema` → `{ dialect: 'cel', source }`). This module
19
+ * converges them onto the same engine so the whole platform speaks one dialect
20
+ * (framework ADR-0058, "one authoring language, one interpret backend").
21
+ *
22
+ * Two evaluation entry points:
23
+ * - {@link evalRowPredicate} — a single boolean predicate over a row record
24
+ * (row-action `visible` / `disabled`, or a formatting `condition`).
25
+ * - {@link resolveConditionalFormatting} — the one implementation of the
26
+ * three historical rule shapes, returning the first matching rule's style.
27
+ *
28
+ * **Dialect routing.** A predicate authored as the `{ dialect: 'cel', source }`
29
+ * envelope is authoritative CEL and is *always* sent to the CEL engine. A bare
30
+ * string is CEL per the spec contract, *unless* it carries legacy-only syntax
31
+ * (`${…}` templating, `===`/`!==`, `?.` JS optional-chaining, JS string/array
32
+ * methods) — those are routed to the legacy engine for back-compat and emit a
33
+ * one-time deprecation warning, because such a string means something different
34
+ * (or nothing) to the server's CEL engine.
35
+ */
36
+ import { type FieldRulePredicate } from './fieldRules.js';
37
+ /** True when a *string* predicate uses legacy-only (non-CEL) syntax. */
38
+ export declare function isLegacyDialectSource(source: string): boolean;
39
+ /** Options for {@link evalRowPredicate}. */
40
+ export interface RowPredicateOptions {
41
+ /** Value returned when the predicate is absent or fails to evaluate.
42
+ * Pick the safe default for the surface: `false` (fail-closed) for a
43
+ * row-action `visible`/`disabled`; `false` (no style) for formatting. */
44
+ fallback?: boolean;
45
+ /** Extra top-level scope merged alongside the row — e.g. the global predicate
46
+ * scope (`features` / `user` / `app`) a host shell provides. */
47
+ scope?: Record<string, unknown>;
48
+ /** When true, log a one-time warning if a *present* predicate faults. */
49
+ warnOnError?: boolean;
50
+ /** Label used in warning messages (e.g. an action name or rule index). */
51
+ label?: string;
52
+ }
53
+ /**
54
+ * Evaluate a single boolean predicate against a row record on the canonical CEL
55
+ * engine (with a legacy-dialect fallback — see the module note). The row's
56
+ * fields are bound three ways so every authoring convention resolves:
57
+ * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
58
+ * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
59
+ * alongside so `features.*` / `user.*` predicates keep working.
60
+ */
61
+ export declare function evalRowPredicate(pred: FieldRulePredicate | undefined | null, row: Record<string, unknown> | null | undefined, opts?: RowPredicateOptions): boolean;
62
+ /** A conditional-formatting rule in any of the three historical shapes. */
63
+ export type ConditionalFormattingRuleLike = {
64
+ /** Spec shape: a CEL predicate (`ExpressionInputSchema`). */
65
+ condition?: FieldRulePredicate;
66
+ /** ObjectUI template shape: a `${…}` / plain expression string. */
67
+ expression?: string;
68
+ /** ObjectUI native shape: structured field/operator/value comparison. */
69
+ field?: string;
70
+ operator?: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in';
71
+ value?: unknown;
72
+ /** Style outputs (all shapes). `style` is the base; the three color props
73
+ * override individual CSS keys. */
74
+ style?: Record<string, unknown>;
75
+ backgroundColor?: string;
76
+ textColor?: string;
77
+ borderColor?: string;
78
+ };
79
+ /**
80
+ * Evaluate conditional-formatting rules against a record and return the style
81
+ * map for the first matching rule (rules are first-match-wins), or `{}`.
82
+ *
83
+ * Zero-React: returns a plain `Record<string, string>` style map; React
84
+ * consumers cast it to `CSSProperties`. Accepts all three historical rule
85
+ * shapes — spec `{ condition, style }`, ObjectUI `{ expression, … }`, and the
86
+ * native `{ field, operator, value, … }` — and evaluates every expression on
87
+ * the canonical CEL engine (with the legacy-dialect fallback in
88
+ * {@link evalRowPredicate}).
89
+ *
90
+ * @param scope Extra top-level scope (host predicate scope) bound alongside
91
+ * the row, so `features.*` predicates keep resolving.
92
+ */
93
+ export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>): Record<string, string>;
@@ -0,0 +1,43 @@
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
+ import type { DependsOnInput, OptionLike } from './optionRules.js';
9
+ /** A lint finding on one option's `visibleWhen` predicate. */
10
+ export interface OptionLintIssue {
11
+ /** Name of the `select`/`radio` field that owns the option (`''` if unnamed). */
12
+ field: string;
13
+ /** `value` of the option whose predicate is faulty. */
14
+ option: string;
15
+ /** Machine-readable finding kind. */
16
+ code: 'syntax' | 'unknown-field' | 'option-literal-not-in-domain';
17
+ /** Human-readable, self-correcting explanation. */
18
+ message: string;
19
+ }
20
+ /**
21
+ * Minimal field shape the linter reads — structurally satisfied by
22
+ * `FormFieldConfig`, `SelectFieldMetadata`, and a bare object-schema field.
23
+ * Only `name`, the type (`type` or `widget`), `options`, and `dependsOn` are
24
+ * consulted.
25
+ */
26
+ export interface LintFieldLike {
27
+ name?: string;
28
+ type?: string;
29
+ widget?: string;
30
+ dependsOn?: DependsOnInput;
31
+ options?: readonly OptionLike[] | null;
32
+ }
33
+ /**
34
+ * Statically lint the per-option `visibleWhen` predicates of a field list
35
+ * (a form's `fields`, or an object's fields) and return every provable issue.
36
+ * An empty array means nothing was disprovable — not that every predicate is
37
+ * semantically correct (see the conservative-by-design note above).
38
+ *
39
+ * @param fields Sibling fields sharing one record scope. Enum fields
40
+ * (`select`/`radio`/`multiselect`) contribute their option
41
+ * `value`s as the value domain for cross-checks.
42
+ */
43
+ export declare function lintOptionPredicates(fields: readonly LintFieldLike[] | null | undefined): OptionLintIssue[];
@@ -0,0 +1,166 @@
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
+ * Build-time / authoring guardrail for cascading `select` options (#1583, #2284).
10
+ *
11
+ * A per-option `visibleWhen` predicate (see {@link ./optionRules.js}) fails
12
+ * *closed*: a broken or wrong predicate makes its option silently never show.
13
+ * That is invisible at runtime — the offered set just quietly lacks a value —
14
+ * so the class of bug this catches is exactly the one runtime fail-open can't
15
+ * surface. The motivating case (#2284) is an AI-authored literal typo:
16
+ *
17
+ * { value: 'zj', visibleWhen: "record.country == 'chna'" } // 'chna' ≠ 'cn'
18
+ *
19
+ * `country`'s own options are `cn` / `us`, so `record.country` can never equal
20
+ * `'chna'` and Zhejiang is unreachable. The reference *parses*, the field
21
+ * *exists* — only the compared literal is outside the controlling field's value
22
+ * domain, which no syntax/existence check knows about. This linter closes that
23
+ * hole by cross-checking each option predicate against its sibling fields.
24
+ *
25
+ * Three checks, in order of confidence:
26
+ * 1. `syntax` — the predicate is valid CEL. Delegated to the canonical
27
+ * `@objectstack/formula` `validateExpression` (no schema
28
+ * hint → pure parse validation, so a legitimate
29
+ * `current_user.positions` reference is never flagged).
30
+ * 2. `unknown-field` — a `record.<name>` reference names a field the form
31
+ * doesn't declare (a sibling typo, `record.contry`).
32
+ * 3. `option-literal-not-in-domain` — a `record.<enum> == '<literal>'` (or
33
+ * `!=` / `in [...]`) compares against a value outside that
34
+ * *enum* sibling's declared option set (the `'chna'` case).
35
+ *
36
+ * Deliberately **conservative**: it only flags what it can statically prove and
37
+ * never guesses. Anything it can't resolve — a non-`record.` root
38
+ * (`current_user.*`), a reference to a non-enum field (free text / number /
39
+ * lookup, whose domain is open), a comparison it doesn't recognise — is left
40
+ * alone. False positives would train authors to ignore it, so there are none by
41
+ * construction; it is a safety net, not a schema validator.
42
+ */
43
+ import { validateExpression } from '@objectstack/formula';
44
+ /** Field types whose value set is a closed, statically-known enum. */
45
+ const OPTION_FIELD_TYPES = new Set(['select', 'radio', 'multiselect']);
46
+ /** The literal source of a predicate (`string` or `{ source }` envelope). */
47
+ function predicateSource(pred) {
48
+ return typeof pred === 'string' ? pred : (pred?.source ?? '');
49
+ }
50
+ /** Resolved value type of a field, or `select`/`radio`/`multiselect` for enums. */
51
+ function fieldType(f) {
52
+ return (f.widget || f.type || '').toString();
53
+ }
54
+ /** All `record.<name>` field references in a predicate source. */
55
+ function recordRefs(source) {
56
+ const refs = [];
57
+ const re = /record\.([A-Za-z_$][\w$]*)/g;
58
+ let m;
59
+ while ((m = re.exec(source)) !== null)
60
+ refs.push(m[1]);
61
+ return refs;
62
+ }
63
+ /**
64
+ * Every `(field, literal)` pair where the predicate compares a `record.<field>`
65
+ * to a string literal via `==`, `!=`, or `in [...]` — the forms whose literal
66
+ * can be domain-checked. Recognises both operand orders for equality. Anything
67
+ * else (ranges, function calls, arithmetic) yields nothing and is skipped.
68
+ */
69
+ function literalComparisons(source) {
70
+ const out = [];
71
+ // record.field == 'lit' / record.field != 'lit'
72
+ const eqRight = /record\.([A-Za-z_$][\w$]*)\s*[=!]=\s*'([^']*)'/g;
73
+ // 'lit' == record.field / 'lit' != record.field
74
+ const eqLeft = /'([^']*)'\s*[=!]=\s*record\.([A-Za-z_$][\w$]*)/g;
75
+ // record.field in ['a', 'b', ...]
76
+ const inList = /record\.([A-Za-z_$][\w$]*)\s+in\s+\[([^\]]*)\]/g;
77
+ let m;
78
+ while ((m = eqRight.exec(source)) !== null)
79
+ out.push({ field: m[1], literal: m[2] });
80
+ while ((m = eqLeft.exec(source)) !== null)
81
+ out.push({ field: m[2], literal: m[1] });
82
+ while ((m = inList.exec(source)) !== null) {
83
+ const field = m[1];
84
+ const listRe = /'([^']*)'/g;
85
+ let lm;
86
+ while ((lm = listRe.exec(m[2])) !== null)
87
+ out.push({ field, literal: lm[1] });
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Statically lint the per-option `visibleWhen` predicates of a field list
93
+ * (a form's `fields`, or an object's fields) and return every provable issue.
94
+ * An empty array means nothing was disprovable — not that every predicate is
95
+ * semantically correct (see the conservative-by-design note above).
96
+ *
97
+ * @param fields Sibling fields sharing one record scope. Enum fields
98
+ * (`select`/`radio`/`multiselect`) contribute their option
99
+ * `value`s as the value domain for cross-checks.
100
+ */
101
+ export function lintOptionPredicates(fields) {
102
+ if (!fields || fields.length === 0)
103
+ return [];
104
+ const knownFields = new Set();
105
+ // Value domain of each *enum* field; fields with an open domain are absent.
106
+ const domainByField = new Map();
107
+ for (const f of fields) {
108
+ if (!f?.name)
109
+ continue;
110
+ knownFields.add(f.name);
111
+ if (OPTION_FIELD_TYPES.has(fieldType(f)) && f.options && f.options.length > 0) {
112
+ domainByField.set(f.name, new Set(f.options.map((o) => o.value)));
113
+ }
114
+ }
115
+ const issues = [];
116
+ for (const f of fields) {
117
+ if (!OPTION_FIELD_TYPES.has(fieldType(f)) || !f.options)
118
+ continue;
119
+ const field = f.name ?? '';
120
+ for (const opt of f.options) {
121
+ if (opt?.visibleWhen == null)
122
+ continue;
123
+ const source = predicateSource(opt.visibleWhen);
124
+ if (!source.trim())
125
+ continue;
126
+ const option = opt.value;
127
+ // 1. Syntax — canonical CEL parse (no schema hint = no scope false-positives).
128
+ const parsed = validateExpression('predicate', source);
129
+ if (!parsed.ok) {
130
+ issues.push({
131
+ field,
132
+ option,
133
+ code: 'syntax',
134
+ message: parsed.errors[0]?.message ?? `invalid CEL predicate: ${source}`,
135
+ });
136
+ // Extraction below trusts a parseable source; skip it for broken CEL.
137
+ continue;
138
+ }
139
+ // 2. Unknown sibling field — a `record.<name>` the form never declares.
140
+ for (const ref of recordRefs(source)) {
141
+ if (!knownFields.has(ref)) {
142
+ issues.push({
143
+ field,
144
+ option,
145
+ code: 'unknown-field',
146
+ message: `option '${option}' visibleWhen references record.${ref}, which is not a field of this form`,
147
+ });
148
+ }
149
+ }
150
+ // 3. Literal outside the controlling enum's value domain (the #2284 case).
151
+ for (const { field: ref, literal } of literalComparisons(source)) {
152
+ const domain = domainByField.get(ref);
153
+ if (domain && !domain.has(literal)) {
154
+ issues.push({
155
+ field,
156
+ option,
157
+ code: 'option-literal-not-in-domain',
158
+ message: `option '${option}' visibleWhen compares record.${ref} to '${literal}', ` +
159
+ `which is not one of ${ref}'s option values (${[...domain].map((v) => `'${v}'`).join(', ')})`,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return issues;
166
+ }
@@ -15,7 +15,7 @@
15
15
  * {@link evalFieldPredicate}). This expresses both:
16
16
  * - cascades — `record.country == 'cn'` (the dependent list narrows as the
17
17
  * controlling field changes), and
18
- * - role/context gating — `'admin' in current_user.roles`.
18
+ * - role/context gating — `'admin' in current_user.positions`.
19
19
  *
20
20
  * A field declares which sibling fields its option list reacts to via
21
21
  * `dependsOn` (aligns with `@objectstack/spec` Field.dependsOn — the same knob
@@ -15,7 +15,7 @@
15
15
  * {@link evalFieldPredicate}). This expresses both:
16
16
  * - cascades — `record.country == 'cn'` (the dependent list narrows as the
17
17
  * controlling field changes), and
18
- * - role/context gating — `'admin' in current_user.roles`.
18
+ * - role/context gating — `'admin' in current_user.positions`.
19
19
  *
20
20
  * A field declares which sibling fields its option list reacts to via
21
21
  * `dependsOn` (aligns with `@objectstack/spec` Field.dependsOn — the same knob
package/dist/index.d.ts CHANGED
@@ -39,8 +39,11 @@ export * from './runtime/capabilities.js';
39
39
  export { composeStacks } from '@objectstack/spec';
40
40
  export * from './utils/drill-down.js';
41
41
  export * from './utils/date-macros.js';
42
+ export * from './utils/dashboard-filters.js';
43
+ export * from './utils/merge-filters.js';
42
44
  export * from './utils/compare-to.js';
43
45
  export * from './utils/chart-series.js';
44
46
  export * from './utils/dataset-format.js';
45
47
  export * from './utils/record-title.js';
46
48
  export * from './utils/export-filename.js';
49
+ export * from './utils/reference-keys.js';
package/dist/index.js CHANGED
@@ -38,8 +38,11 @@ export * from './runtime/capabilities.js';
38
38
  export { composeStacks } from '@objectstack/spec';
39
39
  export * from './utils/drill-down.js';
40
40
  export * from './utils/date-macros.js';
41
+ export * from './utils/dashboard-filters.js';
42
+ export * from './utils/merge-filters.js';
41
43
  export * from './utils/compare-to.js';
42
44
  export * from './utils/chart-series.js';
43
45
  export * from './utils/dataset-format.js';
44
46
  export * from './utils/record-title.js';
45
47
  export * from './utils/export-filename.js';
48
+ export * from './utils/reference-keys.js';
@@ -0,0 +1,98 @@
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
+ * Dashboard-level filter resolution (framework#2501).
10
+ *
11
+ * A dashboard declares top-level filters (`globalFilters` + the built-in
12
+ * `dateRange`). Their values live as dashboard-level variables; each widget
13
+ * may declare which of ITS OWN fields a filter binds to via
14
+ * `filterBindings`. At render time the dashboard broadcasts the filter
15
+ * values into each bound widget's inline query by merging a widget-scoped
16
+ * `FilterCondition` into the widget's own `filter`.
17
+ *
18
+ * Everything in this module is pure and synchronous so the binding rules
19
+ * are unit-testable in isolation from React and the data layer.
20
+ */
21
+ import type { DashboardSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types';
22
+ /** Reserved filter name for the dashboard's built-in date range. */
23
+ export declare const DATE_RANGE_FILTER_NAME = "dateRange";
24
+ export interface DashboardFilterDef {
25
+ /** Stable name — the variable key and the key widgets bind against. */
26
+ name: string;
27
+ /** Default target field when a widget declares no explicit binding. */
28
+ field: string;
29
+ label?: string;
30
+ type: 'text' | 'select' | 'date' | 'number' | 'lookup' | 'dateRange';
31
+ /**
32
+ * Static options, NORMALIZED to `{ value, label }` pairs by
33
+ * `resolveDashboardFilterDefs` — authors may write either the
34
+ * @objectstack/spec object form (`{ value, label }`) or the bare-string
35
+ * shorthand; consumers always see the object form.
36
+ */
37
+ options?: Array<{
38
+ value: string;
39
+ label: string;
40
+ }>;
41
+ optionsFrom?: {
42
+ object: string;
43
+ valueField: string;
44
+ labelField?: string;
45
+ filter?: any;
46
+ };
47
+ defaultValue?: any;
48
+ /** Legacy widget-id allow-list; ignored when a widget binds explicitly. */
49
+ targetWidgets?: string[];
50
+ /** dateRange only — whether the UI offers a custom from/to picker. */
51
+ allowCustomRange?: boolean;
52
+ }
53
+ /** Value shape held by a `dateRange`-typed filter variable. */
54
+ export interface DateRangeValue {
55
+ /** One of the `DashboardSchema.dateRange.defaultRange` presets. */
56
+ preset?: string;
57
+ /** Custom range bounds as ISO dates (either bound may be omitted). */
58
+ from?: string;
59
+ to?: string;
60
+ }
61
+ /** Preset keys the filter bar offers, in display order. */
62
+ export declare const DATE_RANGE_PRESETS: string[];
63
+ /**
64
+ * Normalize a dashboard schema's filter declarations into a flat list of
65
+ * filter definitions. The built-in `dateRange` (when declared) comes first
66
+ * under the reserved name `"dateRange"`; each `globalFilters` entry follows,
67
+ * named by its `name` (defaulting to `field`). Later duplicates win.
68
+ */
69
+ export declare function resolveDashboardFilterDefs(schema: Pick<DashboardSchema, 'globalFilters' | 'dateRange'>): DashboardFilterDef[];
70
+ /**
71
+ * Derive `PageVariable` definitions for a dashboard's filter values so the
72
+ * dashboard can host them in a `PageVariablesProvider` (the page/dashboard
73
+ * variables primitive). Filter values are then also readable in widget
74
+ * expressions as `page.<name>`.
75
+ */
76
+ export declare function dashboardFilterVariableDefs(defs: DashboardFilterDef[]): PageVariable[];
77
+ /**
78
+ * Build the operator shape (the value side of a `FilterCondition` entry) for
79
+ * one filter's current value. Returns `undefined` when the value imposes no
80
+ * constraint. The caller keys the result by the bound field name.
81
+ */
82
+ export declare function buildFilterCondition(def: DashboardFilterDef, value: unknown): Record<string, unknown> | unknown | undefined;
83
+ /**
84
+ * Compute the widget-scoped `FilterCondition` for the current filter values:
85
+ * one `{ [boundField]: condition }` entry per active, bound filter, combined
86
+ * with `$and` when several apply. Returns `undefined` when nothing applies —
87
+ * callers then leave the widget's own filter untouched.
88
+ *
89
+ * Metadata-aware default bindings (#2578 item 5): when `knownFields` is
90
+ * provided (the widget's object field names), a DEFAULT binding whose target
91
+ * field is not on the object is skipped with a console warning instead of
92
+ * emitting a query the backend will empty-match or reject. An EXPLICIT
93
+ * `filterBindings` string is always honoured — the author asked for that
94
+ * field, so a typo surfaces as a visible (fixable) empty widget rather than
95
+ * being silently dropped. Callers omit `knownFields` when metadata is not
96
+ * (yet) available, preserving the previous behaviour.
97
+ */
98
+ export declare function buildWidgetScopedFilter(widget: Pick<DashboardWidgetSchema, 'id' | 'filterBindings'>, defs: DashboardFilterDef[], values: Record<string, unknown>, knownFields?: ReadonlySet<string>): Record<string, unknown> | undefined;
@@ -0,0 +1,229 @@
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
+ /** Reserved filter name for the dashboard's built-in date range. */
9
+ export const DATE_RANGE_FILTER_NAME = 'dateRange';
10
+ /** Default target field for the built-in date range filter. */
11
+ const DATE_RANGE_DEFAULT_FIELD = 'created_at';
12
+ /**
13
+ * Date-range presets → date-macro token bounds. Tokens stay symbolic in the
14
+ * generated condition; every widget renderer resolves them at query time via
15
+ * `resolveDateMacros`, exactly like hand-authored widget filters.
16
+ */
17
+ const PRESET_RANGES = {
18
+ today: { from: '{today}', to: '{today}' },
19
+ yesterday: { from: '{yesterday}', to: '{yesterday}' },
20
+ this_week: { from: '{current_week_start}', to: '{current_week_end}' },
21
+ last_week: { from: '{last_week_start}', to: '{last_week_end}' },
22
+ this_month: { from: '{current_month_start}', to: '{current_month_end}' },
23
+ last_month: { from: '{last_month_start}', to: '{last_month_end}' },
24
+ this_quarter: { from: '{current_quarter_start}', to: '{current_quarter_end}' },
25
+ last_quarter: { from: '{last_quarter_start}', to: '{last_quarter_end}' },
26
+ this_year: { from: '{current_year_start}', to: '{current_year_end}' },
27
+ last_year: { from: '{last_year_start}', to: '{last_year_end}' },
28
+ last_7_days: { from: '{7_days_ago}', to: '{today}' },
29
+ last_30_days: { from: '{30_days_ago}', to: '{today}' },
30
+ last_90_days: { from: '{90_days_ago}', to: '{today}' },
31
+ };
32
+ /** Preset keys the filter bar offers, in display order. */
33
+ export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES);
34
+ /**
35
+ * Normalize a filter's static `options` declaration to `{ value, label }`
36
+ * pairs. The @objectstack/spec `GlobalFilterSchema.options` form is
37
+ * `{ value, label }` objects (label possibly an i18n record); the bare-string
38
+ * shorthand (`options: ['EMEA', …]`) is also accepted. Rendering an
39
+ * un-normalized object child crashes React — this is the single place both
40
+ * shapes converge.
41
+ */
42
+ function normalizeFilterOptions(options) {
43
+ if (!Array.isArray(options) || options.length === 0)
44
+ return undefined;
45
+ const normalized = [];
46
+ for (const o of options) {
47
+ if (o === null || o === undefined)
48
+ continue;
49
+ if (typeof o === 'object') {
50
+ const value = o.value;
51
+ if (value === undefined || value === null)
52
+ continue;
53
+ const label = o.label;
54
+ normalized.push({
55
+ value: String(value),
56
+ label: typeof label === 'string' && label ? label : String(value),
57
+ });
58
+ }
59
+ else {
60
+ normalized.push({ value: String(o), label: String(o) });
61
+ }
62
+ }
63
+ return normalized.length > 0 ? normalized : undefined;
64
+ }
65
+ /**
66
+ * Normalize a dashboard schema's filter declarations into a flat list of
67
+ * filter definitions. The built-in `dateRange` (when declared) comes first
68
+ * under the reserved name `"dateRange"`; each `globalFilters` entry follows,
69
+ * named by its `name` (defaulting to `field`). Later duplicates win.
70
+ */
71
+ export function resolveDashboardFilterDefs(schema) {
72
+ const byName = new Map();
73
+ if (schema.dateRange) {
74
+ const preset = schema.dateRange.defaultRange;
75
+ byName.set(DATE_RANGE_FILTER_NAME, {
76
+ name: DATE_RANGE_FILTER_NAME,
77
+ field: schema.dateRange.field || DATE_RANGE_DEFAULT_FIELD,
78
+ type: 'dateRange',
79
+ // 'custom' has no bounds of its own — start empty and let the user pick.
80
+ defaultValue: preset && preset !== 'custom' ? { preset } : undefined,
81
+ allowCustomRange: schema.dateRange.allowCustomRange,
82
+ });
83
+ }
84
+ for (const f of schema.globalFilters ?? []) {
85
+ if (!f?.field)
86
+ continue;
87
+ const name = f.name || f.field;
88
+ if (byName.has(name) && typeof console !== 'undefined') {
89
+ console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`);
90
+ }
91
+ byName.set(name, {
92
+ name,
93
+ field: f.field,
94
+ label: f.label,
95
+ type: f.type ?? 'text',
96
+ options: normalizeFilterOptions(f.options),
97
+ optionsFrom: f.optionsFrom,
98
+ defaultValue: f.defaultValue,
99
+ targetWidgets: f.targetWidgets,
100
+ });
101
+ }
102
+ return Array.from(byName.values());
103
+ }
104
+ /**
105
+ * Derive `PageVariable` definitions for a dashboard's filter values so the
106
+ * dashboard can host them in a `PageVariablesProvider` (the page/dashboard
107
+ * variables primitive). Filter values are then also readable in widget
108
+ * expressions as `page.<name>`.
109
+ */
110
+ export function dashboardFilterVariableDefs(defs) {
111
+ return defs.map((def) => ({
112
+ name: def.name,
113
+ type: def.type === 'dateRange' ? 'object' : 'string',
114
+ defaultValue: def.defaultValue,
115
+ }));
116
+ }
117
+ /** True when a filter value carries no constraint. */
118
+ function isEmptyValue(def, value) {
119
+ if (value === undefined || value === null || value === '')
120
+ return true;
121
+ if (Array.isArray(value))
122
+ return value.length === 0;
123
+ if (def.type === 'dateRange' || def.type === 'date') {
124
+ const v = value;
125
+ if (typeof v !== 'object')
126
+ return false;
127
+ return !v.preset && !v.from && !v.to;
128
+ }
129
+ if (typeof value === 'object')
130
+ return Object.keys(value).length === 0;
131
+ return false;
132
+ }
133
+ /**
134
+ * Build the operator shape (the value side of a `FilterCondition` entry) for
135
+ * one filter's current value. Returns `undefined` when the value imposes no
136
+ * constraint. The caller keys the result by the bound field name.
137
+ */
138
+ export function buildFilterCondition(def, value) {
139
+ if (isEmptyValue(def, value))
140
+ return undefined;
141
+ if (def.type === 'dateRange' || def.type === 'date') {
142
+ const v = value;
143
+ if (typeof v === 'object') {
144
+ const range = v.preset ? PRESET_RANGES[v.preset] : undefined;
145
+ const from = range?.from ?? v.from;
146
+ const to = range?.to ?? v.to;
147
+ if (!from && !to)
148
+ return undefined;
149
+ return {
150
+ ...(from ? { $gte: from } : {}),
151
+ ...(to ? { $lte: to } : {}),
152
+ };
153
+ }
154
+ // A bare string date means equality on that day.
155
+ return value;
156
+ }
157
+ if (def.type === 'select' || def.type === 'lookup') {
158
+ return Array.isArray(value) ? { $in: value } : value;
159
+ }
160
+ if (def.type === 'text') {
161
+ return { $contains: value };
162
+ }
163
+ // number and anything else: equality.
164
+ return value;
165
+ }
166
+ /**
167
+ * Resolve which of the widget's fields a filter binds to.
168
+ * Returns `undefined` when the widget is not bound to this filter.
169
+ *
170
+ * Precedence: explicit `filterBindings` entry (string overrides the field,
171
+ * `false` opts out — both win over everything) → legacy `targetWidgets`
172
+ * allow-list → the filter's own default `field`.
173
+ */
174
+ function resolveBoundField(widget, def) {
175
+ const binding = widget.filterBindings?.[def.name];
176
+ if (binding === false)
177
+ return undefined;
178
+ if (typeof binding === 'string' && binding)
179
+ return binding;
180
+ if (def.targetWidgets && def.targetWidgets.length > 0) {
181
+ if (!widget.id || !def.targetWidgets.includes(widget.id))
182
+ return undefined;
183
+ }
184
+ return def.field;
185
+ }
186
+ /**
187
+ * Compute the widget-scoped `FilterCondition` for the current filter values:
188
+ * one `{ [boundField]: condition }` entry per active, bound filter, combined
189
+ * with `$and` when several apply. Returns `undefined` when nothing applies —
190
+ * callers then leave the widget's own filter untouched.
191
+ *
192
+ * Metadata-aware default bindings (#2578 item 5): when `knownFields` is
193
+ * provided (the widget's object field names), a DEFAULT binding whose target
194
+ * field is not on the object is skipped with a console warning instead of
195
+ * emitting a query the backend will empty-match or reject. An EXPLICIT
196
+ * `filterBindings` string is always honoured — the author asked for that
197
+ * field, so a typo surfaces as a visible (fixable) empty widget rather than
198
+ * being silently dropped. Callers omit `knownFields` when metadata is not
199
+ * (yet) available, preserving the previous behaviour.
200
+ */
201
+ export function buildWidgetScopedFilter(widget, defs, values, knownFields) {
202
+ const conditions = [];
203
+ for (const def of defs) {
204
+ const value = values[def.name];
205
+ if (isEmptyValue(def, value))
206
+ continue;
207
+ const field = resolveBoundField(widget, def);
208
+ if (!field)
209
+ continue;
210
+ const isExplicit = typeof widget.filterBindings?.[def.name] === 'string';
211
+ if (!isExplicit && knownFields && !knownFields.has(field)) {
212
+ if (typeof console !== 'undefined') {
213
+ console.warn(`[dashboard-filters] skipping filter "${def.name}" on widget "${widget.id ?? '?'}": ` +
214
+ `default field "${field}" does not exist on the widget's object — ` +
215
+ `map it with filterBindings: { "${def.name}": "<field>" } or opt out with false`);
216
+ }
217
+ continue;
218
+ }
219
+ const condition = buildFilterCondition(def, value);
220
+ if (condition === undefined)
221
+ continue;
222
+ conditions.push({ [field]: condition });
223
+ }
224
+ if (conditions.length === 0)
225
+ return undefined;
226
+ if (conditions.length === 1)
227
+ return conditions[0];
228
+ return { $and: conditions };
229
+ }
@@ -0,0 +1,16 @@
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
+ * Combine two `FilterCondition` objects via `$and`, dropping empty ones.
10
+ *
11
+ * The scope-filter combinator shared by the dataset report path and the
12
+ * dashboard filter broadcast: a widget's own `runtimeFilter`/`filter` is
13
+ * intersected with a host-supplied runtime filter. Returns `undefined` when
14
+ * nothing meaningful remains so callers can omit the key entirely.
15
+ */
16
+ export declare function mergeFilters(a: Record<string, unknown> | undefined, b: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
@@ -0,0 +1,26 @@
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
+ * Combine two `FilterCondition` objects via `$and`, dropping empty ones.
10
+ *
11
+ * The scope-filter combinator shared by the dataset report path and the
12
+ * dashboard filter broadcast: a widget's own `runtimeFilter`/`filter` is
13
+ * intersected with a host-supplied runtime filter. Returns `undefined` when
14
+ * nothing meaningful remains so callers can omit the key entirely.
15
+ */
16
+ export function mergeFilters(a, b) {
17
+ const hasA = a && Object.keys(a).length > 0;
18
+ const hasB = b && Object.keys(b).length > 0;
19
+ if (hasA && hasB)
20
+ return { $and: [a, b] };
21
+ if (hasA)
22
+ return a;
23
+ if (hasB)
24
+ return b;
25
+ return undefined;
26
+ }
Binary file
@@ -0,0 +1,36 @@
1
+ /**
2
+ * ObjectUI — reference-key canonicalization
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
+ * Backend object schemas follow the ObjectStack convention and name a
10
+ * relational field's target object `reference`
11
+ * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`), while ObjectUI's
12
+ * types — and most in-repo consumers — historically read `reference_to`
13
+ * (some legacy configs also carry camelCase `referenceTo`). A consumer that
14
+ * reads only one key silently loses the relation under the other convention:
15
+ * the exact bug HeaderHighlight had (#2407 / PR #2587), where a served
16
+ * `reference`-keyed lookup rendered a raw id.
17
+ *
18
+ * `normalizeFieldReferenceKeys` stamps BOTH snake_case keys onto the field
19
+ * definition whenever any of the three spellings is present, so downstream
20
+ * reads work regardless of which single key they check. It mutates the field
21
+ * in place — the ObjectStack adapter caches the schema object and re-serves
22
+ * it, so the one pass must stick — and is idempotent. Keys that are already
23
+ * set are never overwritten.
24
+ */
25
+ export declare function normalizeFieldReferenceKeys<T>(fieldDef: T): T;
26
+ /**
27
+ * Apply {@link normalizeFieldReferenceKeys} to every field of an object
28
+ * schema. Accepts both field-container shapes the metadata API serves —
29
+ * a `name → def` map or an array of defs — and tolerates anything else by
30
+ * returning the input untouched. Mutates in place; idempotent.
31
+ *
32
+ * This is meant to run at the choke point where object schemas enter the
33
+ * client (`ObjectStackAdapter.getObjectSchema`, the app-shell metadata
34
+ * provider) so per-consumer dual-key fallbacks can't drift.
35
+ */
36
+ export declare function normalizeSchemaReferenceKeys<T>(schema: T): T;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * ObjectUI — reference-key canonicalization
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
+ * Backend object schemas follow the ObjectStack convention and name a
10
+ * relational field's target object `reference`
11
+ * (e.g. `{ type: 'lookup', reference: 'showcase_account' }`), while ObjectUI's
12
+ * types — and most in-repo consumers — historically read `reference_to`
13
+ * (some legacy configs also carry camelCase `referenceTo`). A consumer that
14
+ * reads only one key silently loses the relation under the other convention:
15
+ * the exact bug HeaderHighlight had (#2407 / PR #2587), where a served
16
+ * `reference`-keyed lookup rendered a raw id.
17
+ *
18
+ * `normalizeFieldReferenceKeys` stamps BOTH snake_case keys onto the field
19
+ * definition whenever any of the three spellings is present, so downstream
20
+ * reads work regardless of which single key they check. It mutates the field
21
+ * in place — the ObjectStack adapter caches the schema object and re-serves
22
+ * it, so the one pass must stick — and is idempotent. Keys that are already
23
+ * set are never overwritten.
24
+ */
25
+ export function normalizeFieldReferenceKeys(fieldDef) {
26
+ if (!fieldDef || typeof fieldDef !== 'object')
27
+ return fieldDef;
28
+ const f = fieldDef;
29
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
30
+ if (target == null || target === '')
31
+ return fieldDef;
32
+ if (f.reference_to === undefined)
33
+ f.reference_to = target;
34
+ if (f.reference === undefined)
35
+ f.reference = target;
36
+ return fieldDef;
37
+ }
38
+ /**
39
+ * Apply {@link normalizeFieldReferenceKeys} to every field of an object
40
+ * schema. Accepts both field-container shapes the metadata API serves —
41
+ * a `name → def` map or an array of defs — and tolerates anything else by
42
+ * returning the input untouched. Mutates in place; idempotent.
43
+ *
44
+ * This is meant to run at the choke point where object schemas enter the
45
+ * client (`ObjectStackAdapter.getObjectSchema`, the app-shell metadata
46
+ * provider) so per-consumer dual-key fallbacks can't drift.
47
+ */
48
+ export function normalizeSchemaReferenceKeys(schema) {
49
+ const fields = schema && typeof schema === 'object' ? schema.fields : null;
50
+ if (!fields || typeof fields !== 'object')
51
+ return schema;
52
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
53
+ for (const def of defs)
54
+ normalizeFieldReferenceKeys(def);
55
+ return schema;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "@objectstack/spec": "^14.6.0",
30
30
  "lodash": "^4.18.1",
31
31
  "zod": "^4.4.3",
32
- "@object-ui/types": "14.0.0"
32
+ "@object-ui/types": "14.1.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^6.0.3",