@object-ui/core 16.1.0 → 17.2.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 +1881 -4
- package/dist/actions/ActionEngine.d.ts +11 -1
- package/dist/actions/ActionEngine.js +14 -3
- package/dist/actions/ActionRunner.d.ts +199 -24
- package/dist/actions/ActionRunner.js +189 -84
- package/dist/actions/actionKeys.d.ts +135 -0
- package/dist/actions/actionKeys.js +283 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/adapters/ApiDataSource.js +10 -1
- package/dist/adapters/ValueDataSource.d.ts +2 -2
- package/dist/evaluator/fieldRules.d.ts +0 -2
- package/dist/evaluator/fieldRules.js +3 -2
- package/dist/evaluator/optionLint.js +5 -2
- package/dist/evaluator/optionRules.d.ts +13 -6
- package/dist/evaluator/optionRules.js +0 -23
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/query/query-ast.d.ts +6 -6
- package/dist/query/query-ast.js +2 -2
- package/dist/registry/PluginSystem.d.ts +15 -5
- package/dist/registry/PluginSystem.js +1 -1
- package/dist/registry/Registry.d.ts +81 -2
- package/dist/registry/Registry.js +102 -2
- package/dist/registry/WidgetRegistry.d.ts +7 -7
- package/dist/registry/WidgetRegistry.js +1 -1
- package/dist/registry/public-blocks.js +52 -1
- package/dist/styling/scoped-styles.d.ts +14 -8
- package/dist/theme/ThemeEngine.js +0 -12
- package/dist/utils/chart-series.d.ts +42 -2
- package/dist/utils/chart-series.js +55 -0
- package/dist/utils/column-identity.d.ts +110 -0
- package/dist/utils/column-identity.js +210 -0
- package/dist/utils/dashboard-filters.d.ts +11 -2
- package/dist/utils/dashboard-filters.js +99 -4
- package/dist/utils/dataset-format.d.ts +13 -1
- package/dist/utils/dataset-format.js +10 -2
- package/dist/utils/expand-fields.js +8 -8
- package/dist/utils/filter-converter.d.ts +47 -0
- package/dist/utils/filter-converter.js +94 -7
- package/dist/utils/filter-tokens.d.ts +108 -0
- package/dist/utils/filter-tokens.js +180 -0
- package/dist/utils/freeze-schema.d.ts +14 -6
- package/dist/utils/freeze-schema.js +14 -6
- package/dist/utils/managedBy.d.ts +97 -51
- package/dist/utils/managedBy.js +117 -53
- package/dist/utils/normalize-list-view.d.ts +109 -0
- package/dist/utils/normalize-list-view.js +255 -0
- package/dist/utils/sort-values.d.ts +52 -0
- package/dist/utils/sort-values.js +125 -0
- package/dist/validation/index.d.ts +10 -1
- package/dist/validation/index.js +10 -1
- package/dist/validation/required-presence.d.ts +31 -0
- package/dist/validation/required-presence.js +36 -0
- package/dist/validation/schema-validator.d.ts +24 -8
- package/dist/validation/validators/index.d.ts +7 -1
- package/dist/validation/validators/index.js +7 -1
- package/dist/validation/validators/object-validation-engine.d.ts +151 -31
- package/dist/validation/validators/object-validation-engine.js +275 -142
- package/package.json +4 -4
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — column identity 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
|
+
* The spec's canonical key for "which object field this column shows".
|
|
10
|
+
* `ListColumnSchema` (`@objectstack/spec/view`) declares `field: z.ZodString`
|
|
11
|
+
* as its ONLY required key — there is no `name`, no `fieldName`, no
|
|
12
|
+
* `accessorKey` on it. So `field` is not one dialect among several: it is the
|
|
13
|
+
* contract, and everything else is objectui-side legacy (AGENTS.md #0).
|
|
14
|
+
*
|
|
15
|
+
* Note this is the exact opposite of the FORM layer, where the runtime key is
|
|
16
|
+
* `name` (objectui#3090). The two surfaces sit next to each other and mean the
|
|
17
|
+
* opposite thing by the same pair of key names, which is why copy-paste between
|
|
18
|
+
* them keeps producing the defect this module exists to close.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CANONICAL_COLUMN_IDENTITY_KEY = "field";
|
|
21
|
+
/**
|
|
22
|
+
* Legacy identity spellings, in the order a canonicalizing read falls back
|
|
23
|
+
* through them. `field` wins over both, always.
|
|
24
|
+
*
|
|
25
|
+
* `fieldName` is here for defence, not because objectui emits it: as of
|
|
26
|
+
* objectui#3104 a repo-wide scan finds NO writer of a column-level `fieldName`
|
|
27
|
+
* — every `fieldName:` occurrence in the tree is a function parameter or an
|
|
28
|
+
* unrelated interface member (`AIFieldSuggestion.fieldName`). It survives
|
|
29
|
+
* purely in READERS that were written defensively, and stored host metadata
|
|
30
|
+
* could still carry it, so the fold accepts it and the ratchet counts it.
|
|
31
|
+
*/
|
|
32
|
+
export declare const LEGACY_COLUMN_IDENTITY_KEYS: readonly ["name", "fieldName"];
|
|
33
|
+
/**
|
|
34
|
+
* NOT an identity key — deliberately excluded from everything in this module.
|
|
35
|
+
*
|
|
36
|
+
* `accessorKey` is TanStack Table's own column key (`TableColumn.accessorKey`,
|
|
37
|
+
* `packages/types/src/data-display.ts`, "Data accessor key"). It names a slot in
|
|
38
|
+
* the table LIBRARY's column model, not a field in ObjectStack metadata. Reads
|
|
39
|
+
* like `RelatedList`'s `accessorKey || field || name` therefore merge two
|
|
40
|
+
* different vocabularies in one expression; canonicalizing across that boundary
|
|
41
|
+
* would fossilize the merge, which is precisely the mistake this battle exists
|
|
42
|
+
* to undo. The fold neither reads nor writes it.
|
|
43
|
+
*/
|
|
44
|
+
export declare const TABLE_ADAPTER_COLUMN_KEY = "accessorKey";
|
|
45
|
+
/**
|
|
46
|
+
* Read a column entry's field identity — the ONE reader every consumer should
|
|
47
|
+
* converge onto (objectui#3104 PR2).
|
|
48
|
+
*
|
|
49
|
+
* Accepts the three shapes a `columns` array actually holds: a bare string
|
|
50
|
+
* (`'stage'`), a spec column (`{ field: 'stage' }`), and the legacy objectui
|
|
51
|
+
* shapes (`{ name }` / `{ fieldName }`). Resolution is canonical-first, so it
|
|
52
|
+
* agrees with `buildExpandFields` and with the `$select` projection rather than
|
|
53
|
+
* with whichever half of a mixed-key column happened to be read.
|
|
54
|
+
*
|
|
55
|
+
* Returns `undefined` — not `''` — when nothing is resolvable, so callers can
|
|
56
|
+
* tell "no identity" from "identity is the empty string" and drop the entry
|
|
57
|
+
* instead of requesting a nameless column.
|
|
58
|
+
*/
|
|
59
|
+
export declare function columnIdentity(entry: unknown): string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* True when a column entry carries more than one identity key AND they
|
|
62
|
+
* disagree — the shape that makes the renderer and the data request resolve
|
|
63
|
+
* two different fields. Exported for the "say who wins, loudly" step
|
|
64
|
+
* (objectui#3104 PR3) and used by the tests to name the defect precisely.
|
|
65
|
+
*/
|
|
66
|
+
export declare function hasConflictingColumnIdentity(entry: unknown): boolean;
|
|
67
|
+
/** Reset the warn-once memo. Exported for tests. */
|
|
68
|
+
export declare function resetColumnIdentityWarnings(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Stamp the canonical identity onto one column entry.
|
|
71
|
+
*
|
|
72
|
+
* The transitional strategy (objectui#3104 PR1) is **mirror, don't delete**:
|
|
73
|
+
*
|
|
74
|
+
* - `field` is written with the resolved identity, so the request path — which
|
|
75
|
+
* reads `field` and ONLY `field` in `ListView`'s `$expand`/`$select`
|
|
76
|
+
* builders and in `ObjectGrid.getSelectFields` — always finds one.
|
|
77
|
+
* - a legacy key that is ALREADY PRESENT is overwritten with that same
|
|
78
|
+
* identity, so the name-first read sites still standing (`ListView` ×8,
|
|
79
|
+
* `ObjectGrid`, `ObjectTree`, the detail renderers) resolve to exactly what
|
|
80
|
+
* the request asked for.
|
|
81
|
+
* - a legacy key that is ABSENT is never invented. A spec-clean
|
|
82
|
+
* `{ field: 'stage' }` comes back untouched, by reference; the fold does not
|
|
83
|
+
* manufacture the legacy vocabulary it is trying to retire.
|
|
84
|
+
*
|
|
85
|
+
* This differs on purpose from `normalizeListViewSchema`'s other folds, which
|
|
86
|
+
* DELETE the legacy key so a missed read-site fails loudly. Deleting works
|
|
87
|
+
* inside this repo — every name-first read here falls through to `field` — but
|
|
88
|
+
* `columns` entries cross the package boundary into host applications and
|
|
89
|
+
* third-party renderers, and dropping `name` from under them is a breaking
|
|
90
|
+
* change we have no inventory for. Mirroring is behaviour-preserving for every
|
|
91
|
+
* reader, in or out of the repo; the deletion is PR3's call to make once the
|
|
92
|
+
* in-repo consumers have converged onto {@link columnIdentity}.
|
|
93
|
+
*
|
|
94
|
+
* The result: for any single-key column the resolved identity is unchanged at
|
|
95
|
+
* every read site. The only entries whose behaviour moves are the ones where
|
|
96
|
+
* two sites already disagreed — which is the defect, not a regression.
|
|
97
|
+
*/
|
|
98
|
+
export declare function normalizeColumnIdentity<T>(entry: T): T;
|
|
99
|
+
/**
|
|
100
|
+
* Stamp the canonical identity across a `columns` array.
|
|
101
|
+
*
|
|
102
|
+
* Non-mutating and allocation-frugal in the same way as
|
|
103
|
+
* `normalizeListViewSchema`: when every entry is already canonical the INPUT
|
|
104
|
+
* array is returned by reference, so `ListView`'s downstream `useMemo`s keep a
|
|
105
|
+
* stable dependency identity on the common path and nothing re-renders.
|
|
106
|
+
*
|
|
107
|
+
* String entries pass through untouched — a bare `'stage'` is unambiguous and
|
|
108
|
+
* every consumer already special-cases it.
|
|
109
|
+
*/
|
|
110
|
+
export declare function normalizeColumnIdentities<T>(columns: T): T;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — column identity 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
|
+
* The spec's canonical key for "which object field this column shows".
|
|
10
|
+
* `ListColumnSchema` (`@objectstack/spec/view`) declares `field: z.ZodString`
|
|
11
|
+
* as its ONLY required key — there is no `name`, no `fieldName`, no
|
|
12
|
+
* `accessorKey` on it. So `field` is not one dialect among several: it is the
|
|
13
|
+
* contract, and everything else is objectui-side legacy (AGENTS.md #0).
|
|
14
|
+
*
|
|
15
|
+
* Note this is the exact opposite of the FORM layer, where the runtime key is
|
|
16
|
+
* `name` (objectui#3090). The two surfaces sit next to each other and mean the
|
|
17
|
+
* opposite thing by the same pair of key names, which is why copy-paste between
|
|
18
|
+
* them keeps producing the defect this module exists to close.
|
|
19
|
+
*/
|
|
20
|
+
export const CANONICAL_COLUMN_IDENTITY_KEY = 'field';
|
|
21
|
+
/**
|
|
22
|
+
* Legacy identity spellings, in the order a canonicalizing read falls back
|
|
23
|
+
* through them. `field` wins over both, always.
|
|
24
|
+
*
|
|
25
|
+
* `fieldName` is here for defence, not because objectui emits it: as of
|
|
26
|
+
* objectui#3104 a repo-wide scan finds NO writer of a column-level `fieldName`
|
|
27
|
+
* — every `fieldName:` occurrence in the tree is a function parameter or an
|
|
28
|
+
* unrelated interface member (`AIFieldSuggestion.fieldName`). It survives
|
|
29
|
+
* purely in READERS that were written defensively, and stored host metadata
|
|
30
|
+
* could still carry it, so the fold accepts it and the ratchet counts it.
|
|
31
|
+
*/
|
|
32
|
+
export const LEGACY_COLUMN_IDENTITY_KEYS = ['name', 'fieldName'];
|
|
33
|
+
/**
|
|
34
|
+
* NOT an identity key — deliberately excluded from everything in this module.
|
|
35
|
+
*
|
|
36
|
+
* `accessorKey` is TanStack Table's own column key (`TableColumn.accessorKey`,
|
|
37
|
+
* `packages/types/src/data-display.ts`, "Data accessor key"). It names a slot in
|
|
38
|
+
* the table LIBRARY's column model, not a field in ObjectStack metadata. Reads
|
|
39
|
+
* like `RelatedList`'s `accessorKey || field || name` therefore merge two
|
|
40
|
+
* different vocabularies in one expression; canonicalizing across that boundary
|
|
41
|
+
* would fossilize the merge, which is precisely the mistake this battle exists
|
|
42
|
+
* to undo. The fold neither reads nor writes it.
|
|
43
|
+
*/
|
|
44
|
+
export const TABLE_ADAPTER_COLUMN_KEY = 'accessorKey';
|
|
45
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
46
|
+
const asIdentity = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Read a column entry's field identity — the ONE reader every consumer should
|
|
49
|
+
* converge onto (objectui#3104 PR2).
|
|
50
|
+
*
|
|
51
|
+
* Accepts the three shapes a `columns` array actually holds: a bare string
|
|
52
|
+
* (`'stage'`), a spec column (`{ field: 'stage' }`), and the legacy objectui
|
|
53
|
+
* shapes (`{ name }` / `{ fieldName }`). Resolution is canonical-first, so it
|
|
54
|
+
* agrees with `buildExpandFields` and with the `$select` projection rather than
|
|
55
|
+
* with whichever half of a mixed-key column happened to be read.
|
|
56
|
+
*
|
|
57
|
+
* Returns `undefined` — not `''` — when nothing is resolvable, so callers can
|
|
58
|
+
* tell "no identity" from "identity is the empty string" and drop the entry
|
|
59
|
+
* instead of requesting a nameless column.
|
|
60
|
+
*/
|
|
61
|
+
export function columnIdentity(entry) {
|
|
62
|
+
if (typeof entry === 'string')
|
|
63
|
+
return asIdentity(entry);
|
|
64
|
+
if (!isRecord(entry))
|
|
65
|
+
return undefined;
|
|
66
|
+
const canonical = asIdentity(entry[CANONICAL_COLUMN_IDENTITY_KEY]);
|
|
67
|
+
if (canonical)
|
|
68
|
+
return canonical;
|
|
69
|
+
for (const key of LEGACY_COLUMN_IDENTITY_KEYS) {
|
|
70
|
+
const legacy = asIdentity(entry[key]);
|
|
71
|
+
if (legacy)
|
|
72
|
+
return legacy;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True when a column entry carries more than one identity key AND they
|
|
78
|
+
* disagree — the shape that makes the renderer and the data request resolve
|
|
79
|
+
* two different fields. Exported for the "say who wins, loudly" step
|
|
80
|
+
* (objectui#3104 PR3) and used by the tests to name the defect precisely.
|
|
81
|
+
*/
|
|
82
|
+
export function hasConflictingColumnIdentity(entry) {
|
|
83
|
+
if (!isRecord(entry))
|
|
84
|
+
return false;
|
|
85
|
+
const resolved = columnIdentity(entry);
|
|
86
|
+
if (!resolved)
|
|
87
|
+
return false;
|
|
88
|
+
for (const key of [CANONICAL_COLUMN_IDENTITY_KEY, ...LEGACY_COLUMN_IDENTITY_KEYS]) {
|
|
89
|
+
const present = asIdentity(entry[key]);
|
|
90
|
+
if (present && present !== resolved)
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
// Warn once per (identity, conflicting spelling), not once per fold: columns are
|
|
96
|
+
// re-normalized on every ListView render, and a warning that floods the console
|
|
97
|
+
// is a warning that gets muted. Keyed by the pair rather than by the identity
|
|
98
|
+
// alone so a column carrying two different stale spellings reports both — the
|
|
99
|
+
// author needs to fix every producer, not just the first one seen.
|
|
100
|
+
const warnedConflicts = new Set();
|
|
101
|
+
/** Reset the warn-once memo. Exported for tests. */
|
|
102
|
+
export function resetColumnIdentityWarnings() {
|
|
103
|
+
warnedConflicts.clear();
|
|
104
|
+
}
|
|
105
|
+
const isDev = () => globalThis.process?.env?.NODE_ENV !==
|
|
106
|
+
'production';
|
|
107
|
+
/**
|
|
108
|
+
* Dev-mode only: say which key won, out loud, when a column carries two identity
|
|
109
|
+
* spellings that disagree.
|
|
110
|
+
*
|
|
111
|
+
* This is the audible half of objectui#3104. The fold below makes the two halves
|
|
112
|
+
* of such a column agree, which is what stops the bug — but silently rewriting
|
|
113
|
+
* `name` to match `field` also hides that the metadata producer is emitting a
|
|
114
|
+
* contradiction. The renderer recovering is not the same as the metadata being
|
|
115
|
+
* right, so the recovery says so.
|
|
116
|
+
*
|
|
117
|
+
* Non-breaking by construction: changes no types, rejects nothing, and is a
|
|
118
|
+
* no-op under `NODE_ENV=production`.
|
|
119
|
+
*/
|
|
120
|
+
function warnOnConflictingIdentity(entry, identity, losing) {
|
|
121
|
+
if (!isDev() || losing.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
for (const key of losing) {
|
|
124
|
+
const memo = `${identity}:${key}:${String(entry[key])}`;
|
|
125
|
+
if (warnedConflicts.has(memo))
|
|
126
|
+
continue;
|
|
127
|
+
warnedConflicts.add(memo);
|
|
128
|
+
// eslint-disable-next-line no-console
|
|
129
|
+
console.warn(`[ObjectUI] Column carries two identities: \`${CANONICAL_COLUMN_IDENTITY_KEY}: ` +
|
|
130
|
+
`'${identity}'\` and \`${key}: '${String(entry[key])}'\`. ` +
|
|
131
|
+
`\`${CANONICAL_COLUMN_IDENTITY_KEY}\` wins — it is the only key ` +
|
|
132
|
+
`\`ListColumnSchema\` declares — and \`${key}\` has been rewritten to match, ` +
|
|
133
|
+
`so the rendered column and the requested field agree. Fix the producer: ` +
|
|
134
|
+
`drop \`${key}\` and author \`${CANONICAL_COLUMN_IDENTITY_KEY}\` only. ` +
|
|
135
|
+
`(objectui#3104)`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Stamp the canonical identity onto one column entry.
|
|
140
|
+
*
|
|
141
|
+
* The transitional strategy (objectui#3104 PR1) is **mirror, don't delete**:
|
|
142
|
+
*
|
|
143
|
+
* - `field` is written with the resolved identity, so the request path — which
|
|
144
|
+
* reads `field` and ONLY `field` in `ListView`'s `$expand`/`$select`
|
|
145
|
+
* builders and in `ObjectGrid.getSelectFields` — always finds one.
|
|
146
|
+
* - a legacy key that is ALREADY PRESENT is overwritten with that same
|
|
147
|
+
* identity, so the name-first read sites still standing (`ListView` ×8,
|
|
148
|
+
* `ObjectGrid`, `ObjectTree`, the detail renderers) resolve to exactly what
|
|
149
|
+
* the request asked for.
|
|
150
|
+
* - a legacy key that is ABSENT is never invented. A spec-clean
|
|
151
|
+
* `{ field: 'stage' }` comes back untouched, by reference; the fold does not
|
|
152
|
+
* manufacture the legacy vocabulary it is trying to retire.
|
|
153
|
+
*
|
|
154
|
+
* This differs on purpose from `normalizeListViewSchema`'s other folds, which
|
|
155
|
+
* DELETE the legacy key so a missed read-site fails loudly. Deleting works
|
|
156
|
+
* inside this repo — every name-first read here falls through to `field` — but
|
|
157
|
+
* `columns` entries cross the package boundary into host applications and
|
|
158
|
+
* third-party renderers, and dropping `name` from under them is a breaking
|
|
159
|
+
* change we have no inventory for. Mirroring is behaviour-preserving for every
|
|
160
|
+
* reader, in or out of the repo; the deletion is PR3's call to make once the
|
|
161
|
+
* in-repo consumers have converged onto {@link columnIdentity}.
|
|
162
|
+
*
|
|
163
|
+
* The result: for any single-key column the resolved identity is unchanged at
|
|
164
|
+
* every read site. The only entries whose behaviour moves are the ones where
|
|
165
|
+
* two sites already disagreed — which is the defect, not a regression.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeColumnIdentity(entry) {
|
|
168
|
+
if (typeof entry === 'string' || !isRecord(entry))
|
|
169
|
+
return entry;
|
|
170
|
+
const identity = columnIdentity(entry);
|
|
171
|
+
if (!identity)
|
|
172
|
+
return entry;
|
|
173
|
+
const needsCanonical = entry[CANONICAL_COLUMN_IDENTITY_KEY] !== identity;
|
|
174
|
+
const staleLegacy = LEGACY_COLUMN_IDENTITY_KEYS.filter((key) => entry[key] !== undefined && entry[key] !== identity);
|
|
175
|
+
if (!needsCanonical && staleLegacy.length === 0)
|
|
176
|
+
return entry;
|
|
177
|
+
// Say who won before rewriting, while the losing spelling is still readable.
|
|
178
|
+
// Only genuine contradictions are reported: a column that merely lacks the
|
|
179
|
+
// canonical key (`{ name: 'stage' }`) is legacy, not conflicting, and gets
|
|
180
|
+
// stamped without noise.
|
|
181
|
+
warnOnConflictingIdentity(entry, identity, staleLegacy);
|
|
182
|
+
const next = { ...entry };
|
|
183
|
+
next[CANONICAL_COLUMN_IDENTITY_KEY] = identity;
|
|
184
|
+
for (const key of staleLegacy)
|
|
185
|
+
next[key] = identity;
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Stamp the canonical identity across a `columns` array.
|
|
190
|
+
*
|
|
191
|
+
* Non-mutating and allocation-frugal in the same way as
|
|
192
|
+
* `normalizeListViewSchema`: when every entry is already canonical the INPUT
|
|
193
|
+
* array is returned by reference, so `ListView`'s downstream `useMemo`s keep a
|
|
194
|
+
* stable dependency identity on the common path and nothing re-renders.
|
|
195
|
+
*
|
|
196
|
+
* String entries pass through untouched — a bare `'stage'` is unambiguous and
|
|
197
|
+
* every consumer already special-cases it.
|
|
198
|
+
*/
|
|
199
|
+
export function normalizeColumnIdentities(columns) {
|
|
200
|
+
if (!Array.isArray(columns))
|
|
201
|
+
return columns;
|
|
202
|
+
let changed = false;
|
|
203
|
+
const next = columns.map((entry) => {
|
|
204
|
+
const folded = normalizeColumnIdentity(entry);
|
|
205
|
+
if (folded !== entry)
|
|
206
|
+
changed = true;
|
|
207
|
+
return folded;
|
|
208
|
+
});
|
|
209
|
+
return (changed ? next : columns);
|
|
210
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Everything in this module is pure and synchronous so the binding rules
|
|
19
19
|
* are unit-testable in isolation from React and the data layer.
|
|
20
20
|
*/
|
|
21
|
-
import type {
|
|
21
|
+
import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types';
|
|
22
22
|
/** Reserved filter name for the dashboard's built-in date range. */
|
|
23
23
|
export declare const DATE_RANGE_FILTER_NAME = "dateRange";
|
|
24
24
|
export interface DashboardFilterDef {
|
|
@@ -66,7 +66,7 @@ export declare const DATE_RANGE_PRESETS: string[];
|
|
|
66
66
|
* under the reserved name `"dateRange"`; each `globalFilters` entry follows,
|
|
67
67
|
* named by its `name` (defaulting to `field`). Later duplicates win.
|
|
68
68
|
*/
|
|
69
|
-
export declare function resolveDashboardFilterDefs(schema: Pick<
|
|
69
|
+
export declare function resolveDashboardFilterDefs(schema: Pick<DashboardComponentSchema, 'globalFilters' | 'dateRange'>): DashboardFilterDef[];
|
|
70
70
|
/**
|
|
71
71
|
* Derive `PageVariable` definitions for a dashboard's filter values so the
|
|
72
72
|
* dashboard can host them in a `PageVariablesProvider` (the page/dashboard
|
|
@@ -78,6 +78,15 @@ export declare function dashboardFilterVariableDefs(defs: DashboardFilterDef[]):
|
|
|
78
78
|
* Build the operator shape (the value side of a `FilterCondition` entry) for
|
|
79
79
|
* one filter's current value. Returns `undefined` when the value imposes no
|
|
80
80
|
* constraint. The caller keys the result by the bound field name.
|
|
81
|
+
*
|
|
82
|
+
* A `date`/`dateRange` value is held to three spellings (#3151): a known
|
|
83
|
+
* preset name → range bounds; a date-macro token or ISO date → equality on
|
|
84
|
+
* that day; **anything else → skipped with a console warning**, never
|
|
85
|
+
* silently downgraded to an equality nothing can match. That last branch is
|
|
86
|
+
* the same strictness `buildWidgetScopedFilter` applies to a default binding
|
|
87
|
+
* on an unknown field name, for the same reason: a query the backend answers
|
|
88
|
+
* `200 OK` with zero rows is indistinguishable from "this range has no data",
|
|
89
|
+
* so the typo has to be said out loud somewhere.
|
|
81
90
|
*/
|
|
82
91
|
export declare function buildFilterCondition(def: DashboardFilterDef, value: unknown): Record<string, unknown> | unknown | undefined;
|
|
83
92
|
/**
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
+
import { resolveDateMacros } from './date-macros.js';
|
|
8
9
|
/** Reserved filter name for the dashboard's built-in date range. */
|
|
9
10
|
export const DATE_RANGE_FILTER_NAME = 'dateRange';
|
|
10
11
|
/** Default target field for the built-in date range filter. */
|
|
@@ -31,6 +32,72 @@ const PRESET_RANGES = {
|
|
|
31
32
|
};
|
|
32
33
|
/** Preset keys the filter bar offers, in display order. */
|
|
33
34
|
export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES);
|
|
35
|
+
/**
|
|
36
|
+
* ISO calendar date, optionally carrying a time part — `2026-01-15`,
|
|
37
|
+
* `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse`, which
|
|
38
|
+
* also accepts locale prose (`March 5, 2026`) and bare years (`2026`);
|
|
39
|
+
* neither is a value the backend compares a date column against usefully.
|
|
40
|
+
*/
|
|
41
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
42
|
+
/**
|
|
43
|
+
* True when a bare string value can legitimately reach a query as a date
|
|
44
|
+
* (#3151). Two spellings qualify:
|
|
45
|
+
*
|
|
46
|
+
* - a **date-macro token** (`{today}`, `${current_month_start}`,
|
|
47
|
+
* `{7_days_ago}`) — it stays symbolic in the generated condition and is
|
|
48
|
+
* resolved at query time by `resolveDateMacros`, exactly like the bounds
|
|
49
|
+
* `PRESET_RANGES` emits. The check asks that resolver itself instead of
|
|
50
|
+
* restating its grammar here: one token vocabulary, no second dialect to
|
|
51
|
+
* drift — and a token it does not know is precisely the typo this guard
|
|
52
|
+
* exists to catch;
|
|
53
|
+
* - an **ISO date**, which means equality on that day (documented behaviour).
|
|
54
|
+
*/
|
|
55
|
+
function isUsableDateString(value) {
|
|
56
|
+
if (resolveDateMacros(value) !== value)
|
|
57
|
+
return true;
|
|
58
|
+
return ISO_DATE_RE.test(value) && !Number.isNaN(Date.parse(value));
|
|
59
|
+
}
|
|
60
|
+
/** `today, yesterday, …` — quoted in every rejection so the fix is in reach. */
|
|
61
|
+
function presetList() {
|
|
62
|
+
return DATE_RANGE_PRESETS.join(', ');
|
|
63
|
+
}
|
|
64
|
+
function warnDateFilter(message) {
|
|
65
|
+
if (typeof console !== 'undefined')
|
|
66
|
+
console.warn(`[dashboard-filters] ${message}`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a date filter's DECLARED default into the `DateRangeValue` shape
|
|
70
|
+
* every date consumer in this module reads (framework#4475).
|
|
71
|
+
*
|
|
72
|
+
* The built-in `dateRange` declaration has always been normalized this way —
|
|
73
|
+
* `schema.dateRange.defaultRange` is a preset NAME and
|
|
74
|
+
* `resolveDashboardFilterDefs` lifts it to `{ preset }`. A `globalFilters`
|
|
75
|
+
* entry of `type: 'date'` was passed through raw instead, and that asymmetry
|
|
76
|
+
* is the whole bug: `@objectstack/spec`'s `GlobalFilterSchema.defaultValue` is
|
|
77
|
+
* `string | number | boolean`, so a bare preset name is the ONLY spelling an
|
|
78
|
+
* author can write, yet nothing ever mapped it. Both symptoms of Setup's
|
|
79
|
+
* System Overview reading 0 across every KPI tile follow from that:
|
|
80
|
+
*
|
|
81
|
+
* - `buildFilterCondition` fell through to its "a bare string date means
|
|
82
|
+
* equality on that day" branch and emitted `created_at = 'last_7_days'`,
|
|
83
|
+
* which matches no row — a query the backend answers `200 OK` with a 0;
|
|
84
|
+
* - `DateRangeFilter` reads `value.preset` / `.from` / `.to`, all `undefined`
|
|
85
|
+
* on a bare string, so the control displayed "All time" while sending that
|
|
86
|
+
* equality — the tiles looked deliberately unfiltered and merely empty.
|
|
87
|
+
*
|
|
88
|
+
* Only a name this module actually knows is lifted. A genuine ISO date string
|
|
89
|
+
* still means equality on that day (the documented behaviour), and a number /
|
|
90
|
+
* boolean / unrecognised string is left exactly as declared — an unrecognised
|
|
91
|
+
* string then never reaches a query at all: `buildFilterCondition` skips it
|
|
92
|
+
* with a warning rather than comparing a column against it (#3151).
|
|
93
|
+
*/
|
|
94
|
+
function normalizeDateDefault(type, defaultValue) {
|
|
95
|
+
if (type !== 'date' && type !== 'dateRange')
|
|
96
|
+
return defaultValue;
|
|
97
|
+
if (typeof defaultValue !== 'string')
|
|
98
|
+
return defaultValue;
|
|
99
|
+
return defaultValue in PRESET_RANGES ? { preset: defaultValue } : defaultValue;
|
|
100
|
+
}
|
|
34
101
|
/**
|
|
35
102
|
* Normalize a filter's static `options` declaration to `{ value, label }`
|
|
36
103
|
* pairs. The @objectstack/spec `GlobalFilterSchema.options` form is
|
|
@@ -88,14 +155,18 @@ export function resolveDashboardFilterDefs(schema) {
|
|
|
88
155
|
if (byName.has(name) && typeof console !== 'undefined') {
|
|
89
156
|
console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`);
|
|
90
157
|
}
|
|
158
|
+
const type = f.type ?? 'text';
|
|
91
159
|
byName.set(name, {
|
|
92
160
|
name,
|
|
93
161
|
field: f.field,
|
|
94
162
|
label: f.label,
|
|
95
|
-
type
|
|
163
|
+
type,
|
|
96
164
|
options: normalizeFilterOptions(f.options),
|
|
97
165
|
optionsFrom: f.optionsFrom,
|
|
98
|
-
|
|
166
|
+
// framework#4475 — same preset-name lifting the built-in `dateRange`
|
|
167
|
+
// above already does; see normalizeDateDefault for why a bare string is
|
|
168
|
+
// the only thing an author can declare here.
|
|
169
|
+
defaultValue: normalizeDateDefault(type, f.defaultValue),
|
|
99
170
|
targetWidgets: f.targetWidgets,
|
|
100
171
|
});
|
|
101
172
|
}
|
|
@@ -134,6 +205,15 @@ function isEmptyValue(def, value) {
|
|
|
134
205
|
* Build the operator shape (the value side of a `FilterCondition` entry) for
|
|
135
206
|
* one filter's current value. Returns `undefined` when the value imposes no
|
|
136
207
|
* constraint. The caller keys the result by the bound field name.
|
|
208
|
+
*
|
|
209
|
+
* A `date`/`dateRange` value is held to three spellings (#3151): a known
|
|
210
|
+
* preset name → range bounds; a date-macro token or ISO date → equality on
|
|
211
|
+
* that day; **anything else → skipped with a console warning**, never
|
|
212
|
+
* silently downgraded to an equality nothing can match. That last branch is
|
|
213
|
+
* the same strictness `buildWidgetScopedFilter` applies to a default binding
|
|
214
|
+
* on an unknown field name, for the same reason: a query the backend answers
|
|
215
|
+
* `200 OK` with zero rows is indistinguishable from "this range has no data",
|
|
216
|
+
* so the typo has to be said out loud somewhere.
|
|
137
217
|
*/
|
|
138
218
|
export function buildFilterCondition(def, value) {
|
|
139
219
|
if (isEmptyValue(def, value))
|
|
@@ -141,9 +221,18 @@ export function buildFilterCondition(def, value) {
|
|
|
141
221
|
if (def.type === 'dateRange' || def.type === 'date') {
|
|
142
222
|
const v = value;
|
|
143
223
|
if (typeof v === 'object') {
|
|
144
|
-
const
|
|
224
|
+
const preset = typeof v.preset === 'string' && v.preset ? v.preset : undefined;
|
|
225
|
+
const range = preset ? PRESET_RANGES[preset] : undefined;
|
|
145
226
|
const from = range?.from ?? v.from;
|
|
146
227
|
const to = range?.to ?? v.to;
|
|
228
|
+
if (preset && !range) {
|
|
229
|
+
// Was already dropped here — but silently, which reads as "no data".
|
|
230
|
+
warnDateFilter(from || to
|
|
231
|
+
? `filter "${def.name}": ignoring unknown date range preset "${preset}" — ` +
|
|
232
|
+
`using the explicit from/to bounds instead; known presets: ${presetList()}`
|
|
233
|
+
: `skipping filter "${def.name}": unknown date range preset "${preset}" — ` +
|
|
234
|
+
`expected one of: ${presetList()}`);
|
|
235
|
+
}
|
|
147
236
|
if (!from && !to)
|
|
148
237
|
return undefined;
|
|
149
238
|
return {
|
|
@@ -151,7 +240,13 @@ export function buildFilterCondition(def, value) {
|
|
|
151
240
|
...(to ? { $lte: to } : {}),
|
|
152
241
|
};
|
|
153
242
|
}
|
|
154
|
-
|
|
243
|
+
if (typeof v === 'string' && !isUsableDateString(v)) {
|
|
244
|
+
warnDateFilter(`skipping filter "${def.name}": value "${v}" is neither a known date range preset ` +
|
|
245
|
+
`nor an ISO date — expected one of: ${presetList()}, an ISO date (YYYY-MM-DD), ` +
|
|
246
|
+
`or a date macro such as {today}`);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
// A bare string date (or date macro) means equality on that day.
|
|
155
250
|
return value;
|
|
156
251
|
}
|
|
157
252
|
if (def.type === 'select' || def.type === 'lookup') {
|
|
@@ -27,7 +27,19 @@ export interface DatasetResultField {
|
|
|
27
27
|
label?: string;
|
|
28
28
|
format?: string;
|
|
29
29
|
currency?: string;
|
|
30
|
+
percentScale?: PercentScale;
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* How a percentage column's stored number relates to its displayed percentage —
|
|
34
|
+
* the server's answer to a question a `%` format string cannot express:
|
|
35
|
+
* `fraction` is a 0–1 ratio (`1` ⇒ "100%"), `whole` is already percentage
|
|
36
|
+
* points (`1` ⇒ "1%"). Resolved from metadata server-side (a `ratio` measure is
|
|
37
|
+
* a fraction by definition; a measure over a `percent` field inherits that
|
|
38
|
+
* field's scale) and carried on the result column, so display never has to
|
|
39
|
+
* infer it from the value's magnitude — the inference that printed a ratio of
|
|
40
|
+
* exactly 1 as "1.0%" (#3136).
|
|
41
|
+
*/
|
|
42
|
+
export type PercentScale = 'fraction' | 'whole';
|
|
31
43
|
/**
|
|
32
44
|
* Scale a stored `percent`-field value to its DISPLAY magnitude.
|
|
33
45
|
*
|
|
@@ -49,7 +61,7 @@ export declare function percentDisplayValue(value: number): number;
|
|
|
49
61
|
* controls grouping / decimals / percent; it can't be baked into the row value
|
|
50
62
|
* server-side (the same number feeds charts), so it is applied here.
|
|
51
63
|
*/
|
|
52
|
-
export declare function formatMeasure(v: unknown, format?: string, currency?: string): string;
|
|
64
|
+
export declare function formatMeasure(v: unknown, format?: string, currency?: string, percentScale?: PercentScale): string;
|
|
53
65
|
/**
|
|
54
66
|
* Format a non-measure (dimension / label) value — the server already resolves
|
|
55
67
|
* dimension display labels, so this only tidies numbers and nulls.
|
|
@@ -28,7 +28,7 @@ export function percentDisplayValue(value) {
|
|
|
28
28
|
* controls grouping / decimals / percent; it can't be baked into the row value
|
|
29
29
|
* server-side (the same number feeds charts), so it is applied here.
|
|
30
30
|
*/
|
|
31
|
-
export function formatMeasure(v, format, currency) {
|
|
31
|
+
export function formatMeasure(v, format, currency, percentScale) {
|
|
32
32
|
if (v == null)
|
|
33
33
|
return '—';
|
|
34
34
|
if (typeof v !== 'number')
|
|
@@ -59,7 +59,15 @@ export function formatMeasure(v, format, currency) {
|
|
|
59
59
|
// (0.75 ⇒ 75%). Scale to display magnitude the SAME way the list-view cell
|
|
60
60
|
// renderer does — otherwise an avg of 0.608 renders as "0.6%" instead of
|
|
61
61
|
// "60.8%", disagreeing with the per-row "75%" the list already shows.
|
|
62
|
-
|
|
62
|
+
//
|
|
63
|
+
// A DECLARED `percentScale` from the server wins outright: the value-magnitude
|
|
64
|
+
// heuristic below cannot tell a 0–1 ratio of exactly 1 from 1 percentage point
|
|
65
|
+
// (both are the number 1) and resolves it as "1%", so an SLA rate of full
|
|
66
|
+
// compliance rendered as "1.0%" instead of "100.0%" (#3136). The heuristic
|
|
67
|
+
// stays as the fallback for columns that arrive without the annotation.
|
|
68
|
+
const display = isPercent
|
|
69
|
+
? (percentScale ? (percentScale === 'fraction' ? v * 100 : v) : percentDisplayValue(v))
|
|
70
|
+
: v;
|
|
63
71
|
const body = display.toLocaleString(undefined, { minimumFractionDigits: decimals ?? 0, maximumFractionDigits: decimals ?? 0 });
|
|
64
72
|
return `${legacyDollar}${body}${isPercent ? '%' : ''}`;
|
|
65
73
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
+
import { columnIdentity } from './column-identity.js';
|
|
8
9
|
/**
|
|
9
10
|
* Relational ("reference-bearing") field types whose stored value is a foreign
|
|
10
11
|
* key into another object — and which therefore benefit from `$expand` so a
|
|
@@ -90,14 +91,13 @@ export function buildExpandFields(schemaFields, columns) {
|
|
|
90
91
|
if (columns && Array.isArray(columns) && columns.length > 0) {
|
|
91
92
|
const columnFieldNames = new Set();
|
|
92
93
|
for (const col of columns) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
94
|
+
// `columnIdentity` handles all three entry shapes (bare string, spec
|
|
95
|
+
// `{field}`, legacy `{name}`/`{fieldName}`) and resolves canonical-first
|
|
96
|
+
// — the same precedence every renderer now uses, so what gets expanded
|
|
97
|
+
// and what gets rendered can no longer name two different fields (#3104).
|
|
98
|
+
const name = columnIdentity(col);
|
|
99
|
+
if (name)
|
|
100
|
+
columnFieldNames.add(name);
|
|
101
101
|
}
|
|
102
102
|
return referenceFieldNames.filter((f) => columnFieldNames.has(f));
|
|
103
103
|
}
|
|
@@ -29,6 +29,19 @@ export type FilterNode = [string, string, any] | [string, ...FilterNode[]];
|
|
|
29
29
|
* @param operator - MongoDB-style operator (e.g., '$gte', '$in')
|
|
30
30
|
* @returns ObjectStack operator or null if not recognized
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* A filter operator this layer will not translate.
|
|
34
|
+
*
|
|
35
|
+
* Carries the data API's own code and status for the same refusal so a failed
|
|
36
|
+
* list renders "the filter is malformed" rather than "check your connection" —
|
|
37
|
+
* `classifyLoadError` reads these, and a bare `Error` classifies as a network
|
|
38
|
+
* fault, which is the one thing this is definitely not.
|
|
39
|
+
*/
|
|
40
|
+
export declare class FilterOperatorError extends Error {
|
|
41
|
+
readonly code = "INVALID_FILTER";
|
|
42
|
+
readonly httpStatus = 400;
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
|
32
45
|
export declare function convertOperatorToAST(operator: string): string | null;
|
|
33
46
|
/**
|
|
34
47
|
* Convert object-based filters to ObjectStack FilterNode AST format.
|
|
@@ -55,3 +68,37 @@ export declare function convertOperatorToAST(operator: string): string | null;
|
|
|
55
68
|
* @throws {Error} If an unknown operator is encountered
|
|
56
69
|
*/
|
|
57
70
|
export declare function convertFiltersToAST(filter: Record<string, any>): FilterNode | Record<string, any>;
|
|
71
|
+
/**
|
|
72
|
+
* Normalize ONE filter source into a single filter node.
|
|
73
|
+
*
|
|
74
|
+
* A "source" is whatever a view hands a renderer, and there are three shapes in
|
|
75
|
+
* circulation, all legitimate:
|
|
76
|
+
*
|
|
77
|
+
* - `[{ field, operator, value }, ...]` a spec `ViewFilterRule[]`
|
|
78
|
+
* - `[['stage', '=', 'won'], ...]` an AST node / legacy flat array
|
|
79
|
+
* - `{ status: 'active' }` a MongoDB-style object
|
|
80
|
+
*
|
|
81
|
+
* The third is the one that kept getting lost. Renderers tested `source.length
|
|
82
|
+
* > 0` before using it, which is `undefined > 0` for an object — so a
|
|
83
|
+
* `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
|
|
84
|
+
* view returned every record. Silently: no error, just a wider answer.
|
|
85
|
+
*
|
|
86
|
+
* Returns `undefined` for an absent or empty source, so callers can skip
|
|
87
|
+
* `$filter` rather than sending an empty array.
|
|
88
|
+
*/
|
|
89
|
+
export declare function toFilterNode(source: unknown): FilterNode | Record<string, any> | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Combine filter sources under a single `and`, each as its OWN child.
|
|
92
|
+
*
|
|
93
|
+
* Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
|
|
94
|
+
* equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
|
|
95
|
+
* where the AST expects nodes, and the server neither understands nor rejects
|
|
96
|
+
* that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
|
|
97
|
+
* `parseFilterAST` reads the rule as a Mongo condition and filters on columns
|
|
98
|
+
* literally named `field` / `operator` / `value`. Spreading is only correct
|
|
99
|
+
* when the source happens to be an array of nodes, which is why it survived.
|
|
100
|
+
*
|
|
101
|
+
* Sources that normalize to nothing are skipped; one surviving source is
|
|
102
|
+
* returned as-is rather than wrapped in a pointless `and`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function mergeFilterNodes(...sources: unknown[]): FilterNode | Record<string, any> | undefined;
|