@object-ui/react 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +908 -0
  2. package/README.md +39 -13
  3. package/dist/SchemaRenderer.d.ts +78 -5
  4. package/dist/SchemaRenderer.js +139 -13
  5. package/dist/context/AppShellContext.d.ts +21 -2
  6. package/dist/context/RecordContext.d.ts +0 -4
  7. package/dist/context/RecordContext.js +0 -2
  8. package/dist/context/RelatedRecordActionsContext.d.ts +29 -0
  9. package/dist/context/RelatedRecordActionsContext.js +6 -0
  10. package/dist/element-data-source/ElementDataSourceGate.d.ts +199 -0
  11. package/dist/element-data-source/ElementDataSourceGate.js +195 -0
  12. package/dist/hooks/index.d.ts +3 -0
  13. package/dist/hooks/index.js +10 -0
  14. package/dist/hooks/useActionTextLocalizer.d.ts +69 -0
  15. package/dist/hooks/useActionTextLocalizer.js +91 -0
  16. package/dist/hooks/useDatasetDimensionLabels.d.ts +104 -0
  17. package/dist/hooks/useDatasetDimensionLabels.js +154 -0
  18. package/dist/hooks/useElementDataSource.d.ts +68 -0
  19. package/dist/hooks/useElementDataSource.js +138 -0
  20. package/dist/hooks/useExpression.d.ts +58 -0
  21. package/dist/hooks/useExpression.js +65 -0
  22. package/dist/hooks/useNavigationOverlay.d.ts +58 -10
  23. package/dist/hooks/useOffline.d.ts +62 -36
  24. package/dist/hooks/useOffline.js +2 -2
  25. package/dist/hooks/usePageAssignment.js +4 -4
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +5 -1
  28. package/dist/schema-input.d.ts +31 -0
  29. package/dist/schema-input.js +31 -0
  30. package/dist/spec-bridge/SpecBridge.d.ts +11 -5
  31. package/dist/spec-bridge/SpecBridge.js +7 -1
  32. package/dist/spec-bridge/bridges/list-view.js +106 -14
  33. package/dist/spec-bridge/types.d.ts +24 -3
  34. package/dist/utils/i18n.d.ts +43 -8
  35. package/dist/utils/i18n.js +42 -4
  36. package/package.json +8 -8
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ObjectUI — useDatasetDimensionLabels
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
+ * The analytics label net's REACT half (objectui#4389) — the fetch-and-memo
9
+ * glue that carries a dataset query's dimension metadata to the pure
10
+ * `@object-ui/core` helpers that decide what a label IS.
11
+ *
12
+ * ## What this is, and what it is not
13
+ *
14
+ * Nothing here decides a label. The resolution rules are all core's, and were
15
+ * never duplicated: `loadDimensionFieldMeta` → `resolveDimensionFieldMeta`, and
16
+ * `deriveDimensionLabelMaps` → `buildDimensionLabelMap`, with the translator
17
+ * bound by `dimensionOptionTranslator` to `{ns}.fieldOptions.<object>.<field>.<value>`
18
+ * — the one convention list and form surfaces already translate select options
19
+ * through. What this hook owns is only the three things core structurally
20
+ * cannot: the host context read, the state, and the memo boundary between them.
21
+ *
22
+ * ## Why it lives HERE
23
+ *
24
+ * objectui#4389 filed the glue as duplicated between `plugin-dashboard`'s
25
+ * `DatasetWidget` and `plugin-report`'s `useDatasetDimensionLabels` (both
26
+ * created by PR #4388), and named `@object-ui/core` as its home. That home was
27
+ * retired by measurement, and the card's PM RULING #2 records the correction:
28
+ * `SchemaRendererContext` is defined in THIS package, which depends on
29
+ * `@object-ui/core`, so core importing it back is a cycle; core is also
30
+ * React-free by declaration, by content, and by AGENTS.md §3. This package is
31
+ * the one place that is downstream of core (the helpers are reachable),
32
+ * downstream of `@object-ui/i18n` (`useSafeFieldLabel` is reachable), the owner
33
+ * of `SchemaRendererContext` (`apiFetch` is a local read), and upstream of both
34
+ * consuming plugins. It already hosts this exact fetch-and-memo-off-`apiFetch`
35
+ * shape three times over — `useViewData`, `useElementDataSource`, `useDiscovery`.
36
+ *
37
+ * ## The two properties this hook exists to state ONCE
38
+ *
39
+ * Both are bug fixes that were previously written out on each surface, which is
40
+ * the drift objectui#4389 was filed to close:
41
+ *
42
+ * 1. **The read rides the host's AUTHENTICATED `apiFetch` (objectui#4121)**, so
43
+ * `apiFetch` is IN the effect's deps. A bearer-token session carries its
44
+ * credential in the `Authorization` header, not a cookie, so
45
+ * `credentials: 'include'` alone left this read unauthenticated in a hosted
46
+ * console — and because the net is best-effort, the symptom was not an error
47
+ * but option colours and dimension labels silently never applying. The
48
+ * context is consulted DIRECTLY rather than through a `useSchemaContext()`
49
+ * that throws, so a surface rendered outside a host keeps degrading to the
50
+ * global `fetch` instead of crashing the render.
51
+ * 2. **The fetched metadata stays LOCALE-FREE in state (objectui#4030 /
52
+ * PR #4324)**, so the locale is NOT in the effect's deps. The bundle is
53
+ * applied one memo down, in {@link useDatasetDimensionLabels}, which is what
54
+ * makes a language switch re-label in place instead of re-issuing the
55
+ * metadata read.
56
+ *
57
+ * Both are pinned in `useDatasetDimensionLabels.test.tsx`, which asserts them
58
+ * at this seam — the level the per-plugin pins cannot state, since neither
59
+ * plugin can observe that the OTHER one inherits the same wiring.
60
+ */
61
+ import { type DimensionFieldMeta, type DimensionRelabelTarget } from '@object-ui/core';
62
+ /** `{ dimension → { rowValue → displayLabel } }`, or null when nothing resolved. */
63
+ export type DimensionLabelMaps = Record<string, Record<string, string>> | null;
64
+ /** What the metadata read resolved, kept locale-free (objectui#4030). */
65
+ export interface DatasetDimensionMeta {
66
+ /** `{ fieldPath → { object, field, options } }` for the paths that resolved. */
67
+ metaByPath: Record<string, DimensionFieldMeta>;
68
+ /** The dimensions this surface relabels, paired with their field paths. */
69
+ relabel: DimensionRelabelTarget[];
70
+ }
71
+ /** Shared options for both hooks below. */
72
+ export interface DatasetDimensionLabelOptions {
73
+ /**
74
+ * Set false to issue no read at all. The dashboard's METRIC branch passes
75
+ * this: it renders ONE measure value plus that measure's header label, so a
76
+ * dimension's value never reaches its output in any spelling and resolving
77
+ * would be a metadata read nothing consumes (objectui#4263, pinned).
78
+ */
79
+ enabled?: boolean;
80
+ }
81
+ /**
82
+ * Resolve one dataset query's dimension field metadata — the LOCALE-FREE half.
83
+ *
84
+ * Use this directly only when the surface derives more from the metadata than
85
+ * label maps; the dashboard does, for its chart-only per-category colours and
86
+ * declared category order. Everything else wants {@link useDatasetDimensionLabels}.
87
+ *
88
+ * @param object the dataset's base object, as the query result reported it
89
+ * @param dimensionFields the result's `dimension → field path` map (a dotted
90
+ * path resolves against the relationship TARGET, ADR-0071 multi-hop included)
91
+ * @param dimensions the dimension names this surface renders
92
+ */
93
+ export declare function useDatasetDimensionMeta(object: string | undefined, dimensionFields: Record<string, string> | undefined, dimensions: readonly string[] | undefined, options?: DatasetDimensionLabelOptions): DatasetDimensionMeta | null;
94
+ /**
95
+ * Resolve the `{ dimension → { value → displayLabel } }` maps for one dataset
96
+ * query's dimensions, with the locale bundle applied.
97
+ *
98
+ * The full glue: {@link useDatasetDimensionMeta} for the locale-free read, then
99
+ * core's `deriveDimensionLabelMaps` in a render memo. Feed the result to
100
+ * `relabelDimensions`. A language switch re-runs only the memo.
101
+ *
102
+ * Parameters are as {@link useDatasetDimensionMeta}.
103
+ */
104
+ export declare function useDatasetDimensionLabels(object: string | undefined, dimensionFields: Record<string, string> | undefined, dimensions: readonly string[] | undefined, options?: DatasetDimensionLabelOptions): DimensionLabelMaps;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ObjectUI — useDatasetDimensionLabels
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
+ * The analytics label net's REACT half (objectui#4389) — the fetch-and-memo
9
+ * glue that carries a dataset query's dimension metadata to the pure
10
+ * `@object-ui/core` helpers that decide what a label IS.
11
+ *
12
+ * ## What this is, and what it is not
13
+ *
14
+ * Nothing here decides a label. The resolution rules are all core's, and were
15
+ * never duplicated: `loadDimensionFieldMeta` → `resolveDimensionFieldMeta`, and
16
+ * `deriveDimensionLabelMaps` → `buildDimensionLabelMap`, with the translator
17
+ * bound by `dimensionOptionTranslator` to `{ns}.fieldOptions.<object>.<field>.<value>`
18
+ * — the one convention list and form surfaces already translate select options
19
+ * through. What this hook owns is only the three things core structurally
20
+ * cannot: the host context read, the state, and the memo boundary between them.
21
+ *
22
+ * ## Why it lives HERE
23
+ *
24
+ * objectui#4389 filed the glue as duplicated between `plugin-dashboard`'s
25
+ * `DatasetWidget` and `plugin-report`'s `useDatasetDimensionLabels` (both
26
+ * created by PR #4388), and named `@object-ui/core` as its home. That home was
27
+ * retired by measurement, and the card's PM RULING #2 records the correction:
28
+ * `SchemaRendererContext` is defined in THIS package, which depends on
29
+ * `@object-ui/core`, so core importing it back is a cycle; core is also
30
+ * React-free by declaration, by content, and by AGENTS.md §3. This package is
31
+ * the one place that is downstream of core (the helpers are reachable),
32
+ * downstream of `@object-ui/i18n` (`useSafeFieldLabel` is reachable), the owner
33
+ * of `SchemaRendererContext` (`apiFetch` is a local read), and upstream of both
34
+ * consuming plugins. It already hosts this exact fetch-and-memo-off-`apiFetch`
35
+ * shape three times over — `useViewData`, `useElementDataSource`, `useDiscovery`.
36
+ *
37
+ * ## The two properties this hook exists to state ONCE
38
+ *
39
+ * Both are bug fixes that were previously written out on each surface, which is
40
+ * the drift objectui#4389 was filed to close:
41
+ *
42
+ * 1. **The read rides the host's AUTHENTICATED `apiFetch` (objectui#4121)**, so
43
+ * `apiFetch` is IN the effect's deps. A bearer-token session carries its
44
+ * credential in the `Authorization` header, not a cookie, so
45
+ * `credentials: 'include'` alone left this read unauthenticated in a hosted
46
+ * console — and because the net is best-effort, the symptom was not an error
47
+ * but option colours and dimension labels silently never applying. The
48
+ * context is consulted DIRECTLY rather than through a `useSchemaContext()`
49
+ * that throws, so a surface rendered outside a host keeps degrading to the
50
+ * global `fetch` instead of crashing the render.
51
+ * 2. **The fetched metadata stays LOCALE-FREE in state (objectui#4030 /
52
+ * PR #4324)**, so the locale is NOT in the effect's deps. The bundle is
53
+ * applied one memo down, in {@link useDatasetDimensionLabels}, which is what
54
+ * makes a language switch re-label in place instead of re-issuing the
55
+ * metadata read.
56
+ *
57
+ * Both are pinned in `useDatasetDimensionLabels.test.tsx`, which asserts them
58
+ * at this seam — the level the per-plugin pins cannot state, since neither
59
+ * plugin can observe that the OTHER one inherits the same wiring.
60
+ */
61
+ import { useContext, useEffect, useMemo, useState } from 'react';
62
+ import { deriveDimensionLabelMaps, loadDimensionFieldMeta, } from '@object-ui/core';
63
+ import { useSafeFieldLabel } from '@object-ui/i18n';
64
+ import { SchemaRendererContext } from '../context/SchemaRendererContext';
65
+ /**
66
+ * Resolve one dataset query's dimension field metadata — the LOCALE-FREE half.
67
+ *
68
+ * Use this directly only when the surface derives more from the metadata than
69
+ * label maps; the dashboard does, for its chart-only per-category colours and
70
+ * declared category order. Everything else wants {@link useDatasetDimensionLabels}.
71
+ *
72
+ * @param object the dataset's base object, as the query result reported it
73
+ * @param dimensionFields the result's `dimension → field path` map (a dotted
74
+ * path resolves against the relationship TARGET, ADR-0071 multi-hop included)
75
+ * @param dimensions the dimension names this surface renders
76
+ */
77
+ export function useDatasetDimensionMeta(object, dimensionFields, dimensions, options) {
78
+ // The host's AUTHENTICATED fetch (objectui#4121) — see property 1 in the
79
+ // file header for why it is read off the context directly.
80
+ const apiFetch = useContext(SchemaRendererContext)?.apiFetch;
81
+ const enabled = options?.enabled ?? true;
82
+ // LOCALE-FREE state (objectui#4030) — see property 2 in the file header.
83
+ const [meta, setMeta] = useState(null);
84
+ const dims = (dimensions ?? []).filter(Boolean);
85
+ // A string signature, for the same reason `useDatasetRows` uses one: the
86
+ // dimension list and field map reach a renderer as arrays/objects rebuilt on
87
+ // every render, so keying the effect on their identity would refetch forever.
88
+ // NOTE what is deliberately ABSENT from it: the locale. That absence is
89
+ // property 2, and it is pinned.
90
+ const signature = [
91
+ enabled ? '1' : '0',
92
+ object ?? '',
93
+ dims.join(','),
94
+ JSON.stringify(dimensionFields ?? null),
95
+ ].join('|');
96
+ useEffect(() => {
97
+ if (!enabled || !object || dims.length === 0) {
98
+ setMeta(null);
99
+ return;
100
+ }
101
+ const fieldOf = (dim) => (dimensionFields && dimensionFields[dim]) || dim;
102
+ let cancelled = false;
103
+ (async () => {
104
+ try {
105
+ const doFetch = apiFetch ?? fetch;
106
+ const loadObjectSchema = async (name) => {
107
+ const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
108
+ headers: { accept: 'application/json' },
109
+ credentials: 'include',
110
+ });
111
+ const doc = await r.json().catch(() => null);
112
+ return doc?.item ?? doc?.data ?? doc;
113
+ };
114
+ // ONE walk for every dimension, memoized per call inside core — sibling
115
+ // dimensions sharing a relationship prefix read that object once, and
116
+ // the base object is seeded so it is never re-read.
117
+ const metaByPath = await loadDimensionFieldMeta(loadObjectSchema, object, dims.map(fieldOf));
118
+ if (!cancelled) {
119
+ setMeta({ metaByPath, relabel: dims.map((dim) => ({ dim, path: fieldOf(dim) })) });
120
+ }
121
+ }
122
+ catch {
123
+ // Best-effort by construction: a failed read leaves the rows exactly as
124
+ // the server sent them, which is what these surfaces rendered before.
125
+ if (!cancelled)
126
+ setMeta(null);
127
+ }
128
+ })();
129
+ return () => {
130
+ cancelled = true;
131
+ };
132
+ // `apiFetch` joins the deps (objectui#4121); the locale does NOT
133
+ // (objectui#4030 / PR #4324). `apiFetch` comes from the provider's
134
+ // memoized context value, and this hook's own `setState` cannot re-render
135
+ // the provider, so its identity is stable across the effect's own updates.
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [signature, apiFetch]);
138
+ return meta;
139
+ }
140
+ /**
141
+ * Resolve the `{ dimension → { value → displayLabel } }` maps for one dataset
142
+ * query's dimensions, with the locale bundle applied.
143
+ *
144
+ * The full glue: {@link useDatasetDimensionMeta} for the locale-free read, then
145
+ * core's `deriveDimensionLabelMaps` in a render memo. Feed the result to
146
+ * `relabelDimensions`. A language switch re-runs only the memo.
147
+ *
148
+ * Parameters are as {@link useDatasetDimensionMeta}.
149
+ */
150
+ export function useDatasetDimensionLabels(object, dimensionFields, dimensions, options) {
151
+ const { fieldOptionLabel } = useSafeFieldLabel();
152
+ const meta = useDatasetDimensionMeta(object, dimensionFields, dimensions, options);
153
+ return useMemo(() => deriveDimensionLabelMaps(meta?.metaByPath, meta?.relabel, fieldOptionLabel), [meta, fieldOptionLabel]);
154
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ObjectUI — useElementDataSource
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 half of consuming `PageComponentSchema.dataSource` — the spec's
9
+ * `ElementDataSourceSchema` per-element data binding (objectstack#5576).
10
+ *
11
+ * `@object-ui/core` owns the pure half (telling the binding apart from a runtime
12
+ * adapter, matching a view name, composing the view with the binding's own keys).
13
+ * This hook owns the part that needs React and a data source: FETCHING the
14
+ * object's saved views so `view` can be resolved to real columns/filter/sort.
15
+ *
16
+ * It lives in `@object-ui/react` rather than in the one plugin that consumes it
17
+ * today because the binding is declared on EVERY page component: `list-view`
18
+ * reads it here, `element:record_picker` already reads `schema.dataSource` (and
19
+ * still ignores `view`), and the remaining object-bound blocks will need the same
20
+ * resolution. One resolver in the runtime layer, not one per plugin.
21
+ */
22
+ import { type ComposedElementDataSource, type ElementDataSourceConfig, type ElementSavedView } from '@object-ui/core';
23
+ /**
24
+ * Where a resolution attempt stands. Distinguishing `loading` from `missing`
25
+ * matters: a component that treated "not resolved yet" as "does not exist" would
26
+ * flash the configuration error on every mount.
27
+ */
28
+ export type ElementDataSourceStatus =
29
+ /** The node carries no `dataSource` binding — nothing to resolve. */
30
+ 'absent'
31
+ /** Binding present, no `view` named — composition is already final. */
32
+ | 'ready'
33
+ /** A `view` was named and the saved views are still being fetched. */
34
+ | 'loading'
35
+ /** The named `view` was found and composed in. */
36
+ | 'resolved'
37
+ /** The named `view` does not exist on the object (or could not be read). */
38
+ | 'missing';
39
+ export interface UseElementDataSourceResult {
40
+ status: ElementDataSourceStatus;
41
+ /** The binding as authored, or `undefined` when the node carries none. */
42
+ config?: ElementDataSourceConfig;
43
+ /**
44
+ * Binding composed with the resolved view. Present for `ready` and
45
+ * `resolved`; withheld for `loading` and `missing` so a caller cannot render a
46
+ * half-resolved query by accident.
47
+ */
48
+ composed?: ComposedElementDataSource;
49
+ /** The saved view that was applied, when one was. */
50
+ view?: ElementSavedView;
51
+ /** Author-facing explanation, set only for `missing`. */
52
+ error?: string;
53
+ }
54
+ /**
55
+ * Resolve a schema node's `dataSource` binding, fetching the named saved view.
56
+ *
57
+ * @param schema The component's schema node (its `dataSource` is read).
58
+ * @param dataSource Explicit adapter; falls back to `SchemaRendererContext`.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const binding = useElementDataSource(schema, adapter);
63
+ * if (binding.status === 'missing') return <ConfigError message={binding.error} />;
64
+ * if (binding.status === 'loading') return <Skeleton />;
65
+ * const objectName = binding.composed?.object ?? schema.objectName;
66
+ * ```
67
+ */
68
+ export declare function useElementDataSource(schema: unknown, dataSource?: unknown): UseElementDataSourceResult;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ObjectUI — useElementDataSource
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 half of consuming `PageComponentSchema.dataSource` — the spec's
9
+ * `ElementDataSourceSchema` per-element data binding (objectstack#5576).
10
+ *
11
+ * `@object-ui/core` owns the pure half (telling the binding apart from a runtime
12
+ * adapter, matching a view name, composing the view with the binding's own keys).
13
+ * This hook owns the part that needs React and a data source: FETCHING the
14
+ * object's saved views so `view` can be resolved to real columns/filter/sort.
15
+ *
16
+ * It lives in `@object-ui/react` rather than in the one plugin that consumes it
17
+ * today because the binding is declared on EVERY page component: `list-view`
18
+ * reads it here, `element:record_picker` already reads `schema.dataSource` (and
19
+ * still ignores `view`), and the remaining object-bound blocks will need the same
20
+ * resolution. One resolver in the runtime layer, not one per plugin.
21
+ */
22
+ import { useContext, useEffect, useMemo, useState } from 'react';
23
+ import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, isElementDataSourceConfig, resolveSavedView, } from '@object-ui/core';
24
+ import { SchemaRendererContext } from '../context/SchemaRendererContext';
25
+ const isRecord = (v) => !!v && typeof v === 'object';
26
+ /**
27
+ * Read every saved view for `object` from the two places they live, in the same
28
+ * precedence app-shell's `ObjectView` uses: the object definition's embedded
29
+ * `listViews` / `list_views` map first, then the metadata-overlay rows
30
+ * `listViews()` returns — so a user-saved view shadows a metadata one of the
31
+ * same id.
32
+ *
33
+ * `capable` is false when there is no data source, or it exposes neither read —
34
+ * "this object has no view called X" and "nobody here can answer that question"
35
+ * are different facts and must not be reported as the same one. Both are still
36
+ * reported; only the message differs.
37
+ *
38
+ * A REJECTION from either source is swallowed to an empty contribution rather
39
+ * than propagated: one unavailable source must not make an existing view in the
40
+ * OTHER source read as missing.
41
+ */
42
+ async function fetchSavedViews(dataSource, object) {
43
+ const canReadObjectDef = typeof dataSource?.getObjectSchema === 'function';
44
+ const canReadOverlay = typeof dataSource?.listViews === 'function';
45
+ if (!canReadObjectDef && !canReadOverlay)
46
+ return { capable: false, views: {} };
47
+ const embedded = canReadObjectDef
48
+ ? dataSource.getObjectSchema(object).then((def) => (isRecord(def) ? (def.listViews ?? def.list_views) : undefined), () => undefined)
49
+ : Promise.resolve(undefined);
50
+ const overlay = canReadOverlay
51
+ ? dataSource.listViews(object).then((rows) => rows, () => undefined)
52
+ : Promise.resolve(undefined);
53
+ const [embeddedViews, overlayViews] = await Promise.all([embedded, overlay]);
54
+ return { capable: true, views: collectSavedViews(embeddedViews, overlayViews) };
55
+ }
56
+ /**
57
+ * Resolve a schema node's `dataSource` binding, fetching the named saved view.
58
+ *
59
+ * @param schema The component's schema node (its `dataSource` is read).
60
+ * @param dataSource Explicit adapter; falls back to `SchemaRendererContext`.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * const binding = useElementDataSource(schema, adapter);
65
+ * if (binding.status === 'missing') return <ConfigError message={binding.error} />;
66
+ * if (binding.status === 'loading') return <Skeleton />;
67
+ * const objectName = binding.composed?.object ?? schema.objectName;
68
+ * ```
69
+ */
70
+ export function useElementDataSource(schema, dataSource) {
71
+ const context = useContext(SchemaRendererContext);
72
+ const adapter = (dataSource ?? context?.dataSource ?? null);
73
+ const config = useMemo(() => {
74
+ const raw = isRecord(schema) ? schema.dataSource : undefined;
75
+ return isElementDataSourceConfig(raw) ? raw : undefined;
76
+ }, [schema]);
77
+ const object = config?.object;
78
+ const viewName = config?.view;
79
+ /**
80
+ * Which (object, view) pair a stored resolution belongs to.
81
+ *
82
+ * The state carries its own key instead of being CLEARED when the binding
83
+ * changes, so the effect never calls `setState` synchronously in its body
84
+ * (`react-hooks/set-state-in-effect`, an error in this repo — and the cascading
85
+ * render it names is real: every mount would render twice). A resolution whose
86
+ * key no longer matches simply does not count, which reads as `loading` below
87
+ * — the same answer clearing would have produced, without the extra render.
88
+ */
89
+ const requestKey = object && viewName ? JSON.stringify([object, viewName]) : '';
90
+ // `view: null` = resolved to nothing (missing). `null` state = never resolved.
91
+ const [resolved, setResolved] = useState(null);
92
+ useEffect(() => {
93
+ if (!object || !viewName)
94
+ return;
95
+ let cancelled = false;
96
+ fetchSavedViews(adapter, object).then(({ capable, views }) => {
97
+ if (cancelled)
98
+ return;
99
+ if (!capable) {
100
+ setResolved({
101
+ key: requestKey,
102
+ view: null,
103
+ error: `dataSource.view "${viewName}" cannot be resolved: this data source ` +
104
+ `cannot list the saved views of object "${object}".`,
105
+ });
106
+ return;
107
+ }
108
+ const view = resolveSavedView(views, viewName, object);
109
+ setResolved(view
110
+ ? { key: requestKey, view }
111
+ : {
112
+ key: requestKey,
113
+ view: null,
114
+ error: elementDataSourceViewNotFoundMessage(object, viewName, Object.keys(views)),
115
+ });
116
+ });
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [adapter, object, viewName, requestKey]);
121
+ return useMemo(() => {
122
+ if (!config)
123
+ return { status: 'absent' };
124
+ if (!config.view) {
125
+ return { status: 'ready', config, composed: composeElementDataSource(config) };
126
+ }
127
+ if (resolved?.key !== requestKey)
128
+ return { status: 'loading', config };
129
+ if (resolved.view === null)
130
+ return { status: 'missing', config, error: resolved.error };
131
+ return {
132
+ status: 'resolved',
133
+ config,
134
+ view: resolved.view,
135
+ composed: composeElementDataSource(config, resolved.view),
136
+ };
137
+ }, [config, resolved, requestKey]);
138
+ }
@@ -56,6 +56,64 @@ export declare function usePredicateScope(): Record<string, any>;
56
56
  * dependencies").
57
57
  */
58
58
  export { toPredicateInput } from '@object-ui/core';
59
+ /**
60
+ * Build the predicate context for a **row record** — the bag to hand
61
+ * {@link useCondition} when the thing being gated is scoped to one record.
62
+ *
63
+ * The row is bound the THREE ways the platform's row surfaces bind it:
64
+ * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
65
+ * `data.status` (legacy). This is not three dialects; it is one rule, and it is
66
+ * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
67
+ * header, list rows, the row kebab and conditional formatting all evaluate
68
+ * through it), restated here for the `useCondition` tier so both tiers answer
69
+ * an author's `visible:` the same way.
70
+ *
71
+ * ## Why a helper and not "just spread the row" (objectui#4075)
72
+ *
73
+ * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
74
+ * so a caller that passes the row spread flat resolves the shorthand spelling
75
+ * and NOTHING else. The canonical spelling is the `record.` root — it is what
76
+ * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
77
+ * namespace), what `evalRowPredicate` binds, and what the server enforces
78
+ * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
79
+ * the evaluator throws `record is not defined`, and a fail-closed `visible`
80
+ * turns that throw into "hidden". A correctly-authored predicate then deletes
81
+ * its own button, indistinguishably from the gate having said no.
82
+ *
83
+ * That was live, not theoretical: every declared action on framework's
84
+ * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
85
+ * so the whole server-declared approval decision set was invisible wherever
86
+ * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
87
+ * and the four generic action renderers carried the same root-only binding
88
+ * until objectui#4075.
89
+ *
90
+ * `record` and `data` are written AFTER the spread deliberately: a row that
91
+ * happens to carry a field literally named `record` or `data` must not shadow
92
+ * the root every predicate is written against. Same precedence as
93
+ * `evalRowPredicate`.
94
+ *
95
+ * This is the BINDING rule only. It deliberately does not touch the evaluation
96
+ * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
97
+ * keep their pinned semantics (objectui#3492 / #3842 / #3850 / #3871), and each
98
+ * caller keeps its own error policy (fail-closed `visible` vs the fail-soft
99
+ * legs). Binding the row is a different question from what to do when the
100
+ * predicate faults.
101
+ *
102
+ * ## No row → bind NOTHING, do not bind an empty one
103
+ *
104
+ * `null` / `undefined` / a non-object returns an EMPTY bag, not
105
+ * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
106
+ * merges this context OVER the ambient predicate scope, so binding an empty
107
+ * record would blank out a `record` that a host had put in the scope itself —
108
+ * which is exactly how `action:group`'s dropdown leaf is driven in
109
+ * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
110
+ * host to supply the row. "This surface has no row of its own" must stay
111
+ * distinct from "this surface's row is empty"; only the latter is entitled to
112
+ * shadow the scope.
113
+ *
114
+ * @param record The row, or nothing.
115
+ */
116
+ export declare function usePredicateRecordContext(record: unknown): Record<string, any>;
59
117
  /**
60
118
  * Hook for evaluating expressions with dynamic context
61
119
  *
@@ -70,6 +70,71 @@ export function usePredicateScope() {
70
70
  * dependencies").
71
71
  */
72
72
  export { toPredicateInput } from '@object-ui/core';
73
+ /**
74
+ * Build the predicate context for a **row record** — the bag to hand
75
+ * {@link useCondition} when the thing being gated is scoped to one record.
76
+ *
77
+ * The row is bound the THREE ways the platform's row surfaces bind it:
78
+ * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
79
+ * `data.status` (legacy). This is not three dialects; it is one rule, and it is
80
+ * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
81
+ * header, list rows, the row kebab and conditional formatting all evaluate
82
+ * through it), restated here for the `useCondition` tier so both tiers answer
83
+ * an author's `visible:` the same way.
84
+ *
85
+ * ## Why a helper and not "just spread the row" (objectui#4075)
86
+ *
87
+ * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
88
+ * so a caller that passes the row spread flat resolves the shorthand spelling
89
+ * and NOTHING else. The canonical spelling is the `record.` root — it is what
90
+ * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
91
+ * namespace), what `evalRowPredicate` binds, and what the server enforces
92
+ * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
93
+ * the evaluator throws `record is not defined`, and a fail-closed `visible`
94
+ * turns that throw into "hidden". A correctly-authored predicate then deletes
95
+ * its own button, indistinguishably from the gate having said no.
96
+ *
97
+ * That was live, not theoretical: every declared action on framework's
98
+ * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
99
+ * so the whole server-declared approval decision set was invisible wherever
100
+ * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
101
+ * and the four generic action renderers carried the same root-only binding
102
+ * until objectui#4075.
103
+ *
104
+ * `record` and `data` are written AFTER the spread deliberately: a row that
105
+ * happens to carry a field literally named `record` or `data` must not shadow
106
+ * the root every predicate is written against. Same precedence as
107
+ * `evalRowPredicate`.
108
+ *
109
+ * This is the BINDING rule only. It deliberately does not touch the evaluation
110
+ * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
111
+ * keep their pinned semantics (objectui#3492 / #3842 / #3850 / #3871), and each
112
+ * caller keeps its own error policy (fail-closed `visible` vs the fail-soft
113
+ * legs). Binding the row is a different question from what to do when the
114
+ * predicate faults.
115
+ *
116
+ * ## No row → bind NOTHING, do not bind an empty one
117
+ *
118
+ * `null` / `undefined` / a non-object returns an EMPTY bag, not
119
+ * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
120
+ * merges this context OVER the ambient predicate scope, so binding an empty
121
+ * record would blank out a `record` that a host had put in the scope itself —
122
+ * which is exactly how `action:group`'s dropdown leaf is driven in
123
+ * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
124
+ * host to supply the row. "This surface has no row of its own" must stay
125
+ * distinct from "this surface's row is empty"; only the latter is entitled to
126
+ * shadow the scope.
127
+ *
128
+ * @param record The row, or nothing.
129
+ */
130
+ export function usePredicateRecordContext(record) {
131
+ return useMemo(() => {
132
+ if (record == null || typeof record !== 'object' || Array.isArray(record))
133
+ return {};
134
+ const row = record;
135
+ return { ...row, record: row, data: row };
136
+ }, [record]);
137
+ }
73
138
  /**
74
139
  * Hook for evaluating expressions with dynamic context
75
140
  *