@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.
- package/CHANGELOG.md +908 -0
- package/README.md +39 -13
- package/dist/SchemaRenderer.d.ts +78 -5
- package/dist/SchemaRenderer.js +139 -13
- package/dist/context/AppShellContext.d.ts +21 -2
- package/dist/context/RecordContext.d.ts +0 -4
- package/dist/context/RecordContext.js +0 -2
- package/dist/context/RelatedRecordActionsContext.d.ts +29 -0
- package/dist/context/RelatedRecordActionsContext.js +6 -0
- package/dist/element-data-source/ElementDataSourceGate.d.ts +199 -0
- package/dist/element-data-source/ElementDataSourceGate.js +195 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +10 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +69 -0
- package/dist/hooks/useActionTextLocalizer.js +91 -0
- package/dist/hooks/useDatasetDimensionLabels.d.ts +104 -0
- package/dist/hooks/useDatasetDimensionLabels.js +154 -0
- package/dist/hooks/useElementDataSource.d.ts +68 -0
- package/dist/hooks/useElementDataSource.js +138 -0
- package/dist/hooks/useExpression.d.ts +58 -0
- package/dist/hooks/useExpression.js +65 -0
- package/dist/hooks/useNavigationOverlay.d.ts +58 -10
- package/dist/hooks/useOffline.d.ts +62 -36
- package/dist/hooks/useOffline.js +2 -2
- package/dist/hooks/usePageAssignment.js +4 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/schema-input.d.ts +31 -0
- package/dist/schema-input.js +31 -0
- package/dist/spec-bridge/SpecBridge.d.ts +11 -5
- package/dist/spec-bridge/SpecBridge.js +7 -1
- package/dist/spec-bridge/bridges/list-view.js +106 -14
- package/dist/spec-bridge/types.d.ts +24 -3
- package/dist/utils/i18n.d.ts +43 -8
- package/dist/utils/i18n.js +42 -4
- 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
|
*
|