@object-ui/core 16.0.0 → 17.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +1589 -1
  2. package/dist/actions/ActionRunner.d.ts +88 -16
  3. package/dist/actions/ActionRunner.js +164 -81
  4. package/dist/actions/actionKeys.d.ts +118 -0
  5. package/dist/actions/actionKeys.js +243 -0
  6. package/dist/actions/index.d.ts +1 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/adapters/ApiDataSource.d.ts +11 -1
  9. package/dist/adapters/ApiDataSource.js +21 -1
  10. package/dist/adapters/ValueDataSource.d.ts +10 -2
  11. package/dist/adapters/ValueDataSource.js +9 -0
  12. package/dist/adapters/batchTransaction.d.ts +49 -0
  13. package/dist/adapters/batchTransaction.js +118 -0
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.js +1 -0
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +48 -0
  18. package/dist/evaluator/SafeExpressionParser.js +5 -2
  19. package/dist/evaluator/fieldRules.d.ts +0 -2
  20. package/dist/evaluator/fieldRules.js +3 -2
  21. package/dist/evaluator/optionLint.js +5 -2
  22. package/dist/evaluator/optionRules.d.ts +35 -6
  23. package/dist/evaluator/optionRules.js +18 -23
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +8 -0
  26. package/dist/registry/Registry.d.ts +81 -2
  27. package/dist/registry/Registry.js +102 -2
  28. package/dist/registry/public-blocks.js +52 -1
  29. package/dist/theme/ThemeEngine.js +0 -12
  30. package/dist/utils/chart-series.d.ts +28 -0
  31. package/dist/utils/chart-series.js +55 -0
  32. package/dist/utils/column-identity.d.ts +110 -0
  33. package/dist/utils/column-identity.js +210 -0
  34. package/dist/utils/dashboard-filters.d.ts +2 -2
  35. package/dist/utils/dataset-format.d.ts +18 -1
  36. package/dist/utils/dataset-format.js +14 -1
  37. package/dist/utils/expand-fields.js +8 -8
  38. package/dist/utils/export-filename.js +4 -1
  39. package/dist/utils/filter-converter.d.ts +47 -0
  40. package/dist/utils/filter-converter.js +94 -7
  41. package/dist/utils/filter-tokens.d.ts +101 -0
  42. package/dist/utils/filter-tokens.js +173 -0
  43. package/dist/utils/managedBy.d.ts +119 -0
  44. package/dist/utils/managedBy.js +147 -0
  45. package/dist/utils/normalize-list-view.d.ts +99 -0
  46. package/dist/utils/normalize-list-view.js +255 -0
  47. package/dist/utils/record-title.js +0 -0
  48. package/dist/utils/sort-values.d.ts +52 -0
  49. package/dist/utils/sort-values.js +125 -0
  50. package/dist/validation/index.d.ts +9 -1
  51. package/dist/validation/index.js +9 -1
  52. package/dist/validation/validators/index.d.ts +7 -1
  53. package/dist/validation/validators/index.js +7 -1
  54. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  55. package/dist/validation/validators/object-validation-engine.js +275 -142
  56. package/package.json +4 -4
@@ -147,6 +147,61 @@ export function buildOptionColorMap(options) {
147
147
  }
148
148
  return Object.keys(map).length > 0 ? map : null;
149
149
  }
150
+ /**
151
+ * Build the DECLARED category order from a select field's `options` — the
152
+ * sequence the author wrote them in on the object (framework#3588).
153
+ *
154
+ * A picklist's option order is already the domain order: a sales `stage` field
155
+ * lists Qualification → Needs Analysis → Proposal → Negotiation because that IS
156
+ * the pipeline. Analytics groups by that field and returns buckets in whatever
157
+ * order the GROUP BY produced (usually alphabetical), which for an
158
+ * ordered-sequence chart — a funnel above all — draws a shape that reads as a
159
+ * pipeline but isn't one.
160
+ *
161
+ * Emits BOTH the stored `value` and its display `label` per option, adjacent
162
+ * and in declared order, for the same reason {@link buildOptionColorMap} keys
163
+ * by both: a chart row's category may carry either, depending on whether the
164
+ * server resolved the dimension's labels. Rank is "index of first match", so
165
+ * either key ranks the option identically.
166
+ *
167
+ * Returns `null` for a field with no options, so callers keep their existing
168
+ * ordering (a funnel falls back to descending by value).
169
+ */
170
+ export function buildCategoryOrder(options) {
171
+ if (!Array.isArray(options) || options.length === 0)
172
+ return null;
173
+ const keys = [];
174
+ for (const opt of options) {
175
+ if (typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean') {
176
+ keys.push(String(opt));
177
+ continue;
178
+ }
179
+ if (opt && typeof opt === 'object') {
180
+ const o = opt;
181
+ if (o.value != null)
182
+ keys.push(String(o.value));
183
+ if (o.label != null && String(o.label) !== String(o.value))
184
+ keys.push(String(o.label));
185
+ }
186
+ }
187
+ return keys.length > 0 ? keys : null;
188
+ }
189
+ /**
190
+ * Rank map for {@link buildCategoryOrder} keys — `category → position`, first
191
+ * occurrence wins. Categories absent from the declared order get no entry; the
192
+ * caller decides where those sort (see `AdvancedChartImpl`, which keeps them
193
+ * after the declared ones rather than dropping them).
194
+ */
195
+ export function buildCategoryRank(order) {
196
+ if (!Array.isArray(order) || order.length === 0)
197
+ return null;
198
+ const rank = new Map();
199
+ order.forEach((key, i) => {
200
+ if (!rank.has(key))
201
+ rank.set(key, i);
202
+ });
203
+ return rank.size > 0 ? rank : null;
204
+ }
150
205
  /**
151
206
  * Build a `{ value → label }` map from a select/enum field's `options`, for
152
207
  * resolving a grouped dimension's stored value to its display label (fed to
@@ -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 { DashboardSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types';
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<DashboardSchema, 'globalFilters' | 'dateRange'>): DashboardFilterDef[];
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
@@ -69,6 +69,16 @@ export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] |
69
69
  measureField: (name: string) => DatasetResultField | undefined;
70
70
  headerLabel: (name: string) => string;
71
71
  };
72
+ /**
73
+ * A half-open date-range drill scope for one time-bucketed dimension (#1752):
74
+ * the object FIELD to filter and its inclusive `gte` / exclusive `lt` bounds
75
+ * (the server's `drillRanges` sidecar entry).
76
+ */
77
+ export interface DatasetDrillRange {
78
+ field: string;
79
+ gte: unknown;
80
+ lt: unknown;
81
+ }
72
82
  /**
73
83
  * Build the record-list filter for a drilled dataset bucket (ADR-0021 D2).
74
84
  *
@@ -79,7 +89,14 @@ export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] |
79
89
  * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
80
90
  * list stays within the same slice the aggregate was computed over.
81
91
  *
92
+ * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
93
+ * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
94
+ * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
95
+ * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
96
+ * scopes the list to the clicked time bucket instead of every bucket (which the
97
+ * old date-dim skip degraded to — a superset).
98
+ *
82
99
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
83
100
  * filters identically (and correctly, including lookups) on both surfaces.
84
101
  */
85
- export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>): Record<string, unknown>;
102
+ export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>, rawRanges?: Record<string, DatasetDrillRange>): Record<string, unknown>;
@@ -104,14 +104,27 @@ export function buildDatasetFieldHelpers(fields, object, fieldLabel) {
104
104
  * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
105
105
  * list stays within the same slice the aggregate was computed over.
106
106
  *
107
+ * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
108
+ * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
109
+ * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
110
+ * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
111
+ * scopes the list to the clicked time bucket instead of every bucket (which the
112
+ * old date-dim skip degraded to — a superset).
113
+ *
107
114
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
108
115
  * filters identically (and correctly, including lookups) on both surfaces.
109
116
  */
110
- export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runtimeFilter) {
117
+ export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runtimeFilter, rawRanges) {
111
118
  const drillFilter = {};
112
119
  for (const d of drillDims) {
113
120
  const raw = rawRow?.[d];
114
121
  drillFilter[dimensionFields[d]] = raw === '' || raw === undefined ? null : raw;
115
122
  }
123
+ if (rawRanges) {
124
+ for (const r of Object.values(rawRanges)) {
125
+ if (r && r.field)
126
+ drillFilter[r.field] = { $gte: r.gte, $lt: r.lt };
127
+ }
128
+ }
116
129
  return runtimeFilter ? { ...runtimeFilter, ...drillFilter } : drillFilter;
117
130
  }
@@ -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
- if (typeof col === 'string') {
94
- columnFieldNames.add(col);
95
- }
96
- else if (col && typeof col === 'object') {
97
- const name = col.field ?? col.name ?? col.fieldName;
98
- if (name)
99
- columnFieldNames.add(name);
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
  }
@@ -8,7 +8,10 @@
8
8
  * translated object label (e.g. 合同) → object API name → 'export'.
9
9
  */
10
10
  /** Windows-reserved + control characters that cannot appear in a filename. */
11
- const ILLEGAL_FILENAME_CHARS = /[\\\/:*?"<>|\u0000-\u001f]+/g;
11
+ // The control-char range in the class is matched deliberately (illegal in
12
+ // filenames, must be stripped), so no-control-regex is intentionally disabled.
13
+ // eslint-disable-next-line no-control-regex
14
+ const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\u0000-\u001f]+/g;
12
15
  /** Longest base we emit — keeps the full name comfortably under OS limits. */
13
16
  const MAX_BASE_LENGTH = 80;
14
17
  /**
@@ -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;