@object-ui/core 6.2.2 → 7.0.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 +205 -0
- package/dist/actions/ActionEngine.d.ts +42 -4
- package/dist/actions/ActionEngine.js +99 -3
- package/dist/actions/ActionRunner.d.ts +106 -0
- package/dist/actions/ActionRunner.js +213 -10
- package/dist/evaluator/ExpressionEvaluator.js +26 -4
- package/dist/evaluator/fieldRules.d.ts +49 -0
- package/dist/evaluator/fieldRules.js +86 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/registry/Registry.d.ts +11 -0
- package/dist/registry/Registry.js +36 -0
- package/dist/styling/scoped-styles.d.ts +41 -0
- package/dist/styling/scoped-styles.js +68 -0
- package/dist/utils/chart-series.d.ts +52 -0
- package/dist/utils/chart-series.js +72 -0
- package/dist/utils/dataset-format.d.ts +85 -0
- package/dist/utils/dataset-format.js +117 -0
- package/dist/validation/validators/object-validation-engine.js +20 -21
- package/package.json +5 -4
- package/dist/utils/normalize-quick-filter.d.ts +0 -29
- package/dist/utils/normalize-quick-filter.js +0 -66
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
* Scoped style-object → CSS compiler (framework ADR-0065).
|
|
9
|
+
*
|
|
10
|
+
* The SDUI styling primitive: a metadata node carries a `responsiveStyles`
|
|
11
|
+
* object (per-breakpoint CSS-property maps); at render time it compiles to
|
|
12
|
+
* **id-scoped CSS** injected as a `<style>` tag. This is build-independent
|
|
13
|
+
* (arbitrary values + design tokens pass through verbatim — no Tailwind JIT
|
|
14
|
+
* needed), collision-free (scoped to one node's class — two independently-built
|
|
15
|
+
* stylesheets cannot fight), and responsive-correct (model breakpoint maps →
|
|
16
|
+
* generated `@media`, never author-written `md:` variant classes).
|
|
17
|
+
*
|
|
18
|
+
* Mirrors Builder.io's SDK (`createCssClass` / `responsiveStyles`). Desktop-first:
|
|
19
|
+
* `large` is the unconditional base; `medium`/`small`/`xsmall` are `max-width`
|
|
20
|
+
* overrides.
|
|
21
|
+
*/
|
|
22
|
+
export type StyleMap = Record<string, string | number>;
|
|
23
|
+
export interface ResponsiveStyles {
|
|
24
|
+
/** Unconditional base (desktop-first). */
|
|
25
|
+
large?: StyleMap;
|
|
26
|
+
medium?: StyleMap;
|
|
27
|
+
small?: StyleMap;
|
|
28
|
+
xsmall?: StyleMap;
|
|
29
|
+
}
|
|
30
|
+
/** max-width breakpoints (px), mirroring Builder.io's default device sizes. */
|
|
31
|
+
export declare const STYLE_BREAKPOINTS: Record<'medium' | 'small' | 'xsmall', number>;
|
|
32
|
+
/** True when a node actually carries responsive styles worth compiling. */
|
|
33
|
+
export declare function hasResponsiveStyles(rs: unknown): rs is ResponsiveStyles;
|
|
34
|
+
/** Deterministic, CSS-safe scope class for a node id. */
|
|
35
|
+
export declare function scopeClassFor(id: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Compile a node's responsive styles to CSS scoped to `selector`.
|
|
38
|
+
* Returns '' when there is nothing to emit. No unscoped/global rule is ever
|
|
39
|
+
* produced — that scoping is what makes per-node styles collision-free.
|
|
40
|
+
*/
|
|
41
|
+
export declare function compileScopedStyles(selector: string, rs: ResponsiveStyles): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
* Scoped style-object → CSS compiler (framework ADR-0065).
|
|
9
|
+
*
|
|
10
|
+
* The SDUI styling primitive: a metadata node carries a `responsiveStyles`
|
|
11
|
+
* object (per-breakpoint CSS-property maps); at render time it compiles to
|
|
12
|
+
* **id-scoped CSS** injected as a `<style>` tag. This is build-independent
|
|
13
|
+
* (arbitrary values + design tokens pass through verbatim — no Tailwind JIT
|
|
14
|
+
* needed), collision-free (scoped to one node's class — two independently-built
|
|
15
|
+
* stylesheets cannot fight), and responsive-correct (model breakpoint maps →
|
|
16
|
+
* generated `@media`, never author-written `md:` variant classes).
|
|
17
|
+
*
|
|
18
|
+
* Mirrors Builder.io's SDK (`createCssClass` / `responsiveStyles`). Desktop-first:
|
|
19
|
+
* `large` is the unconditional base; `medium`/`small`/`xsmall` are `max-width`
|
|
20
|
+
* overrides.
|
|
21
|
+
*/
|
|
22
|
+
/** max-width breakpoints (px), mirroring Builder.io's default device sizes. */
|
|
23
|
+
export const STYLE_BREAKPOINTS = {
|
|
24
|
+
medium: 991,
|
|
25
|
+
small: 640,
|
|
26
|
+
xsmall: 479,
|
|
27
|
+
};
|
|
28
|
+
const camelToKebab = (k) => k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
29
|
+
/** Style-map → `prop: value;` declarations. Values pass through **verbatim** —
|
|
30
|
+
* that is the whole point: arbitrary values (`13px`, `#1a2b3c`) and design
|
|
31
|
+
* tokens (`var(--space-6)`) work with zero build step. */
|
|
32
|
+
const declarations = (m) => Object.entries(m)
|
|
33
|
+
.filter(([, v]) => v != null && v !== '')
|
|
34
|
+
.map(([k, v]) => `${camelToKebab(k)}: ${typeof v === 'number' ? String(v) : v};`)
|
|
35
|
+
.join(' ');
|
|
36
|
+
/** True when a node actually carries responsive styles worth compiling. */
|
|
37
|
+
export function hasResponsiveStyles(rs) {
|
|
38
|
+
if (!rs || typeof rs !== 'object')
|
|
39
|
+
return false;
|
|
40
|
+
const r = rs;
|
|
41
|
+
return Boolean(r.large || r.medium || r.small || r.xsmall);
|
|
42
|
+
}
|
|
43
|
+
/** Deterministic, CSS-safe scope class for a node id. */
|
|
44
|
+
export function scopeClassFor(id) {
|
|
45
|
+
return `os-s-${String(id).replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compile a node's responsive styles to CSS scoped to `selector`.
|
|
49
|
+
* Returns '' when there is nothing to emit. No unscoped/global rule is ever
|
|
50
|
+
* produced — that scoping is what makes per-node styles collision-free.
|
|
51
|
+
*/
|
|
52
|
+
export function compileScopedStyles(selector, rs) {
|
|
53
|
+
const rules = [];
|
|
54
|
+
if (rs.large) {
|
|
55
|
+
const base = declarations(rs.large);
|
|
56
|
+
if (base)
|
|
57
|
+
rules.push(`${selector} { ${base} }`);
|
|
58
|
+
}
|
|
59
|
+
for (const size of ['medium', 'small', 'xsmall']) {
|
|
60
|
+
const s = rs[size];
|
|
61
|
+
if (!s)
|
|
62
|
+
continue;
|
|
63
|
+
const d = declarations(s);
|
|
64
|
+
if (d)
|
|
65
|
+
rules.push(`@media (max-width: ${STYLE_BREAKPOINTS[size]}px) { ${selector} { ${d} } }`);
|
|
66
|
+
}
|
|
67
|
+
return rules.join('\n');
|
|
68
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
* chart-series — shape a semantic-layer `queryDataset` result into the
|
|
10
|
+
* `{ data, xAxisKey, series }` triple a chart renderer consumes (ADR-0021, #1759).
|
|
11
|
+
*
|
|
12
|
+
* Shared by `ObjectChart` (plugin-charts) and `DatasetWidget` (plugin-dashboard)
|
|
13
|
+
* so multi-dimension charts visualise identically across surfaces.
|
|
14
|
+
*
|
|
15
|
+
* Rules:
|
|
16
|
+
* - **2+ dimensions, single measure** → pivot the SECOND dimension into one
|
|
17
|
+
* series per distinct value (grouped/coloured bars). `xAxisKey` = first
|
|
18
|
+
* dimension; each output row is one first-dimension bucket with a column per
|
|
19
|
+
* second-dimension value holding the measure. This makes the second dimension
|
|
20
|
+
* visible instead of just repeating the x-axis label.
|
|
21
|
+
* - **otherwise** (single dimension, or multiple measures) → first dimension is
|
|
22
|
+
* the x-axis and each measure is its own series (long format passes through).
|
|
23
|
+
*/
|
|
24
|
+
export interface ChartResultField {
|
|
25
|
+
name: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
format?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ChartSeries {
|
|
30
|
+
dataKey: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ChartSeriesResult {
|
|
34
|
+
data: Array<Record<string, unknown>>;
|
|
35
|
+
xAxisKey: string | undefined;
|
|
36
|
+
series: ChartSeries[];
|
|
37
|
+
}
|
|
38
|
+
export declare function buildChartSeries(rows: Array<Record<string, unknown>> | null | undefined, dimensions: string[] | null | undefined, values: string[] | null | undefined, fields?: ChartResultField[] | null): ChartSeriesResult;
|
|
39
|
+
/**
|
|
40
|
+
* Inverse of {@link buildChartSeries}: map a clicked chart segment back to the
|
|
41
|
+
* index of its source dataset row, so a chart click can drill through to the
|
|
42
|
+
* same records a table/pivot row would.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors `buildChartSeries`' pivot rule:
|
|
45
|
+
* - **2+ dimensions, single measure** (second dim pivoted into series) → match
|
|
46
|
+
* BOTH the x-axis dimension (`category`) and the series dimension (`seriesKey`).
|
|
47
|
+
* - **otherwise** → match the x-axis (first) dimension only.
|
|
48
|
+
*
|
|
49
|
+
* Comparison is string-wise on the rows' display values (which is what the chart
|
|
50
|
+
* surfaces as `category` / series key). Returns `-1` when nothing matches.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findChartSeriesRow(rows: Array<Record<string, unknown>> | null | undefined, dimensions: string[] | null | undefined, values: string[] | null | undefined, category: string | undefined, seriesKey?: string): number;
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
export function buildChartSeries(rows, dimensions, values, fields) {
|
|
9
|
+
const dims = (dimensions ?? []).filter(Boolean);
|
|
10
|
+
const vals = (values ?? []).filter(Boolean);
|
|
11
|
+
const safeRows = Array.isArray(rows) ? rows : [];
|
|
12
|
+
const labelOf = (name) => (fields ?? []).find((f) => f.name === name)?.label ?? name;
|
|
13
|
+
// Multi-dimension, single-measure → pivot the second dimension into series.
|
|
14
|
+
if (dims.length >= 2 && vals.length === 1) {
|
|
15
|
+
const xKey = dims[0];
|
|
16
|
+
const groupKey = dims[1];
|
|
17
|
+
const measure = vals[0];
|
|
18
|
+
const seriesKeys = [];
|
|
19
|
+
const byX = new Map();
|
|
20
|
+
for (const row of safeRows) {
|
|
21
|
+
const xRaw = row[xKey];
|
|
22
|
+
const xId = String(xRaw ?? '');
|
|
23
|
+
if (!byX.has(xId))
|
|
24
|
+
byX.set(xId, { [xKey]: xRaw });
|
|
25
|
+
const gId = String(row[groupKey] ?? '');
|
|
26
|
+
if (gId !== '' && !seriesKeys.includes(gId))
|
|
27
|
+
seriesKeys.push(gId);
|
|
28
|
+
byX.get(xId)[gId] = row[measure];
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
data: Array.from(byX.values()),
|
|
32
|
+
xAxisKey: xKey,
|
|
33
|
+
// Series labels are the second-dimension values themselves (already
|
|
34
|
+
// server-resolved to display labels by queryDataset).
|
|
35
|
+
series: seriesKeys.map((k) => ({ dataKey: k, label: k })),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Default: first dimension on the x-axis, one series per measure.
|
|
39
|
+
return {
|
|
40
|
+
data: safeRows,
|
|
41
|
+
xAxisKey: dims[0],
|
|
42
|
+
series: vals.map((v) => ({ dataKey: v, label: labelOf(v) })),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Inverse of {@link buildChartSeries}: map a clicked chart segment back to the
|
|
47
|
+
* index of its source dataset row, so a chart click can drill through to the
|
|
48
|
+
* same records a table/pivot row would.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors `buildChartSeries`' pivot rule:
|
|
51
|
+
* - **2+ dimensions, single measure** (second dim pivoted into series) → match
|
|
52
|
+
* BOTH the x-axis dimension (`category`) and the series dimension (`seriesKey`).
|
|
53
|
+
* - **otherwise** → match the x-axis (first) dimension only.
|
|
54
|
+
*
|
|
55
|
+
* Comparison is string-wise on the rows' display values (which is what the chart
|
|
56
|
+
* surfaces as `category` / series key). Returns `-1` when nothing matches.
|
|
57
|
+
*/
|
|
58
|
+
export function findChartSeriesRow(rows, dimensions, values, category, seriesKey) {
|
|
59
|
+
const dims = (dimensions ?? []).filter(Boolean);
|
|
60
|
+
const vals = (values ?? []).filter(Boolean);
|
|
61
|
+
const safeRows = Array.isArray(rows) ? rows : [];
|
|
62
|
+
const xDim = dims[0];
|
|
63
|
+
if (!xDim)
|
|
64
|
+
return -1;
|
|
65
|
+
const c = String(category ?? '');
|
|
66
|
+
if (dims.length >= 2 && vals.length === 1) {
|
|
67
|
+
const gDim = dims[1];
|
|
68
|
+
const s = String(seriesKey ?? '');
|
|
69
|
+
return safeRows.findIndex((r) => String(r[xDim] ?? '') === c && String(r[gDim] ?? '') === s);
|
|
70
|
+
}
|
|
71
|
+
return safeRows.findIndex((r) => String(r[xDim] ?? '') === c);
|
|
72
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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-format — shared value formatting for semantic-layer `queryDataset`
|
|
10
|
+
* results (ADR-0021). Used by every dataset-bound surface (dashboard widgets,
|
|
11
|
+
* reports, the dataset preview) so a measure renders identically everywhere:
|
|
12
|
+
* a currency amount carries its declared currency symbol, a plain number stays
|
|
13
|
+
* a plain number, and a dimension label passes through untouched.
|
|
14
|
+
*
|
|
15
|
+
* Pure (no React / i18n) so it can live in `@object-ui/core` and be imported
|
|
16
|
+
* by both `@object-ui/plugin-dashboard` and `@object-ui/plugin-report`.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Column metadata the analytics server returns alongside the rows: a display
|
|
20
|
+
* `label` for both dimensions and measures, plus a measure's numeral `format`
|
|
21
|
+
* and declared `currency`. A superset of {@link ChartResultField} (which only
|
|
22
|
+
* needs name/label/format).
|
|
23
|
+
*/
|
|
24
|
+
export interface DatasetResultField {
|
|
25
|
+
name: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
format?: string;
|
|
29
|
+
currency?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Scale a stored `percent`-field value to its DISPLAY magnitude.
|
|
33
|
+
*
|
|
34
|
+
* Percent fields store a FRACTION (0–1) by convention — a stored `0.75` means
|
|
35
|
+
* 75% (see the percent edit widget `PercentField`, which divides input by 100).
|
|
36
|
+
* A value already in whole-percent form (magnitude ≥ 1, e.g. a `progress` /
|
|
37
|
+
* `completion` field storing `57`) is passed through unchanged. This is the
|
|
38
|
+
* SINGLE source of truth for percent display scaling, shared by the list-view
|
|
39
|
+
* percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
|
|
40
|
+
* measure formatter ({@link formatMeasure}) so a percent renders identically as
|
|
41
|
+
* a row value and as an aggregated metric — the two surfaces can never drift.
|
|
42
|
+
*/
|
|
43
|
+
export declare function percentDisplayValue(value: number): number;
|
|
44
|
+
/**
|
|
45
|
+
* Format a MEASURE value. Currency comes from the field's declared `currency`
|
|
46
|
+
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
|
|
47
|
+
* string — an amount with no declared currency must render as a plain number,
|
|
48
|
+
* never a misleading "$". The numeral `format` hint (e.g. "0,0", "0.0%")
|
|
49
|
+
* controls grouping / decimals / percent; it can't be baked into the row value
|
|
50
|
+
* server-side (the same number feeds charts), so it is applied here.
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatMeasure(v: unknown, format?: string, currency?: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Format a non-measure (dimension / label) value — the server already resolves
|
|
55
|
+
* dimension display labels, so this only tidies numbers and nulls.
|
|
56
|
+
*/
|
|
57
|
+
export declare function formatDimensionValue(v: unknown): string;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a column header / measure helper pair for one result set.
|
|
60
|
+
* `headerLabel` maps a dimension/measure NAME to its display label — the
|
|
61
|
+
* server-enriched field `label`, then (when `fieldLabel` is supplied) through
|
|
62
|
+
* the i18n field-label convention so a translated label wins, then the raw name
|
|
63
|
+
* as a last resort. `measureField` exposes a field's `format`/`currency`.
|
|
64
|
+
*
|
|
65
|
+
* `fieldLabel` is injected (rather than imported) so this stays React/i18n-free;
|
|
66
|
+
* callers pass `useSafeFieldLabel().fieldLabel`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] | undefined, object: string | undefined, fieldLabel?: (objectName: string, fieldName: string, fallback: string) => string): {
|
|
69
|
+
measureField: (name: string) => DatasetResultField | undefined;
|
|
70
|
+
headerLabel: (name: string) => string;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Build the record-list filter for a drilled dataset bucket (ADR-0021 D2).
|
|
74
|
+
*
|
|
75
|
+
* Each drillable dimension maps to its underlying object field, filtered by the
|
|
76
|
+
* dimension's RAW grouped value (from the server's parallel `drillRawRows`, NOT
|
|
77
|
+
* the visible row which carries the display LABEL — a select/lookup label would
|
|
78
|
+
* mis-filter). An empty/undefined raw value normalizes to `null` (an explicit
|
|
79
|
+
* "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
|
|
80
|
+
* list stays within the same slice the aggregate was computed over.
|
|
81
|
+
*
|
|
82
|
+
* Shared by the dashboard `DatasetWidget` and the report renderer so a drill
|
|
83
|
+
* filters identically (and correctly, including lookups) on both surfaces.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
* Scale a stored `percent`-field value to its DISPLAY magnitude.
|
|
10
|
+
*
|
|
11
|
+
* Percent fields store a FRACTION (0–1) by convention — a stored `0.75` means
|
|
12
|
+
* 75% (see the percent edit widget `PercentField`, which divides input by 100).
|
|
13
|
+
* A value already in whole-percent form (magnitude ≥ 1, e.g. a `progress` /
|
|
14
|
+
* `completion` field storing `57`) is passed through unchanged. This is the
|
|
15
|
+
* SINGLE source of truth for percent display scaling, shared by the list-view
|
|
16
|
+
* percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
|
|
17
|
+
* measure formatter ({@link formatMeasure}) so a percent renders identically as
|
|
18
|
+
* a row value and as an aggregated metric — the two surfaces can never drift.
|
|
19
|
+
*/
|
|
20
|
+
export function percentDisplayValue(value) {
|
|
21
|
+
return value > -1 && value < 1 ? value * 100 : value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Format a MEASURE value. Currency comes from the field's declared `currency`
|
|
25
|
+
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
|
|
26
|
+
* string — an amount with no declared currency must render as a plain number,
|
|
27
|
+
* never a misleading "$". The numeral `format` hint (e.g. "0,0", "0.0%")
|
|
28
|
+
* controls grouping / decimals / percent; it can't be baked into the row value
|
|
29
|
+
* server-side (the same number feeds charts), so it is applied here.
|
|
30
|
+
*/
|
|
31
|
+
export function formatMeasure(v, format, currency) {
|
|
32
|
+
if (v == null)
|
|
33
|
+
return '—';
|
|
34
|
+
if (typeof v !== 'number')
|
|
35
|
+
return String(v);
|
|
36
|
+
const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined;
|
|
37
|
+
if (currency) {
|
|
38
|
+
try {
|
|
39
|
+
return new Intl.NumberFormat(undefined, {
|
|
40
|
+
style: 'currency',
|
|
41
|
+
currency,
|
|
42
|
+
minimumFractionDigits: decimals ?? 0,
|
|
43
|
+
maximumFractionDigits: decimals ?? 2,
|
|
44
|
+
}).format(v);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Unknown currency code → fall through to plain number formatting.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!format) {
|
|
51
|
+
// No format hint → preserve the plain rendering (integers verbatim).
|
|
52
|
+
return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
53
|
+
}
|
|
54
|
+
const isPercent = format.includes('%');
|
|
55
|
+
// A legacy "$" literal in the format string is still honored (explicit author
|
|
56
|
+
// choice) — but it is NOT how a real currency field gets its symbol.
|
|
57
|
+
const legacyDollar = format.includes('$') ? '$' : '';
|
|
58
|
+
// numeral's "0.0%" multiplies by 100, and a percent field stores a FRACTION
|
|
59
|
+
// (0.75 ⇒ 75%). Scale to display magnitude the SAME way the list-view cell
|
|
60
|
+
// renderer does — otherwise an avg of 0.608 renders as "0.6%" instead of
|
|
61
|
+
// "60.8%", disagreeing with the per-row "75%" the list already shows.
|
|
62
|
+
const display = isPercent ? percentDisplayValue(v) : v;
|
|
63
|
+
const body = display.toLocaleString(undefined, { minimumFractionDigits: decimals ?? 0, maximumFractionDigits: decimals ?? 0 });
|
|
64
|
+
return `${legacyDollar}${body}${isPercent ? '%' : ''}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Format a non-measure (dimension / label) value — the server already resolves
|
|
68
|
+
* dimension display labels, so this only tidies numbers and nulls.
|
|
69
|
+
*/
|
|
70
|
+
export function formatDimensionValue(v) {
|
|
71
|
+
if (v == null)
|
|
72
|
+
return '—';
|
|
73
|
+
if (typeof v === 'number') {
|
|
74
|
+
return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
75
|
+
}
|
|
76
|
+
return String(v);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a column header / measure helper pair for one result set.
|
|
80
|
+
* `headerLabel` maps a dimension/measure NAME to its display label — the
|
|
81
|
+
* server-enriched field `label`, then (when `fieldLabel` is supplied) through
|
|
82
|
+
* the i18n field-label convention so a translated label wins, then the raw name
|
|
83
|
+
* as a last resort. `measureField` exposes a field's `format`/`currency`.
|
|
84
|
+
*
|
|
85
|
+
* `fieldLabel` is injected (rather than imported) so this stays React/i18n-free;
|
|
86
|
+
* callers pass `useSafeFieldLabel().fieldLabel`.
|
|
87
|
+
*/
|
|
88
|
+
export function buildDatasetFieldHelpers(fields, object, fieldLabel) {
|
|
89
|
+
const fieldByName = new Map((fields ?? []).map((f) => [f.name, f]));
|
|
90
|
+
const measureField = (name) => fieldByName.get(name);
|
|
91
|
+
const headerLabel = (name) => {
|
|
92
|
+
const fallback = measureField(name)?.label ?? name;
|
|
93
|
+
return object && fieldLabel ? fieldLabel(object, name, fallback) : fallback;
|
|
94
|
+
};
|
|
95
|
+
return { measureField, headerLabel };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the record-list filter for a drilled dataset bucket (ADR-0021 D2).
|
|
99
|
+
*
|
|
100
|
+
* Each drillable dimension maps to its underlying object field, filtered by the
|
|
101
|
+
* dimension's RAW grouped value (from the server's parallel `drillRawRows`, NOT
|
|
102
|
+
* the visible row which carries the display LABEL — a select/lookup label would
|
|
103
|
+
* mis-filter). An empty/undefined raw value normalizes to `null` (an explicit
|
|
104
|
+
* "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
|
|
105
|
+
* list stays within the same slice the aggregate was computed over.
|
|
106
|
+
*
|
|
107
|
+
* Shared by the dashboard `DatasetWidget` and the report renderer so a drill
|
|
108
|
+
* filters identically (and correctly, including lookups) on both surfaces.
|
|
109
|
+
*/
|
|
110
|
+
export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runtimeFilter) {
|
|
111
|
+
const drillFilter = {};
|
|
112
|
+
for (const d of drillDims) {
|
|
113
|
+
const raw = rawRow?.[d];
|
|
114
|
+
drillFilter[dimensionFields[d]] = raw === '' || raw === undefined ? null : raw;
|
|
115
|
+
}
|
|
116
|
+
return runtimeFilter ? { ...runtimeFilter, ...drillFilter } : drillFilter;
|
|
117
|
+
}
|
|
@@ -290,29 +290,28 @@ export class ObjectValidationEngine {
|
|
|
290
290
|
* Validate state machine transitions
|
|
291
291
|
*/
|
|
292
292
|
validateStateMachine(rule, context) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
293
|
+
// ADR-0020 flat FSM: a `field` plus a `{ from: [allowedTo] }` map.
|
|
294
|
+
// Semantics mirror the server-side rule-validator:
|
|
295
|
+
// - insert / no prior state → allow (initial assignment)
|
|
296
|
+
// - field unchanged (from === to) → allow (no transition)
|
|
297
|
+
// - `from` not declared in the map → lenient allow (unconstrained)
|
|
298
|
+
// - `to` ∉ transitions[from] → reject
|
|
299
|
+
const currentState = context.record[rule.field];
|
|
300
|
+
const previousState = context.oldRecord?.[rule.field];
|
|
301
|
+
// No prior state (insert) — any initial value is allowed.
|
|
302
|
+
if (previousState === undefined || previousState === null) {
|
|
297
303
|
return { valid: true };
|
|
298
304
|
}
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (transition.condition) {
|
|
310
|
-
const conditionMet = this.expressionEvaluator.evaluate(transition.condition, context.record);
|
|
311
|
-
if (!conditionMet) {
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
// Valid transition found
|
|
305
|
+
// The state field wasn't part of this change — nothing to police.
|
|
306
|
+
if (previousState === currentState) {
|
|
307
|
+
return { valid: true };
|
|
308
|
+
}
|
|
309
|
+
const allowed = rule.transitions[previousState];
|
|
310
|
+
// `from` state not declared — leave it unconstrained (lenient).
|
|
311
|
+
if (allowed === undefined) {
|
|
312
|
+
return { valid: true };
|
|
313
|
+
}
|
|
314
|
+
if (allowed.includes(currentState)) {
|
|
316
315
|
return { valid: true };
|
|
317
316
|
}
|
|
318
317
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,14 +25,15 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@objectstack/
|
|
28
|
+
"@objectstack/formula": "^10.0.0",
|
|
29
|
+
"@objectstack/spec": "^10.0.0",
|
|
29
30
|
"lodash": "^4.18.1",
|
|
30
31
|
"zod": "^4.4.3",
|
|
31
|
-
"@object-ui/types": "
|
|
32
|
+
"@object-ui/types": "7.0.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"typescript": "^6.0.3",
|
|
35
|
-
"vitest": "^4.1.
|
|
36
|
+
"vitest": "^4.1.9"
|
|
36
37
|
},
|
|
37
38
|
"keywords": [
|
|
38
39
|
"objectui",
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
* QuickFilter Normalization Utility
|
|
10
|
-
*
|
|
11
|
-
* Adapter layer that converts between two QuickFilter formats:
|
|
12
|
-
* - Spec format: { field, operator, value }
|
|
13
|
-
* - ObjectUI format: { id, label, filters[], icon?, defaultActive? }
|
|
14
|
-
*
|
|
15
|
-
* Both formats are accepted; spec-format items are auto-converted to ObjectUI format.
|
|
16
|
-
*/
|
|
17
|
-
import type { QuickFilterItem, ObjectUIQuickFilterItem } from '@object-ui/types';
|
|
18
|
-
/** Normalized ObjectUI QuickFilter shape (output of normalizeQuickFilter) */
|
|
19
|
-
export type NormalizedQuickFilter = ObjectUIQuickFilterItem;
|
|
20
|
-
/**
|
|
21
|
-
* Normalize a single QuickFilter item.
|
|
22
|
-
* - If it's already in ObjectUI format (has id + label + filters), return as-is.
|
|
23
|
-
* - If it's in Spec format (has field + operator), convert to ObjectUI format.
|
|
24
|
-
*/
|
|
25
|
-
export declare function normalizeQuickFilter(item: QuickFilterItem): NormalizedQuickFilter;
|
|
26
|
-
/**
|
|
27
|
-
* Normalize an array of QuickFilter items (mixed formats accepted).
|
|
28
|
-
*/
|
|
29
|
-
export declare function normalizeQuickFilters(items: QuickFilterItem[] | undefined): NormalizedQuickFilter[] | undefined;
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
* Map a human-readable / spec operator to the ObjectStack AST operator.
|
|
10
|
-
*/
|
|
11
|
-
function mapSpecOperator(op) {
|
|
12
|
-
switch (op) {
|
|
13
|
-
case 'equals':
|
|
14
|
-
case 'eq': return '=';
|
|
15
|
-
case 'notEquals':
|
|
16
|
-
case 'ne':
|
|
17
|
-
case 'neq': return '!=';
|
|
18
|
-
case 'contains': return 'contains';
|
|
19
|
-
case 'notContains': return 'notcontains';
|
|
20
|
-
case 'greaterThan':
|
|
21
|
-
case 'gt': return '>';
|
|
22
|
-
case 'greaterOrEqual':
|
|
23
|
-
case 'gte': return '>=';
|
|
24
|
-
case 'lessThan':
|
|
25
|
-
case 'lt': return '<';
|
|
26
|
-
case 'lessOrEqual':
|
|
27
|
-
case 'lte': return '<=';
|
|
28
|
-
case 'in': return 'in';
|
|
29
|
-
case 'notIn': return 'not in';
|
|
30
|
-
case 'before': return '<';
|
|
31
|
-
case 'after': return '>';
|
|
32
|
-
default: return op;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Normalize a single QuickFilter item.
|
|
37
|
-
* - If it's already in ObjectUI format (has id + label + filters), return as-is.
|
|
38
|
-
* - If it's in Spec format (has field + operator), convert to ObjectUI format.
|
|
39
|
-
*/
|
|
40
|
-
export function normalizeQuickFilter(item) {
|
|
41
|
-
// Already in ObjectUI format
|
|
42
|
-
if ('id' in item && 'filters' in item && item.label && Array.isArray(item.filters)) {
|
|
43
|
-
return item;
|
|
44
|
-
}
|
|
45
|
-
// Spec format: { field, operator, value }
|
|
46
|
-
if ('field' in item && 'operator' in item) {
|
|
47
|
-
const op = mapSpecOperator(item.operator);
|
|
48
|
-
return {
|
|
49
|
-
id: `${item.field}-${item.operator}-${String(item.value ?? '')}`,
|
|
50
|
-
label: item.label || `${item.field} ${item.operator} ${String(item.value ?? '')}`,
|
|
51
|
-
filters: [[item.field, op, item.value]],
|
|
52
|
-
icon: item.icon,
|
|
53
|
-
defaultActive: item.defaultActive,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
// Unknown format — return as-is
|
|
57
|
-
return item;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Normalize an array of QuickFilter items (mixed formats accepted).
|
|
61
|
-
*/
|
|
62
|
-
export function normalizeQuickFilters(items) {
|
|
63
|
-
if (!items || items.length === 0)
|
|
64
|
-
return undefined;
|
|
65
|
-
return items.map(normalizeQuickFilter);
|
|
66
|
-
}
|