@object-ui/core 11.0.0 → 11.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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 11.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d23d6eb: Three-tier AI page authoring: `kind:'html'` and a trusted `kind:'react'` tier.
8
+
9
+ - **`@object-ui/react-runtime`** (new) — the trusted runtime-React tier for
10
+ `kind:'react'` pages (vendored react-runner: Sucrase transpile + scope-eval,
11
+ no sandbox). Renders real JSX/TSX (any HTML + JS + hooks/useState/map/onClick)
12
+ in the main React tree with an injected scope (React, the public data blocks,
13
+ page data) and a built-in error boundary.
14
+ - **`@object-ui/core`** — new runtime capability gate (`enableCapability` /
15
+ `disableCapability` / `isCapabilityEnabled`, `CAP_REACT_PAGES`). `react-pages`
16
+ defaults **ON** (the platform trusts reviewed, draft-gated authors); a
17
+ deployment turns it OFF server-side (the runtime injects the disable global
18
+ when `OS_DISABLE_REACT_PAGES` is set). Never controlled from authored metadata.
19
+ - **`@object-ui/components`** — PageRenderer now routes `kind:'react'`
20
+ (capability-gated, lazy-loads the runtime) and renders `kind:'html'` (the
21
+ former `kind:'jsx'`, still accepted as a deprecated alias). The `html` tier
22
+ now resolves the full safe native HTML tag set (h1–h6, p, a, ul/ol/li, img,
23
+ blockquote, pre, strong/em, …) so authored HTML lives up to its name.
24
+
25
+ ### Patch Changes
26
+
27
+ - @object-ui/types@11.3.0
28
+
29
+ ## 11.2.0
30
+
31
+ ### Minor Changes
32
+
33
+ - 9e7a986: ADR-0080: AI-authored UI pages. New `@object-ui/sdui-parser` compiles a constrained JSX/HTML+Tailwind source into the SchemaNode tree (parse, never execute) with whitelist sanitization, manifest validation, and `.d.ts` codegen for the JSX type surface. `PageRenderer` renders `kind:'jsx'` pages; `ComponentRegistry` gains `tier` + `getPublicConfigs()` (capability vs contract).
34
+ - 1311749: ADR-0080 M5: curated PUBLIC block contract (capability ≠ contract). Adds `PUBLIC_BLOCKS` — the single, reviewable list of ~36 object-aware + layout/content blocks that form the AI/contract surface (Salesforce-App-Builder-shaped). `getPublicConfigs()` now returns the curated set (plus any `tier:'public'` opt-in), keyed by bare tag and deduped across the registry's dual-key registrations. The full ~244 registered types remain a rendering capability.
35
+
36
+ ### Patch Changes
37
+
38
+ - @object-ui/types@11.2.0
39
+
40
+ ## 11.1.0
41
+
42
+ ### Patch Changes
43
+
44
+ - @object-ui/types@11.1.0
45
+
3
46
  ## 7.3.0
4
47
 
5
48
  ### Patch Changes
@@ -135,6 +135,12 @@ export interface ActionDef {
135
135
  execute?: string;
136
136
  /** Target URL or identifier (for type: 'url', 'modal', 'flow') */
137
137
  target?: string;
138
+ /** For type: 'url' — where to open `target`. `'new-tab'` forces a new
139
+ * browser tab/window, `'self'` forces same-page navigation. When omitted,
140
+ * external URLs open in a new tab and relative URLs navigate in place.
141
+ * This is the public, static "open in new tab" switch (ActionSchema.openIn),
142
+ * distinct from the async-handler `opensInNewTab` pre-open dance. */
143
+ openIn?: 'self' | 'new-tab';
138
144
  /** Modal schema to open (for type: 'modal') */
139
145
  modal?: any;
140
146
  /** Chained actions to execute after this one */
@@ -468,7 +468,12 @@ export class ActionRunner {
468
468
  window.location.href = url;
469
469
  return { success: true };
470
470
  }
471
- const newTab = action.params?.newTab ?? isExternal;
471
+ // `openIn` is the first-class, declarative switch (ActionSchema.openIn);
472
+ // it wins over the legacy `params.newTab` escape hatch and the
473
+ // external-URL heuristic.
474
+ const newTab = action.openIn === 'new-tab' ? true
475
+ : action.openIn === 'self' ? false
476
+ : (action.params?.newTab ?? isExternal);
472
477
  if (this.navigationHandler) {
473
478
  this.navigationHandler(url, { external: isExternal, newTab });
474
479
  return { success: true };
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export type { SchemaNode, ComponentRendererProps } from './types/index.js';
9
9
  export * from './registry/Registry.js';
10
+ export * from './registry/public-blocks.js';
10
11
  export * from './registry/PluginSystem.js';
11
12
  export * from './registry/PluginScopeImpl.js';
12
13
  export * from './registry/WidgetRegistry.js';
@@ -28,6 +29,7 @@ export * from './utils/merge-views-into-objects.js';
28
29
  export * from './utils/freeze-schema.js';
29
30
  export * from './protocols/index.js';
30
31
  export * from './styling/scoped-styles.js';
32
+ export * from './runtime/capabilities.js';
31
33
  /**
32
34
  * @deprecated Import `composeStacks` from `@objectstack/spec` instead.
33
35
  *
@@ -40,3 +42,4 @@ export * from './utils/date-macros.js';
40
42
  export * from './utils/compare-to.js';
41
43
  export * from './utils/chart-series.js';
42
44
  export * from './utils/dataset-format.js';
45
+ export * from './utils/record-title.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  export * from './registry/Registry.js';
9
+ export * from './registry/public-blocks.js';
9
10
  export * from './registry/PluginSystem.js';
10
11
  export * from './registry/PluginScopeImpl.js';
11
12
  export * from './registry/WidgetRegistry.js';
@@ -27,6 +28,7 @@ export * from './utils/merge-views-into-objects.js';
27
28
  export * from './utils/freeze-schema.js';
28
29
  export * from './protocols/index.js';
29
30
  export * from './styling/scoped-styles.js';
31
+ export * from './runtime/capabilities.js';
30
32
  /**
31
33
  * @deprecated Import `composeStacks` from `@objectstack/spec` instead.
32
34
  *
@@ -39,3 +41,4 @@ export * from './utils/date-macros.js';
39
41
  export * from './utils/compare-to.js';
40
42
  export * from './utils/chart-series.js';
41
43
  export * from './utils/dataset-format.js';
44
+ export * from './utils/record-title.js';
@@ -25,6 +25,13 @@ export type ComponentMeta = {
25
25
  label?: string;
26
26
  icon?: string;
27
27
  category?: string;
28
+ /**
29
+ * Public contract tier (ADR-0080). `'public'` = part of the curated,
30
+ * type-checked, AI-facing block set (gets a strengthened contract, the
31
+ * JSX type surface, the api-surface ratchet, and customer docs). Undefined
32
+ * or `'internal'` = rendering capability only, not part of the contract.
33
+ */
34
+ tier?: 'public' | 'internal';
28
35
  namespace?: string;
29
36
  /**
30
37
  * When true, prevents the component from being registered with a non-namespaced fallback.
@@ -182,6 +189,12 @@ export declare class Registry<T = any> {
182
189
  * @returns Array of all component configurations
183
190
  */
184
191
  getAllConfigs(): ComponentConfig<T>[];
192
+ /**
193
+ * Get the curated PUBLIC-tier component configs (ADR-0080) — those registered
194
+ * with `tier: 'public'`. This is the contract/AI-vocabulary surface, a subset
195
+ * of the full rendering capability returned by {@link getAllConfigs}.
196
+ */
197
+ getPublicConfigs(): ComponentConfig<T>[];
185
198
  /**
186
199
  * Get all components in a specific namespace.
187
200
  *
@@ -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 { PUBLIC_BLOCKS } from './public-blocks.js';
8
9
  export class Registry {
9
10
  constructor() {
10
11
  Object.defineProperty(this, "components", {
@@ -270,6 +271,35 @@ export class Registry {
270
271
  getAllConfigs() {
271
272
  return Array.from(this.components.values());
272
273
  }
274
+ /**
275
+ * Get the curated PUBLIC-tier component configs (ADR-0080) — those registered
276
+ * with `tier: 'public'`. This is the contract/AI-vocabulary surface, a subset
277
+ * of the full rendering capability returned by {@link getAllConfigs}.
278
+ */
279
+ getPublicConfigs() {
280
+ // Dedupe by the config's canonical (namespaced) `type` — a component is
281
+ // registered under both a bare and a namespaced key pointing at the same
282
+ // canonical type, and we want one contract entry per component.
283
+ const seenCanonical = new Set();
284
+ const out = [];
285
+ const add = (tag, cfg) => {
286
+ if (!cfg || seenCanonical.has(cfg.type))
287
+ return;
288
+ seenCanonical.add(cfg.type);
289
+ // The contract surface is keyed by the bare/curated tag authors write,
290
+ // not the namespaced canonical stored on the config.
291
+ out.push({ ...cfg, type: tag });
292
+ };
293
+ // Curated contract list first (stable, reviewable order) …
294
+ for (const tag of PUBLIC_BLOCKS)
295
+ add(tag, this.getConfig(tag));
296
+ // … plus any bare registration that opted in explicitly via `tier: 'public'`.
297
+ for (const [key, cfg] of this.components.entries()) {
298
+ if (cfg.tier === 'public' && !key.includes(':'))
299
+ add(key, cfg);
300
+ }
301
+ return out;
302
+ }
273
303
  /**
274
304
  * Get all components in a specific namespace.
275
305
  *
@@ -0,0 +1,27 @@
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
+ * ADR-0080 — the curated PUBLIC block contract (capability ≠ contract).
10
+ *
11
+ * The subset of registered components that form the platform's *contract* and
12
+ * AI-authoring vocabulary: type-checked, api-surface-ratcheted, documented, and
13
+ * offered to the JSX-source authoring surface. The full ~244 registered types
14
+ * remain a rendering *capability* (`getAllConfigs`); only these are the
15
+ * contract (`getPublicConfigs`).
16
+ *
17
+ * Shaped like Salesforce App Builder standard components — small, object-centric
18
+ * — plus a thin layout/content layer and one escape hatch. A component not yet
19
+ * registered is simply skipped (the list is aspirational-safe). Registrations
20
+ * may also opt in individually via `tier: 'public'`.
21
+ *
22
+ * This is a single, reviewable source of truth for the public surface — prefer
23
+ * editing this list over scattering `tier` flags across registration sites.
24
+ */
25
+ export declare const PUBLIC_BLOCKS: readonly string[];
26
+ /** Fast membership set built from {@link PUBLIC_BLOCKS}. */
27
+ export declare const PUBLIC_BLOCK_SET: ReadonlySet<string>;
@@ -0,0 +1,67 @@
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
+ * ADR-0080 — the curated PUBLIC block contract (capability ≠ contract).
10
+ *
11
+ * The subset of registered components that form the platform's *contract* and
12
+ * AI-authoring vocabulary: type-checked, api-surface-ratcheted, documented, and
13
+ * offered to the JSX-source authoring surface. The full ~244 registered types
14
+ * remain a rendering *capability* (`getAllConfigs`); only these are the
15
+ * contract (`getPublicConfigs`).
16
+ *
17
+ * Shaped like Salesforce App Builder standard components — small, object-centric
18
+ * — plus a thin layout/content layer and one escape hatch. A component not yet
19
+ * registered is simply skipped (the list is aspirational-safe). Registrations
20
+ * may also opt in individually via `tier: 'public'`.
21
+ *
22
+ * This is a single, reviewable source of truth for the public surface — prefer
23
+ * editing this list over scattering `tier` flags across registration sites.
24
+ */
25
+ export const PUBLIC_BLOCKS = [
26
+ // ── Tier A — object-aware blocks (the contract core) ──────────────────────
27
+ 'object-grid',
28
+ 'list-view',
29
+ 'object-form',
30
+ 'embeddable-form',
31
+ 'object-master-detail-form',
32
+ 'object-kanban',
33
+ 'object-calendar',
34
+ 'object-gantt',
35
+ 'object-timeline',
36
+ 'object-map',
37
+ 'object-metric',
38
+ 'object-chart',
39
+ 'dashboard',
40
+ 'object-pivot',
41
+ 'record:details',
42
+ 'record:highlights',
43
+ 'record:related_list',
44
+ 'record:path',
45
+ 'line_items',
46
+ // ── Tier B — layout / content primitives ──────────────────────────────────
47
+ 'flex',
48
+ 'grid',
49
+ 'stack',
50
+ 'card',
51
+ 'tabs',
52
+ 'accordion',
53
+ 'container',
54
+ 'page:header',
55
+ 'text',
56
+ 'image',
57
+ 'icon',
58
+ 'markdown',
59
+ 'element:divider',
60
+ 'badge',
61
+ 'alert',
62
+ 'button',
63
+ // ── Tier C — escape hatch (flagged, second-class) ─────────────────────────
64
+ 'html',
65
+ ];
66
+ /** Fast membership set built from {@link PUBLIC_BLOCKS}. */
67
+ export const PUBLIC_BLOCK_SET = new Set(PUBLIC_BLOCKS);
@@ -0,0 +1,35 @@
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
+ * Runtime capability gates — switches a *host* controls, never authored
9
+ * metadata. Each capability has a default; a host can flip it explicitly
10
+ * (`enableCapability`/`disableCapability`) or seed it from globals before any
11
+ * bundle code runs.
12
+ *
13
+ * {@link CAP_REACT_PAGES} (`kind:'react'` — execute author JavaScript in the
14
+ * main React tree) defaults **ON**: ObjectStack pages are authored by trusted
15
+ * authors and pass human review (draft-gating, ADR-0033), so the platform
16
+ * trusts them by default. A deployment that does NOT trust its page authors
17
+ * turns it off server-side — the ObjectStack runtime injects the disable global
18
+ * when `OS_PAGE_REACT=off` (see framework cli/utils/console.ts).
19
+ *
20
+ * Globals a host/server can set in the page before the bundle runs:
21
+ * - `globalThis.__OBJECTUI_CAPABILITIES__` — string[] to force ON
22
+ * - `globalThis.__OBJECTUI_CAPABILITIES_DISABLED__` — string[] to force OFF
23
+ */
24
+ /** Turn a capability on. Call from the host process only (not from metadata). */
25
+ export declare function enableCapability(name: string): void;
26
+ /** Turn a capability off. */
27
+ export declare function disableCapability(name: string): void;
28
+ /** Whether a capability is enabled. Host/global override wins over the default. */
29
+ export declare function isCapabilityEnabled(name: string): boolean;
30
+ /**
31
+ * `kind:'react'` — execute trusted author JavaScript (full React: hooks, event
32
+ * handlers, arbitrary JS) directly in the main React tree. Default ON; a
33
+ * deployment disables it server-side with `OS_PAGE_REACT=off`.
34
+ */
35
+ export declare const CAP_REACT_PAGES = "react-pages";
@@ -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
+ * Runtime capability gates — switches a *host* controls, never authored
9
+ * metadata. Each capability has a default; a host can flip it explicitly
10
+ * (`enableCapability`/`disableCapability`) or seed it from globals before any
11
+ * bundle code runs.
12
+ *
13
+ * {@link CAP_REACT_PAGES} (`kind:'react'` — execute author JavaScript in the
14
+ * main React tree) defaults **ON**: ObjectStack pages are authored by trusted
15
+ * authors and pass human review (draft-gating, ADR-0033), so the platform
16
+ * trusts them by default. A deployment that does NOT trust its page authors
17
+ * turns it off server-side — the ObjectStack runtime injects the disable global
18
+ * when `OS_PAGE_REACT=off` (see framework cli/utils/console.ts).
19
+ *
20
+ * Globals a host/server can set in the page before the bundle runs:
21
+ * - `globalThis.__OBJECTUI_CAPABILITIES__` — string[] to force ON
22
+ * - `globalThis.__OBJECTUI_CAPABILITIES_DISABLED__` — string[] to force OFF
23
+ */
24
+ /** Default state per capability when neither the host nor a global overrides it. */
25
+ const DEFAULTS = {
26
+ 'react-pages': true,
27
+ };
28
+ // Explicit host overrides (enable/disable) — win over defaults and globals.
29
+ const overrides = new Map();
30
+ let _seeded = false;
31
+ function seedFromGlobals() {
32
+ if (_seeded)
33
+ return;
34
+ _seeded = true;
35
+ const g = globalThis;
36
+ if (Array.isArray(g.__OBJECTUI_CAPABILITIES__)) {
37
+ for (const c of g.__OBJECTUI_CAPABILITIES__)
38
+ if (typeof c === 'string' && !overrides.has(c))
39
+ overrides.set(c, true);
40
+ }
41
+ if (Array.isArray(g.__OBJECTUI_CAPABILITIES_DISABLED__)) {
42
+ // Disable wins: a server that turned something off must not be overridden
43
+ // by a stale enable-list left in the same page.
44
+ for (const c of g.__OBJECTUI_CAPABILITIES_DISABLED__)
45
+ if (typeof c === 'string')
46
+ overrides.set(c, false);
47
+ }
48
+ }
49
+ /** Turn a capability on. Call from the host process only (not from metadata). */
50
+ export function enableCapability(name) {
51
+ seedFromGlobals();
52
+ overrides.set(name, true);
53
+ }
54
+ /** Turn a capability off. */
55
+ export function disableCapability(name) {
56
+ seedFromGlobals();
57
+ overrides.set(name, false);
58
+ }
59
+ /** Whether a capability is enabled. Host/global override wins over the default. */
60
+ export function isCapabilityEnabled(name) {
61
+ seedFromGlobals();
62
+ const o = overrides.get(name);
63
+ if (o !== undefined)
64
+ return o;
65
+ return DEFAULTS[name] ?? false;
66
+ }
67
+ /**
68
+ * `kind:'react'` — execute trusted author JavaScript (full React: hooks, event
69
+ * handlers, arbitrary JS) directly in the main React tree. Default ON; a
70
+ * deployment disables it server-side with `OS_PAGE_REACT=off`.
71
+ */
72
+ export const CAP_REACT_PAGES = 'react-pages';
@@ -36,6 +36,31 @@ export interface ChartSeriesResult {
36
36
  series: ChartSeries[];
37
37
  }
38
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
+ * Resolve select/enum dimension VALUES to display LABELS in chart rows.
41
+ *
42
+ * Analytics groups by a select field's stored `value` (e.g. `active`), but a
43
+ * chart axis should read the option `label` (e.g. `合作中`). The server SHOULD
44
+ * resolve this (ADR-0021), but when it can't — an AI-built select whose
45
+ * `options` the analytics layer never sees, so its `resolveDimensionLabels`
46
+ * silently no-ops — the rows arrive value-keyed. The axis then shows raw enum
47
+ * values, and (worse) option-keyed colour / category wiring built from the
48
+ * field `label`s no longer lines up with the value-keyed rows, so categories
49
+ * read empty. This is the chart-layer safety net the legacy aggregate path
50
+ * already gets from `resolveGroupByLabels`.
51
+ *
52
+ * Each row is rewritten by replacing `row[dim]` with `labelMaps[dim][value]`
53
+ * when a mapping exists. Measure columns are untouched, so the grouped count
54
+ * stays attached to its (now label-keyed) category — `value` is the matching
55
+ * key, `label` is only the display. Values with no mapping (already a label
56
+ * because the server resolved it, a lookup id, free text) pass through, so this
57
+ * is safe to run unconditionally and is idempotent.
58
+ *
59
+ * Returns a NEW array; a row that needs no change keeps its identity, and the
60
+ * input rows are never mutated, so the server's raw rows survive for
61
+ * index-aligned drill-through (`drillRawRows`).
62
+ */
63
+ export declare function relabelDimensions(rows: Array<Record<string, unknown>> | null | undefined, labelMaps: Record<string, Record<string, string>> | null | undefined): Array<Record<string, unknown>>;
39
64
  /**
40
65
  * Inverse of {@link buildChartSeries}: map a clicked chart segment back to the
41
66
  * index of its source dataset row, so a chart click can drill through to the
@@ -64,3 +89,14 @@ export declare function findChartSeriesRow(rows: Array<Record<string, unknown>>
64
89
  * paint identically across the chart view and dashboard widgets.
65
90
  */
66
91
  export declare function buildOptionColorMap(options: unknown): Record<string, string> | null;
92
+ /**
93
+ * Build a `{ value → label }` map from a select/enum field's `options`, for
94
+ * resolving a grouped dimension's stored value to its display label (fed to
95
+ * {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}.
96
+ *
97
+ * Options may be `{ value, label }` objects or bare strings (value == label —
98
+ * nothing to relabel). Only entries whose `label` actually differs from the
99
+ * `value` are kept, so the map is empty (→ `null`) when relabeling would be a
100
+ * no-op and the caller can skip it entirely.
101
+ */
102
+ export declare function buildDimensionLabelMap(options: unknown): Record<string, string> | null;
@@ -42,6 +42,53 @@ export function buildChartSeries(rows, dimensions, values, fields) {
42
42
  series: vals.map((v) => ({ dataKey: v, label: labelOf(v) })),
43
43
  };
44
44
  }
45
+ /**
46
+ * Resolve select/enum dimension VALUES to display LABELS in chart rows.
47
+ *
48
+ * Analytics groups by a select field's stored `value` (e.g. `active`), but a
49
+ * chart axis should read the option `label` (e.g. `合作中`). The server SHOULD
50
+ * resolve this (ADR-0021), but when it can't — an AI-built select whose
51
+ * `options` the analytics layer never sees, so its `resolveDimensionLabels`
52
+ * silently no-ops — the rows arrive value-keyed. The axis then shows raw enum
53
+ * values, and (worse) option-keyed colour / category wiring built from the
54
+ * field `label`s no longer lines up with the value-keyed rows, so categories
55
+ * read empty. This is the chart-layer safety net the legacy aggregate path
56
+ * already gets from `resolveGroupByLabels`.
57
+ *
58
+ * Each row is rewritten by replacing `row[dim]` with `labelMaps[dim][value]`
59
+ * when a mapping exists. Measure columns are untouched, so the grouped count
60
+ * stays attached to its (now label-keyed) category — `value` is the matching
61
+ * key, `label` is only the display. Values with no mapping (already a label
62
+ * because the server resolved it, a lookup id, free text) pass through, so this
63
+ * is safe to run unconditionally and is idempotent.
64
+ *
65
+ * Returns a NEW array; a row that needs no change keeps its identity, and the
66
+ * input rows are never mutated, so the server's raw rows survive for
67
+ * index-aligned drill-through (`drillRawRows`).
68
+ */
69
+ export function relabelDimensions(rows, labelMaps) {
70
+ const safeRows = Array.isArray(rows) ? rows : [];
71
+ if (!labelMaps)
72
+ return safeRows;
73
+ const dims = Object.keys(labelMaps).filter((d) => labelMaps[d] && Object.keys(labelMaps[d]).length > 0);
74
+ if (dims.length === 0)
75
+ return safeRows;
76
+ return safeRows.map((row) => {
77
+ let next = null;
78
+ for (const dim of dims) {
79
+ const raw = row[dim];
80
+ if (raw == null)
81
+ continue;
82
+ const label = labelMaps[dim][String(raw)];
83
+ if (label != null && label !== raw) {
84
+ if (!next)
85
+ next = { ...row };
86
+ next[dim] = label;
87
+ }
88
+ }
89
+ return next ?? row;
90
+ });
91
+ }
45
92
  /**
46
93
  * Inverse of {@link buildChartSeries}: map a clicked chart segment back to the
47
94
  * index of its source dataset row, so a chart click can drill through to the
@@ -100,3 +147,30 @@ export function buildOptionColorMap(options) {
100
147
  }
101
148
  return Object.keys(map).length > 0 ? map : null;
102
149
  }
150
+ /**
151
+ * Build a `{ value → label }` map from a select/enum field's `options`, for
152
+ * resolving a grouped dimension's stored value to its display label (fed to
153
+ * {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}.
154
+ *
155
+ * Options may be `{ value, label }` objects or bare strings (value == label —
156
+ * nothing to relabel). Only entries whose `label` actually differs from the
157
+ * `value` are kept, so the map is empty (→ `null`) when relabeling would be a
158
+ * no-op and the caller can skip it entirely.
159
+ */
160
+ export function buildDimensionLabelMap(options) {
161
+ if (!Array.isArray(options) || options.length === 0)
162
+ return null;
163
+ const map = {};
164
+ for (const opt of options) {
165
+ if (opt && typeof opt === 'object') {
166
+ const o = opt;
167
+ if (o.value != null && o.label != null) {
168
+ const v = String(o.value);
169
+ const l = String(o.label);
170
+ if (l !== v)
171
+ map[v] = l;
172
+ }
173
+ }
174
+ }
175
+ return Object.keys(map).length > 0 ? map : null;
176
+ }
@@ -5,32 +5,62 @@
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
+ * Relational ("reference-bearing") field types whose stored value is a foreign
10
+ * key into another object — and which therefore benefit from `$expand` so a
11
+ * list / grid / detail cell can render the related record's display name
12
+ * instead of a bare id placeholder ("—").
13
+ *
14
+ * This mirrors the form layer's `DATA_SOURCE_FIELD_TYPES`
15
+ * (`lookup` / `master_detail` / `tree`) and additionally covers `user` — a
16
+ * lookup specialised to `sys_user`. The server resolves `user` through the
17
+ * same expand path as `lookup` / `master_detail` (it carries the same
18
+ * `reference` + id storage), so a `user` column that is NOT requested for
19
+ * expansion comes back as a raw user id and renders as "—". Keeping the set
20
+ * here as the single source of truth is what closes that gap.
21
+ *
22
+ * Note on `tree`: a self-referencing hierarchy field is a reference too, so it
23
+ * belongs in this set; whether the backend materialises the expanded object
24
+ * for it is a server concern — requesting it is harmless and forward-compatible.
25
+ */
26
+ export declare const EXPANDABLE_FIELD_TYPES: ReadonlySet<string>;
27
+ /**
28
+ * Whether a field definition is a reference-bearing type that can be `$expand`-ed.
29
+ * Only the field's `type` matters here — the `reference` / `reference_to` target
30
+ * is irrelevant to the decision, so this works regardless of which canonical key
31
+ * the schema uses to name the related object.
32
+ */
33
+ export declare function isExpandableFieldType(fieldDef: unknown): boolean;
8
34
  /**
9
35
  * Build an array of field names that should be included in `$expand`
10
36
  * when fetching data. This scans the given object schema fields
11
- * (and optional column configuration) for `lookup` and `master_detail`
12
- * field types, so the backend (e.g. objectql) returns expanded objects
13
- * instead of raw foreign-key IDs.
37
+ * (and optional column configuration) for reference-bearing field types
38
+ * (see {@link EXPANDABLE_FIELD_TYPES}: `lookup` / `master_detail` / `tree` /
39
+ * `user`), so the backend (e.g. objectql) returns expanded objects instead of
40
+ * raw foreign-key IDs.
14
41
  *
15
42
  * @param schemaFields - Object map of field metadata from `getObjectSchema()`,
16
- * e.g. `{ account: { type: 'lookup', reference_to: 'accounts' }, ... }`.
43
+ * e.g. `{ account: { type: 'lookup', reference: 'accounts' }, ... }`.
17
44
  * @param columns - Optional explicit column list. When provided, only
18
- * lookup/master_detail fields that appear in `columns` are expanded.
19
- * Accepts `string[]` or `ListColumn[]` (objects with a `field` property).
20
- * @returns Array of field names to pass as `$expand`.
45
+ * reference fields that appear in `columns` are expanded — list/grid/kanban
46
+ * views pass their VISIBLE columns here so wide objects don't pay to expand
47
+ * relations no cell will show. Accepts `string[]` or `ListColumn[]` (objects
48
+ * with a `field` property).
49
+ * @returns Array of field names to pass as `$expand` (empty → omit `$expand`).
21
50
  *
22
51
  * @example
23
52
  * ```ts
24
53
  * const fields = {
25
54
  * name: { type: 'text' },
26
- * account: { type: 'lookup', reference_to: 'accounts' },
27
- * parent: { type: 'master_detail', reference_to: 'contacts' },
55
+ * account: { type: 'lookup', reference: 'accounts' },
56
+ * parent: { type: 'master_detail', reference: 'contacts' },
57
+ * assignee: { type: 'user', reference: 'sys_user' },
28
58
  * };
29
59
  * buildExpandFields(fields);
30
- * // → ['account', 'parent']
60
+ * // → ['account', 'parent', 'assignee']
31
61
  *
32
62
  * buildExpandFields(fields, ['name', 'account']);
33
- * // → ['account']
63
+ * // → ['account'] (only the visible reference columns)
34
64
  * ```
35
65
  */
36
66
  export declare function buildExpandFields(schemaFields?: Record<string, any> | null, columns?: (string | {
@@ -5,51 +5,88 @@
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
+ * Relational ("reference-bearing") field types whose stored value is a foreign
10
+ * key into another object — and which therefore benefit from `$expand` so a
11
+ * list / grid / detail cell can render the related record's display name
12
+ * instead of a bare id placeholder ("—").
13
+ *
14
+ * This mirrors the form layer's `DATA_SOURCE_FIELD_TYPES`
15
+ * (`lookup` / `master_detail` / `tree`) and additionally covers `user` — a
16
+ * lookup specialised to `sys_user`. The server resolves `user` through the
17
+ * same expand path as `lookup` / `master_detail` (it carries the same
18
+ * `reference` + id storage), so a `user` column that is NOT requested for
19
+ * expansion comes back as a raw user id and renders as "—". Keeping the set
20
+ * here as the single source of truth is what closes that gap.
21
+ *
22
+ * Note on `tree`: a self-referencing hierarchy field is a reference too, so it
23
+ * belongs in this set; whether the backend materialises the expanded object
24
+ * for it is a server concern — requesting it is harmless and forward-compatible.
25
+ */
26
+ export const EXPANDABLE_FIELD_TYPES = new Set([
27
+ 'lookup',
28
+ 'master_detail',
29
+ 'tree',
30
+ 'user',
31
+ ]);
32
+ /**
33
+ * Whether a field definition is a reference-bearing type that can be `$expand`-ed.
34
+ * Only the field's `type` matters here — the `reference` / `reference_to` target
35
+ * is irrelevant to the decision, so this works regardless of which canonical key
36
+ * the schema uses to name the related object.
37
+ */
38
+ export function isExpandableFieldType(fieldDef) {
39
+ return (!!fieldDef &&
40
+ typeof fieldDef === 'object' &&
41
+ EXPANDABLE_FIELD_TYPES.has(fieldDef.type));
42
+ }
8
43
  /**
9
44
  * Build an array of field names that should be included in `$expand`
10
45
  * when fetching data. This scans the given object schema fields
11
- * (and optional column configuration) for `lookup` and `master_detail`
12
- * field types, so the backend (e.g. objectql) returns expanded objects
13
- * instead of raw foreign-key IDs.
46
+ * (and optional column configuration) for reference-bearing field types
47
+ * (see {@link EXPANDABLE_FIELD_TYPES}: `lookup` / `master_detail` / `tree` /
48
+ * `user`), so the backend (e.g. objectql) returns expanded objects instead of
49
+ * raw foreign-key IDs.
14
50
  *
15
51
  * @param schemaFields - Object map of field metadata from `getObjectSchema()`,
16
- * e.g. `{ account: { type: 'lookup', reference_to: 'accounts' }, ... }`.
52
+ * e.g. `{ account: { type: 'lookup', reference: 'accounts' }, ... }`.
17
53
  * @param columns - Optional explicit column list. When provided, only
18
- * lookup/master_detail fields that appear in `columns` are expanded.
19
- * Accepts `string[]` or `ListColumn[]` (objects with a `field` property).
20
- * @returns Array of field names to pass as `$expand`.
54
+ * reference fields that appear in `columns` are expanded — list/grid/kanban
55
+ * views pass their VISIBLE columns here so wide objects don't pay to expand
56
+ * relations no cell will show. Accepts `string[]` or `ListColumn[]` (objects
57
+ * with a `field` property).
58
+ * @returns Array of field names to pass as `$expand` (empty → omit `$expand`).
21
59
  *
22
60
  * @example
23
61
  * ```ts
24
62
  * const fields = {
25
63
  * name: { type: 'text' },
26
- * account: { type: 'lookup', reference_to: 'accounts' },
27
- * parent: { type: 'master_detail', reference_to: 'contacts' },
64
+ * account: { type: 'lookup', reference: 'accounts' },
65
+ * parent: { type: 'master_detail', reference: 'contacts' },
66
+ * assignee: { type: 'user', reference: 'sys_user' },
28
67
  * };
29
68
  * buildExpandFields(fields);
30
- * // → ['account', 'parent']
69
+ * // → ['account', 'parent', 'assignee']
31
70
  *
32
71
  * buildExpandFields(fields, ['name', 'account']);
33
- * // → ['account']
72
+ * // → ['account'] (only the visible reference columns)
34
73
  * ```
35
74
  */
36
75
  export function buildExpandFields(schemaFields, columns) {
37
76
  if (!schemaFields || typeof schemaFields !== 'object') {
38
77
  return [];
39
78
  }
40
- // Collect all lookup / master_detail field names from the schema
41
- const lookupFieldNames = [];
79
+ // Collect every reference-bearing field name from the schema.
80
+ const referenceFieldNames = [];
42
81
  for (const [fieldName, fieldDef] of Object.entries(schemaFields)) {
43
- if (fieldDef &&
44
- typeof fieldDef === 'object' &&
45
- (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail')) {
46
- lookupFieldNames.push(fieldName);
82
+ if (isExpandableFieldType(fieldDef)) {
83
+ referenceFieldNames.push(fieldName);
47
84
  }
48
85
  }
49
- if (lookupFieldNames.length === 0) {
86
+ if (referenceFieldNames.length === 0) {
50
87
  return [];
51
88
  }
52
- // When columns are provided, restrict expansion to visible columns only
89
+ // When columns are provided, restrict expansion to visible columns only.
53
90
  if (columns && Array.isArray(columns) && columns.length > 0) {
54
91
  const columnFieldNames = new Set();
55
92
  for (const col of columns) {
@@ -62,7 +99,7 @@ export function buildExpandFields(schemaFields, columns) {
62
99
  columnFieldNames.add(name);
63
100
  }
64
101
  }
65
- return lookupFieldNames.filter((f) => columnFieldNames.has(f));
102
+ return referenceFieldNames.filter((f) => columnFieldNames.has(f));
66
103
  }
67
- return lookupFieldNames;
104
+ return referenceFieldNames;
68
105
  }
@@ -0,0 +1,98 @@
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
+ * Render a record title from a `titleFormat` pattern.
10
+ *
11
+ * Accepts either a legacy string template or an Expression envelope
12
+ * (`{ dialect: 'template', source: string }`) emitted by `@objectstack/spec`'s
13
+ * normalized templates. Both single-brace (`{field}`) and double-brace
14
+ * (`{{field}}`, the handlebars-style some authoring paths emit) placeholders are
15
+ * supported. Dotted paths (`{account.name}`) walk `$expanded` lookup objects; a
16
+ * placeholder that resolves to a whole object is reduced to its display name.
17
+ *
18
+ * Empty placeholders (missing / null / empty fields) are stripped along with
19
+ * any orphan separator they leave behind, so `"{full_name} - {company}"`
20
+ * evaluated against `{ company: "Acme" }` yields `"Acme"`, not `" - Acme"`.
21
+ * Returns an empty string when no placeholder resolved.
22
+ */
23
+ export declare function formatTitleTemplate(titleFormat: string | {
24
+ source?: string;
25
+ } | undefined | null, record: any): string;
26
+ /**
27
+ * Decide whether a field definition can serve as a record title. Excludes the
28
+ * {@link NON_TITLE_TYPES} (dates, numbers, pickers, lookups, binary, …); a
29
+ * `formula` is eligible only when it returns text. A field with no declared
30
+ * type is treated as eligible (text-by-default), so loosely-typed metadata
31
+ * still produces a name rather than falling through to `Record #…`.
32
+ */
33
+ export declare function isTitleEligibleField(def: any): boolean;
34
+ /**
35
+ * Type-aware derivation (precedence step 4): pick the best title-eligible field
36
+ * name from `objectDef.fields`, ranked:
37
+ *
38
+ * 1. name-ish exact — `name`,`title`,`subject`,`label`,`full_name`,
39
+ * `display_name` (case-insensitive)
40
+ * 2. name-ish affix — `*_name`, `*_title`, `name_*` (case-insensitive)
41
+ * 3. declaration order — first remaining title-eligible field
42
+ *
43
+ * Returns the field name, or undefined when `fields` is absent/empty or holds
44
+ * no title-eligible field.
45
+ *
46
+ * Memoized per `objectDef` identity (see {@link deriveTitleFieldCache}): the
47
+ * field scan runs once per object definition, not once per record. The result is
48
+ * byte-identical to the uncached computation — only the scan is cached, and only
49
+ * when `objectDef` is a non-null object (the WeakMap key requirement).
50
+ */
51
+ export declare function deriveTitleField(objectDef: any): string | undefined;
52
+ export interface RecordDisplayNameOptions {
53
+ /**
54
+ * Optional explicit title field (e.g. a view's `titleField` / gallery
55
+ * `titleField` / kanban `cardTitle`). When set and it yields a non-empty
56
+ * value on the record, it wins over everything — preserving each view's
57
+ * author-chosen field. Pass nothing to use the object-level precedence only.
58
+ */
59
+ titleField?: string;
60
+ /** Floor when the record has no id at all. Defaults to `'Untitled'`. */
61
+ fallback?: string;
62
+ /**
63
+ * Whether to probe standard name-ish keys (`name`/`title`/…) directly on the
64
+ * record when `objectDef.fields` is absent (precedence step 3b). Defaults to
65
+ * `true`. Callers that have a better generic fallback to interleave (e.g. the
66
+ * detail header prefers `schema.title`, the object label, over a guessed
67
+ * record key) pass `false` so this step is skipped and the resolver returns
68
+ * its `Record #<id>` floor instead — letting the caller decide.
69
+ */
70
+ deriveFromRecordKeys?: boolean;
71
+ }
72
+ /**
73
+ * THE unified record display-name resolver (ADR-0079).
74
+ *
75
+ * Precedence (ADR-0079 Phase 2 makes the object's declared field canonical and
76
+ * de-prioritizes the legacy `titleFormat` template):
77
+ * 0. `options.titleField`, then `objectDef.titleField` (when provided &
78
+ * non-empty on the record) — lets a view / object keep its author-chosen
79
+ * title field.
80
+ * 1. `objectDef.nameField` — the NEW canonical record-title pointer
81
+ * (ADR-0079). A field name the server can return and query.
82
+ * 2. `objectDef.displayNameField` / `objectDef.NAME_FIELD_KEY` — the
83
+ * deprecated alias of `nameField`, kept for back-compat.
84
+ * 3. `objectDef.titleFormat` (rendered template) — LEGACY, render-only, kept
85
+ * for back-compat only; an explicitly-declared field (1/2) now wins over
86
+ * it. See the deprecation note on the branch below.
87
+ * 4. type-aware derivation from `objectDef.fields` ({@link deriveTitleField}).
88
+ * 4b. standard name-ish keys probed directly on the record
89
+ * ({@link NAME_ISH_RECORD_KEYS}) — the safety net for when `fields` is
90
+ * absent so derivation can't run.
91
+ * 5. `Record #<id>` (using `record.id ?? record._id`), else `options.fallback`
92
+ * (default `'Untitled'`) for a truly id-less record.
93
+ *
94
+ * @param objectDef Object definition (may be `any`/partial — every field is
95
+ * read defensively).
96
+ * @param record The record data.
97
+ */
98
+ export declare function getRecordDisplayName(objectDef: any, record: any, options?: RecordDisplayNameOptions): string;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "11.0.0",
3
+ "version": "11.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@objectstack/formula": "^11.0.0",
29
- "@objectstack/spec": "^11.0.0",
28
+ "@objectstack/formula": "^11.2.0",
29
+ "@objectstack/spec": "^11.2.0",
30
30
  "lodash": "^4.18.1",
31
31
  "zod": "^4.4.3",
32
- "@object-ui/types": "11.0.0"
32
+ "@object-ui/types": "11.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^6.0.3",