@object-ui/core 17.1.0 → 17.3.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 +738 -0
- package/README.md +29 -0
- package/dist/actions/ActionEngine.d.ts +22 -7
- package/dist/actions/ActionEngine.js +40 -22
- package/dist/actions/ActionRunner.d.ts +133 -13
- package/dist/actions/ActionRunner.js +27 -4
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +29 -12
- package/dist/actions/actionKeys.js +61 -10
- package/dist/actions/actionResponse.d.ts +42 -0
- package/dist/actions/actionResponse.js +104 -0
- package/dist/actions/index.d.ts +3 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/serverActionHandler.d.ts +143 -0
- package/dist/actions/serverActionHandler.js +137 -0
- package/dist/evaluator/ExpressionEvaluator.js +4 -2
- package/dist/evaluator/fieldRules.d.ts +26 -2
- package/dist/evaluator/fieldRules.js +63 -8
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +13 -1
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionRules.js +5 -1
- package/dist/evaluator/predicateInput.d.ts +84 -0
- package/dist/evaluator/predicateInput.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +12 -0
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +8 -1
- 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/WidgetRegistry.d.ts +7 -7
- package/dist/registry/WidgetRegistry.js +1 -1
- package/dist/styling/scoped-styles.d.ts +14 -8
- package/dist/utils/chart-series.d.ts +14 -2
- package/dist/utils/dashboard-filters.d.ts +9 -0
- package/dist/utils/dashboard-filters.js +99 -4
- package/dist/utils/dataset-format.d.ts +18 -1
- package/dist/utils/dataset-format.js +10 -2
- package/dist/utils/dataset-pivot.d.ts +57 -0
- package/dist/utils/dataset-pivot.js +57 -0
- package/dist/utils/filter-converter.d.ts +39 -11
- package/dist/utils/filter-converter.js +87 -7
- package/dist/utils/filter-tokens.d.ts +13 -6
- package/dist/utils/filter-tokens.js +13 -6
- package/dist/utils/freeze-schema.d.ts +14 -6
- package/dist/utils/freeze-schema.js +14 -6
- package/dist/utils/managedBy.d.ts +78 -50
- package/dist/utils/managedBy.js +75 -52
- package/dist/utils/normalize-list-view.d.ts +12 -2
- package/dist/utils/predicate-fields.d.ts +96 -0
- package/dist/utils/predicate-fields.js +163 -0
- package/dist/utils/predicate-record.d.ts +32 -0
- package/dist/utils/predicate-record.js +120 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/validation/index.d.ts +1 -0
- package/dist/validation/index.js +1 -0
- 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/package.json +4 -4
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
|
@@ -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') {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Pure (no React / i18n) so it can live in `@object-ui/core` and be imported
|
|
16
16
|
* by both `@object-ui/plugin-dashboard` and `@object-ui/plugin-report`.
|
|
17
17
|
*/
|
|
18
|
+
import type { PercentScale } from '@objectstack/spec/data';
|
|
18
19
|
/**
|
|
19
20
|
* Column metadata the analytics server returns alongside the rows: a display
|
|
20
21
|
* `label` for both dimensions and measures, plus a measure's numeral `format`
|
|
@@ -27,7 +28,23 @@ export interface DatasetResultField {
|
|
|
27
28
|
label?: string;
|
|
28
29
|
format?: string;
|
|
29
30
|
currency?: string;
|
|
31
|
+
percentScale?: PercentScale;
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* How a percentage column's stored number relates to its displayed percentage —
|
|
35
|
+
* the server's answer to a question a `%` format string cannot express:
|
|
36
|
+
* `fraction` is a 0–1 ratio (`1` ⇒ "100%"), `whole` is already percentage
|
|
37
|
+
* points (`1` ⇒ "1%"). Resolved from metadata server-side (a `ratio` measure is
|
|
38
|
+
* a fraction by definition; a measure over a `percent` field inherits that
|
|
39
|
+
* field's scale) and carried on the result column, so display never has to
|
|
40
|
+
* infer it from the value's magnitude — the inference that printed a ratio of
|
|
41
|
+
* exactly 1 as "1.0%" (#3136).
|
|
42
|
+
*
|
|
43
|
+
* Spec-owned since 17.0.0-rc.2 (`@objectstack/spec/data` exports the identical
|
|
44
|
+
* union); re-exported here so existing `@object-ui/core` consumers keep their
|
|
45
|
+
* import path.
|
|
46
|
+
*/
|
|
47
|
+
export type { PercentScale } from '@objectstack/spec/data';
|
|
31
48
|
/**
|
|
32
49
|
* Scale a stored `percent`-field value to its DISPLAY magnitude.
|
|
33
50
|
*
|
|
@@ -49,7 +66,7 @@ export declare function percentDisplayValue(value: number): number;
|
|
|
49
66
|
* controls grouping / decimals / percent; it can't be baked into the row value
|
|
50
67
|
* server-side (the same number feeds charts), so it is applied here.
|
|
51
68
|
*/
|
|
52
|
-
export declare function formatMeasure(v: unknown, format?: string, currency?: string): string;
|
|
69
|
+
export declare function formatMeasure(v: unknown, format?: string, currency?: string, percentScale?: PercentScale): string;
|
|
53
70
|
/**
|
|
54
71
|
* Format a non-measure (dimension / label) value — the server already resolves
|
|
55
72
|
* 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
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
* dataset-pivot — the lookup-key encoders every cross-tab over a semantic-layer
|
|
10
|
+
* `queryDataset` result shares (ADR-0021).
|
|
11
|
+
*
|
|
12
|
+
* A pivot keys three lookups off dimension-value tuples: the DOWN bucket, the
|
|
13
|
+
* ACROSS bucket, and the cell where the two meet. Every one of them used to be
|
|
14
|
+
* spelled by concatenating values with a character the data was ASSUMED never
|
|
15
|
+
* to contain — an empty string, a plain space, U+0001 — and each assumption
|
|
16
|
+
* failed on ordinary data: `"x"` + `"yz"` and `"xy"` + `"z"` are one id under an
|
|
17
|
+
* empty join, and `"New"` × `"York Q1"` and `"New York"` × `"Q1"` are one cell
|
|
18
|
+
* key under a space join. The later row silently overwrote the earlier one, so
|
|
19
|
+
* the cell showed a different row's measure, the overwritten row's value was
|
|
20
|
+
* unreachable, and drill-through followed the same wrong index into the wrong
|
|
21
|
+
* records — all without an error (objectstack#5473, objectstack#5665).
|
|
22
|
+
*
|
|
23
|
+
* `JSON.stringify` carries the boundary in its own quoting rather than in a
|
|
24
|
+
* character the values must avoid, so these are unambiguous for ANY value a
|
|
25
|
+
* dimension can hold. The ids are opaque lookup keys, never displayed — visible
|
|
26
|
+
* text comes from the separately-formatted labels.
|
|
27
|
+
*
|
|
28
|
+
* Both encoders live here, in `@object-ui/core`, because the same pivot is
|
|
29
|
+
* rendered by two packages (`plugin-dashboard`'s `DatasetWidget` and
|
|
30
|
+
* `plugin-report`'s `DatasetReportRenderer`). Each having written its own is
|
|
31
|
+
* exactly why both carried the defect and why fixing one left the other broken.
|
|
32
|
+
* The same rule applies WITHIN a renderer: the cell index and the subtotal
|
|
33
|
+
* lookups key the same buckets, so they must all call these — a second
|
|
34
|
+
* hand-rolled encoding of the same id agrees with the first only for the
|
|
35
|
+
* dimension counts someone happened to test, which is what kept the original
|
|
36
|
+
* bug invisible.
|
|
37
|
+
*
|
|
38
|
+
* Pure (no React / i18n), like its `dataset-format` neighbour.
|
|
39
|
+
*
|
|
40
|
+
* Known residual, tracked separately in objectstack#5666: callers encode a
|
|
41
|
+
* null/undefined dimension value as a placeholder string, so it still collides
|
|
42
|
+
* with a value that literally equals that placeholder. That is a property of
|
|
43
|
+
* the placeholder, not of the encoding below.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Encode a pivot BUCKET id from its dimension values.
|
|
47
|
+
*
|
|
48
|
+
* Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
|
|
49
|
+
* of thing (a dimension-value tuple), and a cross-tab with multiple across
|
|
50
|
+
* dimensions collides on that axis just as readily as on the down axis.
|
|
51
|
+
*/
|
|
52
|
+
export declare const pivotBucketId: (dimensionValues: string[]) => string;
|
|
53
|
+
/**
|
|
54
|
+
* Encode the cell key for a (down bucket, across bucket) pair — the key of the
|
|
55
|
+
* map a renderer reads to place a measure and to resolve a drill-through.
|
|
56
|
+
*/
|
|
57
|
+
export declare const pivotCellKey: (rowId: string, colId: string) => string;
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
* dataset-pivot — the lookup-key encoders every cross-tab over a semantic-layer
|
|
10
|
+
* `queryDataset` result shares (ADR-0021).
|
|
11
|
+
*
|
|
12
|
+
* A pivot keys three lookups off dimension-value tuples: the DOWN bucket, the
|
|
13
|
+
* ACROSS bucket, and the cell where the two meet. Every one of them used to be
|
|
14
|
+
* spelled by concatenating values with a character the data was ASSUMED never
|
|
15
|
+
* to contain — an empty string, a plain space, U+0001 — and each assumption
|
|
16
|
+
* failed on ordinary data: `"x"` + `"yz"` and `"xy"` + `"z"` are one id under an
|
|
17
|
+
* empty join, and `"New"` × `"York Q1"` and `"New York"` × `"Q1"` are one cell
|
|
18
|
+
* key under a space join. The later row silently overwrote the earlier one, so
|
|
19
|
+
* the cell showed a different row's measure, the overwritten row's value was
|
|
20
|
+
* unreachable, and drill-through followed the same wrong index into the wrong
|
|
21
|
+
* records — all without an error (objectstack#5473, objectstack#5665).
|
|
22
|
+
*
|
|
23
|
+
* `JSON.stringify` carries the boundary in its own quoting rather than in a
|
|
24
|
+
* character the values must avoid, so these are unambiguous for ANY value a
|
|
25
|
+
* dimension can hold. The ids are opaque lookup keys, never displayed — visible
|
|
26
|
+
* text comes from the separately-formatted labels.
|
|
27
|
+
*
|
|
28
|
+
* Both encoders live here, in `@object-ui/core`, because the same pivot is
|
|
29
|
+
* rendered by two packages (`plugin-dashboard`'s `DatasetWidget` and
|
|
30
|
+
* `plugin-report`'s `DatasetReportRenderer`). Each having written its own is
|
|
31
|
+
* exactly why both carried the defect and why fixing one left the other broken.
|
|
32
|
+
* The same rule applies WITHIN a renderer: the cell index and the subtotal
|
|
33
|
+
* lookups key the same buckets, so they must all call these — a second
|
|
34
|
+
* hand-rolled encoding of the same id agrees with the first only for the
|
|
35
|
+
* dimension counts someone happened to test, which is what kept the original
|
|
36
|
+
* bug invisible.
|
|
37
|
+
*
|
|
38
|
+
* Pure (no React / i18n), like its `dataset-format` neighbour.
|
|
39
|
+
*
|
|
40
|
+
* Known residual, tracked separately in objectstack#5666: callers encode a
|
|
41
|
+
* null/undefined dimension value as a placeholder string, so it still collides
|
|
42
|
+
* with a value that literally equals that placeholder. That is a property of
|
|
43
|
+
* the placeholder, not of the encoding below.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Encode a pivot BUCKET id from its dimension values.
|
|
47
|
+
*
|
|
48
|
+
* Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
|
|
49
|
+
* of thing (a dimension-value tuple), and a cross-tab with multiple across
|
|
50
|
+
* dimensions collides on that axis just as readily as on the down axis.
|
|
51
|
+
*/
|
|
52
|
+
export const pivotBucketId = (dimensionValues) => JSON.stringify(dimensionValues);
|
|
53
|
+
/**
|
|
54
|
+
* Encode the cell key for a (down bucket, across bucket) pair — the key of the
|
|
55
|
+
* map a renderer reads to place a measure and to resolve a drill-through.
|
|
56
|
+
*/
|
|
57
|
+
export const pivotCellKey = (rowId, colId) => JSON.stringify([rowId, colId]);
|
|
@@ -5,12 +5,6 @@
|
|
|
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
|
-
/**
|
|
9
|
-
* Filter Converter Utilities
|
|
10
|
-
*
|
|
11
|
-
* Shared utilities for converting MongoDB-like filter operators
|
|
12
|
-
* to ObjectStack FilterNode AST format.
|
|
13
|
-
*/
|
|
14
8
|
/**
|
|
15
9
|
* FilterNode AST type definition
|
|
16
10
|
* Represents a filter condition or a logical combination of conditions
|
|
@@ -83,6 +77,36 @@ export declare function convertFiltersToAST(filter: Record<string, any>): Filter
|
|
|
83
77
|
* `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
|
|
84
78
|
* view returned every record. Silently: no error, just a wider answer.
|
|
85
79
|
*
|
|
80
|
+
* The FIRST never worked at all (objectui#3431). The array branch returned
|
|
81
|
+
* every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
|
|
82
|
+
* `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
|
|
83
|
+
* false for an array of objects, and the wire face answers `400
|
|
84
|
+
* INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
|
|
85
|
+
* `showcase_task.in_progress` view (`filter: [{field:'status',
|
|
86
|
+
* operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
|
|
87
|
+
* once lowered to `[['status','equals','in_progress']]`. Every saved view
|
|
88
|
+
* carrying a filter was a failed list. `mergeFilterNodes` below has warned
|
|
89
|
+
* about exactly this hazard since it was written — the warning was accurate,
|
|
90
|
+
* and the sink it warns for never implemented the lowering it describes.
|
|
91
|
+
*
|
|
92
|
+
* **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
|
|
93
|
+
* / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
|
|
94
|
+
* view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
|
|
95
|
+
* `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
|
|
96
|
+
* spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
|
|
97
|
+
* #0.1 inverted. This function is the last hop before the wire, where the value
|
|
98
|
+
* legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
|
|
99
|
+
* It is also the single sink both producers already share — `plugin-list`'s
|
|
100
|
+
* `buildEffectiveFilter` (which feeds the grid AND the export) and
|
|
101
|
+
* `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
|
|
102
|
+
* lowering, one place; the same reason the MongoDB-style shape is lowered here
|
|
103
|
+
* rather than at each of its callers.
|
|
104
|
+
*
|
|
105
|
+
* Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
|
|
106
|
+
* practice: `ObjectView` concatenates a saved view's rules with the
|
|
107
|
+
* `?filter[<field>]=<value>` URL triples into one array. Triples pass through
|
|
108
|
+
* untouched.
|
|
109
|
+
*
|
|
86
110
|
* Returns `undefined` for an absent or empty source, so callers can skip
|
|
87
111
|
* `$filter` rather than sending an empty array.
|
|
88
112
|
*/
|
|
@@ -92,11 +116,15 @@ export declare function toFilterNode(source: unknown): FilterNode | Record<strin
|
|
|
92
116
|
*
|
|
93
117
|
* Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
|
|
94
118
|
* equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
|
|
95
|
-
* where the AST expects nodes, and the server
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
119
|
+
* where the AST expects nodes, and the server does not accept that —
|
|
120
|
+
* `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
|
|
121
|
+
* (objectstack#4121). Spreading is only correct when the source happens to be
|
|
122
|
+
* an array of nodes, which is why it survived.
|
|
123
|
+
*
|
|
124
|
+
* Since objectui#3431 the rule objects never reach that position anyway:
|
|
125
|
+
* `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
|
|
126
|
+
* paragraph above is kept because it is why each source stays its own child —
|
|
127
|
+
* and because it correctly diagnosed the bug the sink itself was carrying.
|
|
100
128
|
*
|
|
101
129
|
* Sources that normalize to nothing are skipped; one surviving source is
|
|
102
130
|
* returned as-is rather than wrapped in a pointless `and`.
|
|
@@ -5,6 +5,13 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Filter Converter Utilities
|
|
10
|
+
*
|
|
11
|
+
* Shared utilities for converting MongoDB-like filter operators
|
|
12
|
+
* to ObjectStack FilterNode AST format.
|
|
13
|
+
*/
|
|
14
|
+
import { normalizeFilterOperator } from '@objectstack/spec/ui';
|
|
8
15
|
/**
|
|
9
16
|
* Map MongoDB-like operators to ObjectStack filter operators.
|
|
10
17
|
*
|
|
@@ -152,6 +159,37 @@ export function convertFiltersToAST(filter) {
|
|
|
152
159
|
// Multiple conditions: combine with 'and'
|
|
153
160
|
return ['and', ...conditions];
|
|
154
161
|
}
|
|
162
|
+
function isViewFilterRule(value) {
|
|
163
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
164
|
+
return false;
|
|
165
|
+
const field = value.field;
|
|
166
|
+
return typeof field === 'string' && field !== '';
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Lower ONE spec `ViewFilterRule` to an ObjectQL AST comparison node.
|
|
170
|
+
*
|
|
171
|
+
* The operator goes through the spec's OWN {@link normalizeFilterOperator} —
|
|
172
|
+
* the exact exit the WRITE side uses (`app-shell/views/viewFilterFold.ts`), so
|
|
173
|
+
* the two directions cannot drift into two dialects. No second canonical map is
|
|
174
|
+
* introduced, and none is needed: all 19 `VIEW_FILTER_OPERATORS` are already
|
|
175
|
+
* members of the wire's `VALID_AST_OPERATORS`, so the lowering is purely
|
|
176
|
+
* structural. An operator the spec does not know is passed through VERBATIM, so
|
|
177
|
+
* `isFilterAST` still refuses it and the server still answers `400
|
|
178
|
+
* INVALID_FILTER` — a misspelling must not be coerced into a valid one.
|
|
179
|
+
*
|
|
180
|
+
* `value` is emitted only when the rule carries one. A rule that omits it
|
|
181
|
+
* (`is_empty` / `is_null`, whose direction comes from the operator NAME) would
|
|
182
|
+
* otherwise gain an invented `null` the author never wrote — `JSON.stringify`
|
|
183
|
+
* turns a hole in an array into `null`, and `['x', 'equals', null]` is a real
|
|
184
|
+
* `{x: null}` predicate, i.e. a silently-wrong filter. Same rule the write side
|
|
185
|
+
* applies (`if (c.value !== undefined)`).
|
|
186
|
+
*/
|
|
187
|
+
function viewFilterRuleToNode(rule) {
|
|
188
|
+
const operator = normalizeFilterOperator(rule.operator);
|
|
189
|
+
return (rule.value === undefined
|
|
190
|
+
? [rule.field, operator]
|
|
191
|
+
: [rule.field, operator, rule.value]);
|
|
192
|
+
}
|
|
155
193
|
/**
|
|
156
194
|
* Normalize ONE filter source into a single filter node.
|
|
157
195
|
*
|
|
@@ -167,14 +205,52 @@ export function convertFiltersToAST(filter) {
|
|
|
167
205
|
* `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
|
|
168
206
|
* view returned every record. Silently: no error, just a wider answer.
|
|
169
207
|
*
|
|
208
|
+
* The FIRST never worked at all (objectui#3431). The array branch returned
|
|
209
|
+
* every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
|
|
210
|
+
* `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
|
|
211
|
+
* false for an array of objects, and the wire face answers `400
|
|
212
|
+
* INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
|
|
213
|
+
* `showcase_task.in_progress` view (`filter: [{field:'status',
|
|
214
|
+
* operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
|
|
215
|
+
* once lowered to `[['status','equals','in_progress']]`. Every saved view
|
|
216
|
+
* carrying a filter was a failed list. `mergeFilterNodes` below has warned
|
|
217
|
+
* about exactly this hazard since it was written — the warning was accurate,
|
|
218
|
+
* and the sink it warns for never implemented the lowering it describes.
|
|
219
|
+
*
|
|
220
|
+
* **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
|
|
221
|
+
* / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
|
|
222
|
+
* view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
|
|
223
|
+
* `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
|
|
224
|
+
* spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
|
|
225
|
+
* #0.1 inverted. This function is the last hop before the wire, where the value
|
|
226
|
+
* legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
|
|
227
|
+
* It is also the single sink both producers already share — `plugin-list`'s
|
|
228
|
+
* `buildEffectiveFilter` (which feeds the grid AND the export) and
|
|
229
|
+
* `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
|
|
230
|
+
* lowering, one place; the same reason the MongoDB-style shape is lowered here
|
|
231
|
+
* rather than at each of its callers.
|
|
232
|
+
*
|
|
233
|
+
* Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
|
|
234
|
+
* practice: `ObjectView` concatenates a saved view's rules with the
|
|
235
|
+
* `?filter[<field>]=<value>` URL triples into one array. Triples pass through
|
|
236
|
+
* untouched.
|
|
237
|
+
*
|
|
170
238
|
* Returns `undefined` for an absent or empty source, so callers can skip
|
|
171
239
|
* `$filter` rather than sending an empty array.
|
|
172
240
|
*/
|
|
173
241
|
export function toFilterNode(source) {
|
|
174
242
|
if (source === null || source === undefined)
|
|
175
243
|
return undefined;
|
|
176
|
-
if (Array.isArray(source))
|
|
177
|
-
|
|
244
|
+
if (Array.isArray(source)) {
|
|
245
|
+
if (source.length === 0)
|
|
246
|
+
return undefined;
|
|
247
|
+
// Spec `ViewFilterRule[]` (possibly mixed with AST nodes) → AST nodes. Left
|
|
248
|
+
// untouched when the array holds no rule objects, which is the common case:
|
|
249
|
+
// user-filter conditions and URL triples are already nodes.
|
|
250
|
+
return (source.some(isViewFilterRule)
|
|
251
|
+
? source.map((el) => (isViewFilterRule(el) ? viewFilterRuleToNode(el) : el))
|
|
252
|
+
: source);
|
|
253
|
+
}
|
|
178
254
|
if (typeof source !== 'object')
|
|
179
255
|
return undefined;
|
|
180
256
|
const obj = source;
|
|
@@ -188,11 +264,15 @@ export function toFilterNode(source) {
|
|
|
188
264
|
*
|
|
189
265
|
* Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
|
|
190
266
|
* equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
|
|
191
|
-
* where the AST expects nodes, and the server
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
267
|
+
* where the AST expects nodes, and the server does not accept that —
|
|
268
|
+
* `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
|
|
269
|
+
* (objectstack#4121). Spreading is only correct when the source happens to be
|
|
270
|
+
* an array of nodes, which is why it survived.
|
|
271
|
+
*
|
|
272
|
+
* Since objectui#3431 the rule objects never reach that position anyway:
|
|
273
|
+
* `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
|
|
274
|
+
* paragraph above is kept because it is why each source stays its own child —
|
|
275
|
+
* and because it correctly diagnosed the bug the sink itself was carrying.
|
|
196
276
|
*
|
|
197
277
|
* Sources that normalize to nothing are skipped; one surviving source is
|
|
198
278
|
* returned as-is rather than wrapped in a pointless `and`.
|
|
@@ -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 { CONTEXT_TOKENS } from '@objectstack/spec/data';
|
|
8
9
|
/**
|
|
9
10
|
* Session-scoped filter placeholders — the sibling vocabulary to
|
|
10
11
|
* `{date-macros}`.
|
|
@@ -42,13 +43,19 @@
|
|
|
42
43
|
* ## Contract source
|
|
43
44
|
*
|
|
44
45
|
* The canonical vocabulary is published as part of the platform contract at
|
|
45
|
-
* `@objectstack/spec` → `CONTEXT_TOKENS
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* `@objectstack/spec/data` → `CONTEXT_TOKENS`. This module used to carry a
|
|
47
|
+
* hand-written copy of that tuple under the spec's own name, with a comment
|
|
48
|
+
* saying the duplication was "temporary until the next coordinated release"
|
|
49
|
+
* — the installed spec (17.0.0-rc.0) has exported it for some time, so the
|
|
50
|
+
* copy was simply a fork wearing the spec's name (objectstack#4115).
|
|
51
|
+
*
|
|
52
|
+
* It is now a **re-export**, which is the only form that cannot drift: the
|
|
53
|
+
* copy was byte-identical, so every value comparison and every behavioural
|
|
54
|
+
* test passed while it sat here. Reference identity is the one check that
|
|
55
|
+
* distinguishes a re-export from a fork (objectui#3003).
|
|
49
56
|
*/
|
|
50
|
-
/** The complete set of session-scoped filter tokens. */
|
|
51
|
-
export
|
|
57
|
+
/** The complete set of session-scoped filter tokens (spec-owned, re-exported). */
|
|
58
|
+
export { CONTEXT_TOKENS };
|
|
52
59
|
export type ContextTokenName = (typeof CONTEXT_TOKENS)[number];
|
|
53
60
|
/** Session values a filter placeholder can resolve against. */
|
|
54
61
|
export interface FilterTokenScope {
|
|
@@ -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 { CONTEXT_TOKENS } from '@objectstack/spec/data';
|
|
8
9
|
import { resolveDateMacros } from './date-macros.js';
|
|
9
10
|
/**
|
|
10
11
|
* Session-scoped filter placeholders — the sibling vocabulary to
|
|
@@ -43,13 +44,19 @@ import { resolveDateMacros } from './date-macros.js';
|
|
|
43
44
|
* ## Contract source
|
|
44
45
|
*
|
|
45
46
|
* The canonical vocabulary is published as part of the platform contract at
|
|
46
|
-
* `@objectstack/spec` → `CONTEXT_TOKENS
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* `@objectstack/spec/data` → `CONTEXT_TOKENS`. This module used to carry a
|
|
48
|
+
* hand-written copy of that tuple under the spec's own name, with a comment
|
|
49
|
+
* saying the duplication was "temporary until the next coordinated release"
|
|
50
|
+
* — the installed spec (17.0.0-rc.0) has exported it for some time, so the
|
|
51
|
+
* copy was simply a fork wearing the spec's name (objectstack#4115).
|
|
52
|
+
*
|
|
53
|
+
* It is now a **re-export**, which is the only form that cannot drift: the
|
|
54
|
+
* copy was byte-identical, so every value comparison and every behavioural
|
|
55
|
+
* test passed while it sat here. Reference identity is the one check that
|
|
56
|
+
* distinguishes a re-export from a fork (objectui#3003).
|
|
50
57
|
*/
|
|
51
|
-
/** The complete set of session-scoped filter tokens. */
|
|
52
|
-
export
|
|
58
|
+
/** The complete set of session-scoped filter tokens (spec-owned, re-exported). */
|
|
59
|
+
export { CONTEXT_TOKENS };
|
|
53
60
|
/**
|
|
54
61
|
* Near-miss spellings → the token the author meant.
|
|
55
62
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* System View immutability layer.
|
|
10
10
|
*
|
|
11
11
|
* A "System View" is any UI schema authored in source code (imported `.ts`/`.json`,
|
|
12
|
-
* `as const` literals, or returned from a `
|
|
12
|
+
* `as const` literals, or returned from a `defineSystemView()` call). Such schemas are
|
|
13
13
|
* part of the product contract and MUST NOT be mutated at runtime. Mutation
|
|
14
14
|
* would cause behavior drift, break TypeScript inference, and bypass code review.
|
|
15
15
|
*
|
|
@@ -37,7 +37,7 @@ export type DeepReadonly<T> = T extends (...args: any[]) => any ? T : T extends
|
|
|
37
37
|
readonly [K in keyof T]: DeepReadonly<T[K]>;
|
|
38
38
|
} : T;
|
|
39
39
|
/**
|
|
40
|
-
* A schema that has been frozen by `
|
|
40
|
+
* A schema that has been frozen by `defineSystemView()`. The marker symbol is
|
|
41
41
|
* non-enumerable and therefore invisible to consumers, but its presence
|
|
42
42
|
* lets us discriminate at runtime.
|
|
43
43
|
*/
|
|
@@ -52,11 +52,19 @@ export declare function deepFreeze<T>(value: T, seen?: WeakSet<object>): DeepRea
|
|
|
52
52
|
/**
|
|
53
53
|
* Mark and freeze a code-loaded schema as a System View.
|
|
54
54
|
*
|
|
55
|
+
* Named `defineView` until objectstack#4115, which is a name `@objectstack/spec`
|
|
56
|
+
* owns for something else entirely: the spec's `defineView(config)` is the
|
|
57
|
+
* VIEW-DOCUMENT factory — it parses a `ViewSchema` (`{ list, form, listViews,
|
|
58
|
+
* formViews }`) and returns a validated `View`. This one takes an arbitrary
|
|
59
|
+
* object, stamps the System-View marker and deep-freezes it; it validates
|
|
60
|
+
* nothing. An app-shell comment already conflated the two, which is precisely
|
|
61
|
+
* the planted-premise failure the guard exists to stop.
|
|
62
|
+
*
|
|
55
63
|
* @example
|
|
56
64
|
* ```ts
|
|
57
|
-
* import {
|
|
65
|
+
* import { defineSystemView } from '@object-ui/core';
|
|
58
66
|
*
|
|
59
|
-
* export const userListView =
|
|
67
|
+
* export const userListView = defineSystemView({
|
|
60
68
|
* type: 'list',
|
|
61
69
|
* data: { object: 'User' },
|
|
62
70
|
* columns: [{ name: 'email' }],
|
|
@@ -69,9 +77,9 @@ export declare function deepFreeze<T>(value: T, seen?: WeakSet<object>): DeepRea
|
|
|
69
77
|
* To produce a mutable derivative (Tenant or User View), call
|
|
70
78
|
* `cloneAsOverride(userListView)`.
|
|
71
79
|
*/
|
|
72
|
-
export declare function
|
|
80
|
+
export declare function defineSystemView<T extends object>(schema: T): SystemView<T>;
|
|
73
81
|
/**
|
|
74
|
-
* Runtime check: was this object produced by `
|
|
82
|
+
* Runtime check: was this object produced by `defineSystemView()` (or loaded from a
|
|
75
83
|
* source that forwards the marker)?
|
|
76
84
|
*/
|
|
77
85
|
export declare function isSystemView(value: unknown): boolean;
|