@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.
@@ -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
- const currentState = context.record[rule.stateField];
294
- const previousState = context.oldRecord?.[rule.stateField];
295
- // If no previous state (insert), allow any state
296
- if (!previousState) {
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
- // Check if transition is allowed
300
- for (const transition of rule.transitions) {
301
- const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from];
302
- if (!fromStates.includes(previousState)) {
303
- continue;
304
- }
305
- if (transition.to !== currentState) {
306
- continue;
307
- }
308
- // Check condition if specified
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": "6.2.2",
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/spec": "^6.7.1",
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": "6.2.2"
32
+ "@object-ui/types": "7.0.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "typescript": "^6.0.3",
35
- "vitest": "^4.1.7"
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
- }