@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 +137 -0
- package/dist/actions/ActionEngine.js +1 -1
- package/dist/actions/ActionRunner.js +36 -5
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +93 -0
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionLint.d.ts +43 -0
- package/dist/evaluator/optionLint.js +166 -0
- package/dist/evaluator/optionRules.d.ts +1 -1
- package/dist/evaluator/optionRules.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/dashboard-filters.d.ts +98 -0
- package/dist/utils/dashboard-filters.js +229 -0
- package/dist/utils/merge-filters.d.ts +16 -0
- package/dist/utils/merge-filters.js +26 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/reference-keys.d.ts +36 -0
- package/dist/utils/reference-keys.js +56 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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';
|
package/dist/evaluator/index.js
CHANGED
|
@@ -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>;
|
|
Binary file
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
32
|
+
"@object-ui/types": "14.1.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^6.0.3",
|