@object-ui/react 17.7.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/README.md +89 -4
  3. package/dist/SchemaRenderer.d.ts +27 -1
  4. package/dist/SchemaRenderer.js +139 -50
  5. package/dist/context/AppShellContext.d.ts +27 -0
  6. package/dist/context/DiscussionContext.d.ts +15 -0
  7. package/dist/context/DiscussionContext.js +2 -0
  8. package/dist/context/InlineEditContext.d.ts +70 -0
  9. package/dist/context/InlineEditContext.js +30 -2
  10. package/dist/context/PrintPageContext.d.ts +60 -0
  11. package/dist/context/PrintPageContext.js +32 -0
  12. package/dist/context/RelatedRecordActionsContext.d.ts +67 -1
  13. package/dist/context/RelatedRecordActionsContext.js +4 -0
  14. package/dist/context/SchemaRendererContext.d.ts +7 -4
  15. package/dist/context/SchemaRendererContext.js +1 -1
  16. package/dist/context/index.d.ts +1 -0
  17. package/dist/context/index.js +1 -0
  18. package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
  19. package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
  20. package/dist/element-data-source/ElementDataSourceGate.d.ts +30 -2
  21. package/dist/element-data-source/ElementDataSourceGate.js +75 -6
  22. package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
  23. package/dist/element-data-source/useElementApiDataSource.js +198 -0
  24. package/dist/hooks/useActionTextLocalizer.d.ts +20 -7
  25. package/dist/hooks/useActionTextLocalizer.js +43 -12
  26. package/dist/hooks/useConfigBagEvaluator.d.ts +5 -4
  27. package/dist/hooks/useConfigBagEvaluator.js +5 -4
  28. package/dist/hooks/useDatasetDimensionLabels.js +25 -10
  29. package/dist/hooks/useDensityMode.d.ts +4 -2
  30. package/dist/hooks/useDensityMode.js +4 -2
  31. package/dist/hooks/usePageAssignment.d.ts +31 -8
  32. package/dist/hooks/usePageAssignment.js +68 -25
  33. package/dist/hooks/useViewData.d.ts +3 -1
  34. package/dist/hooks/useViewData.js +5 -14
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +3 -0
  37. package/dist/utils/localeMap.d.ts +27 -0
  38. package/dist/utils/localeMap.js +106 -0
  39. package/dist/utils/propsBagDiagnostic.js +3 -2
  40. package/dist/utils/unevaluatedExpression.d.ts +3 -1
  41. package/dist/utils/unevaluatedExpression.js +18 -0
  42. package/dist/utils/visibilityDiagnostic.d.ts +14 -5
  43. package/dist/utils/visibilityDiagnostic.js +10 -2
  44. package/package.json +6 -6
@@ -0,0 +1,198 @@
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
+ * The renderer half of the spec's API binding of a page element (objectui#12197):
9
+ * `PageComponentSchema.dataSource` as `ElementApiDataSourceSchema`,
10
+ *
11
+ * ```json
12
+ * { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }
13
+ * ```
14
+ *
15
+ * The spec states what a renderer does with it: the element reads ONE
16
+ * same-origin endpoint with the console's signed-in fetch and binds the selected
17
+ * value, "which the element reads as `data` in its expression holes and its
18
+ * `visibleWhen`". `SchemaRenderer` is the one place both of those are evaluated,
19
+ * so it calls {@link useElementApiDataSource} for every node and binds the
20
+ * answer as the `data` root (see "The API binding" there). This module owns the
21
+ * read itself.
22
+ *
23
+ * ## The binding is judged by the spec's own schema
24
+ *
25
+ * A node's `dataSource` reaches the renderer as stored metadata, unparsed. The
26
+ * hook parses it with `ElementApiDataSourceSchema` before it reads anything,
27
+ * so a binding the spec refuses — an absolute or protocol-relative `url`, a
28
+ * credential key, a `method` other than `GET`, a malformed `path` — is
29
+ * `refused` here with the spec's own message, and no request is sent. There is
30
+ * no second, hand-kept same-origin rule in this repo to drift from the
31
+ * contract's.
32
+ *
33
+ * A `dataSource` that is not an API binding (absent, or the object binding
34
+ * `useElementDataSource` reads) is `absent` here: the two shapes are told apart
35
+ * by `provider: 'api'`, the spec's discriminator.
36
+ *
37
+ * ## The read
38
+ *
39
+ * Through `SchemaRendererContext.apiFetch`, the host's authenticated fetch the
40
+ * console hands every renderer (`ConsoleShell`), falling back to the global
41
+ * fetch only when no host supplies one — the rule every `apiFetch` reader in
42
+ * the tree follows. `GET`, `Accept: application/json`, `credentials: 'include'`.
43
+ * A non-2xx answer, a body that is not JSON, or a `path` that selects nothing
44
+ * is `error`, with a sentence naming which; the value is never guessed.
45
+ *
46
+ * Concurrent elements that bind the same URL share ONE request: {@link
47
+ * readElementApi} keeps a pending request per URL and forgets it the moment it
48
+ * settles, so it caches no answer (the shape `readStorageUsage` uses).
49
+ *
50
+ * @module
51
+ */
52
+ import { useContext, useEffect, useMemo, useState } from 'react';
53
+ import { ElementApiDataSourceSchema } from '@objectstack/spec/ui';
54
+ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
55
+ import { isConfigBag } from '../utils/configBag.js';
56
+ /**
57
+ * Is this `dataSource` value the API binding — whatever it says beyond
58
+ * `provider`? `provider: 'api'` is the spec's discriminator between the two
59
+ * shapes of `PageComponentSchema.dataSource`; the object binding carries no
60
+ * `provider`.
61
+ */
62
+ export function isElementApiDataSourceShape(value) {
63
+ return isConfigBag(value) && value.provider === 'api';
64
+ }
65
+ /**
66
+ * Judge a node's `dataSource` as an API binding: `absent` when it is not one,
67
+ * `refused` with the spec's message when the spec refuses it, otherwise the
68
+ * parsed binding.
69
+ */
70
+ export function judgeElementApiDataSource(value) {
71
+ if (!isElementApiDataSourceShape(value))
72
+ return { status: 'absent' };
73
+ const parsed = ElementApiDataSourceSchema.safeParse(value);
74
+ if (!parsed.success) {
75
+ return {
76
+ status: 'refused',
77
+ error: parsed.error.issues.map((issue) => issue.message).join(' '),
78
+ };
79
+ }
80
+ return { status: 'parsed', config: parsed.data };
81
+ }
82
+ /**
83
+ * Select `path` (dotted segments; a numeric segment indexes an array) from a
84
+ * response body. `found: false` when any segment has nothing under it — an
85
+ * absent key, not a key whose value is `undefined`, is what "selects nothing"
86
+ * means.
87
+ */
88
+ export function selectElementApiPath(body, path) {
89
+ if (!path)
90
+ return { found: true, value: body };
91
+ let current = body;
92
+ for (const segment of path.split('.')) {
93
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
94
+ const index = Number(segment);
95
+ if (index >= current.length)
96
+ return { found: false, segment };
97
+ current = current[index];
98
+ continue;
99
+ }
100
+ if (!isConfigBag(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
101
+ return { found: false, segment };
102
+ }
103
+ current = current[segment];
104
+ }
105
+ return { found: true, value: current };
106
+ }
107
+ /** Pending reads, keyed by URL. An entry lives exactly as long as its request. */
108
+ const IN_FLIGHT = new Map();
109
+ /**
110
+ * `GET url` through `doFetch`, resolving with the parsed JSON body. Rejects on
111
+ * a network failure, a non-2xx answer or a body that is not JSON. Callers that
112
+ * ask while a read of the same URL is pending share it.
113
+ */
114
+ export function readElementApi(url, doFetch) {
115
+ const pending = IN_FLIGHT.get(url);
116
+ if (pending)
117
+ return pending;
118
+ const request = doFetch(url, {
119
+ method: 'GET',
120
+ headers: { Accept: 'application/json' },
121
+ credentials: 'include',
122
+ })
123
+ .then(async (res) => {
124
+ if (!res.ok)
125
+ throw new Error(`GET ${url} answered ${res.status}.`);
126
+ try {
127
+ return await res.json();
128
+ }
129
+ catch {
130
+ throw new Error(`GET ${url} did not answer JSON.`);
131
+ }
132
+ })
133
+ .finally(() => {
134
+ IN_FLIGHT.delete(url);
135
+ });
136
+ IN_FLIGHT.set(url, request);
137
+ return request;
138
+ }
139
+ /**
140
+ * Resolve a node's API binding: read its endpoint and select its `path`.
141
+ *
142
+ * Called unconditionally by `SchemaRenderer` for every node; a node without an
143
+ * API binding costs one `isPlainObject` test and issues no request.
144
+ */
145
+ export function useElementApiDataSource(schema) {
146
+ const context = useContext(SchemaRendererContext);
147
+ const doFetch = context?.apiFetch ?? fetch;
148
+ const raw = isConfigBag(schema) ? schema.dataSource : undefined;
149
+ // Keyed on the binding's CONTENT, never on its identity (AGENTS.md #10): a
150
+ // re-render that hands over an equal binding is the same read.
151
+ const bindingKey = isElementApiDataSourceShape(raw) ? JSON.stringify(raw) : '';
152
+ const judged = useMemo(() => judgeElementApiDataSource(bindingKey ? JSON.parse(bindingKey) : undefined), [bindingKey]);
153
+ const url = judged.status === 'parsed' ? judged.config.read.url : '';
154
+ const path = judged.status === 'parsed' ? judged.config.path : undefined;
155
+ const requestKey = url ? JSON.stringify([url, path ?? null]) : '';
156
+ // The settled answer carries its own key instead of being cleared when the
157
+ // binding changes, so the effect never sets state synchronously; an answer
158
+ // whose key no longer matches reads as `loading` (useElementDataSource's rule).
159
+ const [settled, setSettled] = useState(null);
160
+ useEffect(() => {
161
+ if (!url)
162
+ return;
163
+ let cancelled = false;
164
+ readElementApi(url, doFetch).then((body) => {
165
+ if (cancelled)
166
+ return;
167
+ const selected = selectElementApiPath(body, path);
168
+ setSettled(selected.found
169
+ ? { key: requestKey, ok: true, data: selected.value }
170
+ : {
171
+ key: requestKey,
172
+ ok: false,
173
+ error: `dataSource.path "${path}" selects nothing in the answer of GET ${url}: `
174
+ + `it has no "${selected.segment}".`,
175
+ });
176
+ }, (error) => {
177
+ if (cancelled)
178
+ return;
179
+ setSettled({
180
+ key: requestKey,
181
+ ok: false,
182
+ error: error instanceof Error ? error.message : `GET ${url} failed.`,
183
+ });
184
+ });
185
+ return () => {
186
+ cancelled = true;
187
+ };
188
+ }, [url, path, requestKey, doFetch]);
189
+ if (judged.status === 'absent')
190
+ return { status: 'absent' };
191
+ if (judged.status === 'refused')
192
+ return { status: 'refused', error: judged.error };
193
+ if (settled?.key !== requestKey)
194
+ return { status: 'loading', config: judged.config };
195
+ if (!settled.ok)
196
+ return { status: 'error', config: judged.config, error: settled.error };
197
+ return { status: 'ready', config: judged.config, data: settled.data };
198
+ }
@@ -40,7 +40,17 @@
40
40
  * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
41
  * here overrides it.
42
42
  * - **A nameless action is not translatable.** Without `action.name` there is
43
- * no `_actions.<name>` key to look up, so the literals pass through.
43
+ * no `_actions.<name>` key to look up, so the literals pass through —
44
+ * collapsed to the active language like every other caller's (below), since
45
+ * an inline action (`element:button`) is often nameless (objectui#12757).
46
+ * - **The entry is keyed on the action's own object** (objectui#11439): its
47
+ * declared `objectName`, else the host object the caller draws it on — the
48
+ * object `translateObject` in `@objectstack/spec` stamps on an embedded
49
+ * action. Only an action with neither reads `globalActions.<name>`; a bound
50
+ * action never does (see `actionSuffixes` in `@object-ui/i18n`). The
51
+ * runtime-side resolvers (`useConsoleActionRuntime`, `RecordDetailView`)
52
+ * already key the param / description / result-dialog copy the same way, so
53
+ * one action reads one bundle node on every surface.
44
54
  * - **`confirmText` / `successMessage` are resolved only when the action
45
55
  * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
56
  * onto an action whose metadata never asked for one (nor a success toast).
@@ -52,13 +62,14 @@
52
62
  * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
53
63
  * entry for an outcome the action does not declare is never read, so a
54
64
  * bundle cannot add copy for an answer the author never wrote one for.
55
- * - **`label`, `successMessage` and each `outcomeMessages` entry are reduced
56
- * through `pickLocalized` first**, because each is an `I18nLabel` — a string
57
- * or an inline per-locale map. The map is collapsed to the active language
65
+ * - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
66
+ * `outcomeMessages` entry are reduced through `pickLocalized` first**,
67
+ * because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
58
68
  * BEFORE it is offered to the bundle as the fallback, so a per-locale
59
69
  * literal and a bundle entry cannot disagree about what "the authored text"
60
70
  * is, and the action runner, which has no language, only ever receives a
61
- * string.
71
+ * string. `errorMessage` has no bundle key (the spec's action translation
72
+ * declares none), so the collapse is all it gets (objectui#12757).
62
73
  * - **The success copy stays a template.** A `${result.*}` token, in a bundle
63
74
  * entry or in the authored text, passes through this hook untouched: the
64
75
  * action has not run yet, so there is no result to read. The runner fills
@@ -74,9 +85,11 @@ export interface ActionTextLocalizerOptions {
74
85
  }
75
86
  /**
76
87
  * Returns a copy of `action` with `label` — plus `confirmText`,
77
- * `successMessage` and `outcomeMessages` when the action declares them —
88
+ * `successMessage`, `errorMessage` and `outcomeMessages` when the action
89
+ * declares them —
78
90
  * resolved from the active locale's bundle, falling back to the authored
79
- * literals.
91
+ * literals. `objectName` is the host object the action is drawn on; the
92
+ * action's own declared `objectName` wins over it when present.
80
93
  */
81
94
  export type ActionTextLocalizer = <T extends Record<string, any>>(objectName: string | undefined, action: T, options?: ActionTextLocalizerOptions) => T;
82
95
  export declare function useActionTextLocalizer(): ActionTextLocalizer;
@@ -40,7 +40,17 @@
40
40
  * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
41
  * here overrides it.
42
42
  * - **A nameless action is not translatable.** Without `action.name` there is
43
- * no `_actions.<name>` key to look up, so the literals pass through.
43
+ * no `_actions.<name>` key to look up, so the literals pass through —
44
+ * collapsed to the active language like every other caller's (below), since
45
+ * an inline action (`element:button`) is often nameless (objectui#12757).
46
+ * - **The entry is keyed on the action's own object** (objectui#11439): its
47
+ * declared `objectName`, else the host object the caller draws it on — the
48
+ * object `translateObject` in `@objectstack/spec` stamps on an embedded
49
+ * action. Only an action with neither reads `globalActions.<name>`; a bound
50
+ * action never does (see `actionSuffixes` in `@object-ui/i18n`). The
51
+ * runtime-side resolvers (`useConsoleActionRuntime`, `RecordDetailView`)
52
+ * already key the param / description / result-dialog copy the same way, so
53
+ * one action reads one bundle node on every surface.
44
54
  * - **`confirmText` / `successMessage` are resolved only when the action
45
55
  * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
56
  * onto an action whose metadata never asked for one (nor a success toast).
@@ -52,13 +62,14 @@
52
62
  * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
53
63
  * entry for an outcome the action does not declare is never read, so a
54
64
  * bundle cannot add copy for an answer the author never wrote one for.
55
- * - **`label`, `successMessage` and each `outcomeMessages` entry are reduced
56
- * through `pickLocalized` first**, because each is an `I18nLabel` — a string
57
- * or an inline per-locale map. The map is collapsed to the active language
65
+ * - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
66
+ * `outcomeMessages` entry are reduced through `pickLocalized` first**,
67
+ * because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
58
68
  * BEFORE it is offered to the bundle as the fallback, so a per-locale
59
69
  * literal and a bundle entry cannot disagree about what "the authored text"
60
70
  * is, and the action runner, which has no language, only ever receives a
61
- * string.
71
+ * string. `errorMessage` has no bundle key (the spec's action translation
72
+ * declares none), so the collapse is all it gets (objectui#12757).
62
73
  * - **The success copy stays a template.** A `${result.*}` token, in a bundle
63
74
  * entry or in the authored text, passes through this hook untouched: the
64
75
  * action has not run yet, so there is no result to read. The runner fills
@@ -68,6 +79,8 @@
68
79
  import { useMemo } from 'react';
69
80
  import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n';
70
81
  import { isConfigBag } from '../utils/configBag.js';
82
+ /** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */
83
+ const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'];
71
84
  export function useActionTextLocalizer() {
72
85
  const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel();
73
86
  const { language } = useObjectTranslation();
@@ -82,21 +95,39 @@ export function useActionTextLocalizer() {
82
95
  ? action.name
83
96
  : undefined;
84
97
  const displayLabel = authoredLabel || name || options?.fallbackLabel || '';
85
- // No name, no translation key: the literals are the answer. The label is
98
+ // Every declared `I18nLabel` copy collapses the same way, so neither the
99
+ // bundle fallbacks below nor the nameless return hand the runner a map.
100
+ const collapsed = { ...action, label: displayLabel };
101
+ let changed = displayLabel !== action.label;
102
+ for (const key of DECLARED_COPY_KEYS) {
103
+ const declaredCopy = action[key];
104
+ if (declaredCopy === undefined)
105
+ continue;
106
+ collapsed[key] = pickLocalized(declaredCopy, language);
107
+ if (collapsed[key] !== declaredCopy)
108
+ changed = true;
109
+ }
110
+ // No name, no translation key: the literals are the answer. They are
86
111
  // still normalized (map to string, last-resort placeholder applied) so
87
112
  // every caller reads one shape.
88
113
  if (!name) {
89
- return displayLabel === action.label ? action : { ...action, label: displayLabel };
114
+ return (changed ? collapsed : action);
90
115
  }
116
+ // The key object: the action's declared `objectName`, else the host the
117
+ // caller passes (objectui#11439). An action declared on `sales_order`
118
+ // drawn where the caller knows no object still reads `sales_order`'s
119
+ // `_actions` node, never `globalActions`.
120
+ const declared = action.objectName;
121
+ const keyObject = (typeof declared === 'string' && declared) || objectName;
91
122
  const out = {
92
- ...action,
93
- label: actionLabel(objectName, name, displayLabel),
123
+ ...collapsed,
124
+ label: actionLabel(keyObject, name, displayLabel),
94
125
  };
95
126
  if (action.confirmText !== undefined) {
96
- out.confirmText = actionConfirm(objectName, name, action.confirmText);
127
+ out.confirmText = actionConfirm(keyObject, name, collapsed.confirmText);
97
128
  }
98
129
  if (action.successMessage !== undefined) {
99
- out.successMessage = actionSuccess(objectName, name, pickLocalized(action.successMessage, language));
130
+ out.successMessage = actionSuccess(keyObject, name, collapsed.successMessage);
100
131
  }
101
132
  // The map is an authored bag (outcome → copy), so the one "is this a
102
133
  // real bag?" predicate in this package answers whether to read it.
@@ -104,7 +135,7 @@ export function useActionTextLocalizer() {
104
135
  if (isConfigBag(authoredOutcomes)) {
105
136
  const outcomeMessages = {};
106
137
  for (const [outcome, copy] of Object.entries(authoredOutcomes)) {
107
- const text = actionOutcome(objectName, name, outcome, pickLocalized(copy, language));
138
+ const text = actionOutcome(keyObject, name, outcome, pickLocalized(copy, language));
108
139
  if (text !== undefined)
109
140
  outcomeMessages[outcome] = text;
110
141
  }
@@ -9,10 +9,11 @@
9
9
  * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
10
10
  * rendered WITHOUT `SchemaRenderer` (objectui#10290).
11
11
  *
12
- * The action containers (`action:bar`, `action:group`, `action:menu` in
13
- * `@object-ui/components`) draw their member actions themselves, so a member's
14
- * `properties` never passes through the memo. Its static execution values ride
15
- * `properties.params` (objectui#10289), and those values are templates
12
+ * `action:bar` (in `@object-ui/components`) mounts its member actions itself,
13
+ * so a member's `properties` never passes through the memo. Its static
14
+ * execution values ride `properties.params` (objectui#10289; an `action:group`
15
+ * / `action:menu` member carries no `properties` bag and its container reads
16
+ * none, objectui#11638), and those values are templates
16
17
  * evaluated where `properties` are (objectui#7867). This hook is that
17
18
  * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
18
19
  * scope (the host's ambient roots, `current_user`, the page's bound row as
@@ -13,10 +13,11 @@ import { usePageVariables } from './usePageVariables.js';
13
13
  * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
14
14
  * rendered WITHOUT `SchemaRenderer` (objectui#10290).
15
15
  *
16
- * The action containers (`action:bar`, `action:group`, `action:menu` in
17
- * `@object-ui/components`) draw their member actions themselves, so a member's
18
- * `properties` never passes through the memo. Its static execution values ride
19
- * `properties.params` (objectui#10289), and those values are templates
16
+ * `action:bar` (in `@object-ui/components`) mounts its member actions itself,
17
+ * so a member's `properties` never passes through the memo. Its static
18
+ * execution values ride `properties.params` (objectui#10289; an `action:group`
19
+ * / `action:menu` member carries no `properties` bag and its container reads
20
+ * none, objectui#11638), and those values are templates
20
21
  * evaluated where `properties` are (objectui#7867). This hook is that
21
22
  * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
22
23
  * scope (the host's ambient roots, `current_user`, the page's bound row as
@@ -77,7 +77,12 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
77
77
  export function useDatasetDimensionMeta(object, dimensionFields, dimensions, options) {
78
78
  // The host's AUTHENTICATED fetch (objectui#4121) — see property 1 in the
79
79
  // file header for why it is read off the context directly.
80
- const apiFetch = useContext(SchemaRendererContext)?.apiFetch;
80
+ const context = useContext(SchemaRendererContext);
81
+ const apiFetch = context?.apiFetch;
82
+ // The host data source, when it can answer `getObjectSchema` (objectui#13039):
83
+ // the data layer's shared request-key cache, so N dataset widgets over one
84
+ // object make ONE `meta/object/NAME` request instead of one each.
85
+ const dataSource = context?.dataSource;
81
86
  const enabled = options?.enabled ?? true;
82
87
  // LOCALE-FREE state (objectui#4030) — see property 2 in the file header.
83
88
  const [meta, setMeta] = useState(null);
@@ -103,14 +108,22 @@ export function useDatasetDimensionMeta(object, dimensionFields, dimensions, opt
103
108
  (async () => {
104
109
  try {
105
110
  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
- };
111
+ // objectui#13039 — through the data source's `getObjectSchema` when one
112
+ // that answers it is in scope: the read the dashboard's chart, metric
113
+ // and table widgets make, which the adapter's metadata cache joins while
114
+ // it is in flight. It answers the bare object document, which is what
115
+ // the raw leg's unwrap yields, so core gets one shape from either leg.
116
+ // The raw read stays as the fallback for a surface with no such source.
117
+ const loadObjectSchema = typeof dataSource?.getObjectSchema === 'function'
118
+ ? (name) => dataSource.getObjectSchema(name)
119
+ : async (name) => {
120
+ const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
121
+ headers: { accept: 'application/json' },
122
+ credentials: 'include',
123
+ });
124
+ const doc = await r.json().catch(() => null);
125
+ return doc?.item ?? doc?.data ?? doc;
126
+ };
114
127
  // ONE walk for every dimension, memoized per call inside core — sibling
115
128
  // dimensions sharing a relationship prefix read that object once, and
116
129
  // the base object is seeded so it is never re-read.
@@ -133,8 +146,10 @@ export function useDatasetDimensionMeta(object, dimensionFields, dimensions, opt
133
146
  // (objectui#4030 / PR #4324). `apiFetch` comes from the provider's
134
147
  // memoized context value, and this hook's own `setState` cannot re-render
135
148
  // the provider, so its identity is stable across the effect's own updates.
149
+ // `dataSource` joins for the same reason and from the same value
150
+ // (objectui#13039): it decides which leg the read takes.
136
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
137
- }, [signature, apiFetch]);
152
+ }, [signature, apiFetch, dataSource]);
138
153
  return meta;
139
154
  }
140
155
  /**
@@ -53,9 +53,11 @@ export interface DensityResult {
53
53
  * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
54
54
  *
55
55
  * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
56
- * // retired on `NamedListView` (objectui#7924), so persist the spec value.
56
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value —
57
+ * // here into the host's own per-user store (`saveUserRowHeight` is the
58
+ * // host's; the console keeps the choice on the user's own list state).
57
59
  * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
58
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }),
60
+ * onChange: (m) => saveUserRowHeight(vid, DENSITY_MODE_TO_ROW_HEIGHT[m]),
59
61
  * });
60
62
  * ```
61
63
  */
@@ -38,9 +38,11 @@ const DENSITY_ORDER = ['compact', 'comfortable', 'spacious'];
38
38
  * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
39
39
  *
40
40
  * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
41
- * // retired on `NamedListView` (objectui#7924), so persist the spec value.
41
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value —
42
+ * // here into the host's own per-user store (`saveUserRowHeight` is the
43
+ * // host's; the console keeps the choice on the user's own list state).
42
44
  * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
43
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }),
45
+ * onChange: (m) => saveUserRowHeight(vid, DENSITY_MODE_TO_ROW_HEIGHT[m]),
44
46
  * });
45
47
  * ```
46
48
  */
@@ -6,9 +6,10 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  *
8
8
  * `usePageAssignment` — resolve the record PageSchema that should be rendered
9
- * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`
10
- * and returns the first PageSchema whose `type === 'record'` and `object`
11
- * matches the requested name.
9
+ * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`,
10
+ * keeps the PageSchemas whose `type === 'record'` and `object` matches the
11
+ * requested name, and returns the object's DEFAULT record page among them
12
+ * (`isDefault: true`), else the first of them.
12
13
  *
13
14
  * Returns a discriminated result by `PageSchema.kind`:
14
15
  * - `kind === 'full'` (default): the schema fully describes the page;
@@ -18,13 +19,17 @@
18
19
  * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
19
20
  * fall through to synthesized defaults.
20
21
  *
21
- * Selection is by DECLARATION ORDER: the first matching page wins, so callers
22
- * can deterministically fall back to the auto-generated DetailView when no
23
- * record Page is authored.
22
+ * Selection is by the declared `PageSchema.isDefault`, then by DECLARATION
23
+ * ORDER among pages that are not the default: the spec allows one default
24
+ * record page per object, so the flag needs no tie-break here, and with no
25
+ * default the first matching page wins. No match returns null, so callers can
26
+ * deterministically fall back to the auto-generated DetailView when no record
27
+ * Page is authored.
24
28
  *
25
29
  * Future work (deferred): recordType / profile / app / formFactor filtering.
26
- * Any author-driven ordering would have to be DECLARED on `PageSchema` in
27
- * `@objectstack/spec` first — see the note on the selection itself below.
30
+ * Any further author-driven ordering would have to be DECLARED on
31
+ * `PageSchema` in `@objectstack/spec` first — see the note on the selection
32
+ * itself below.
28
33
  */
29
34
  export interface PageAssignmentOptions {
30
35
  /** Salesforce-style record type filter (reserved for future use). */
@@ -62,6 +67,24 @@ export interface PageAssignmentResult {
62
67
  /** Loader error, if any. */
63
68
  error: Error | null;
64
69
  }
70
+ /**
71
+ * True when `page` is a record page bound to `objectName`: its `type` is
72
+ * `'record'` and its `object` names that object.
73
+ *
74
+ * This is the ONE reading of "a record page of object O" in the console. The
75
+ * object-based lookup below filters its candidates with it, and a route that
76
+ * names a page explicitly (the console's named record page route,
77
+ * objectui#12715) asks it before rendering the named page for a record of O,
78
+ * so a name that is a page of another object, or not a record page at all, is
79
+ * refused rather than rendered. The `pageName` override of
80
+ * {@link usePageAssignment} does not ask it: that option returns the page with
81
+ * that name whatever it is, and a caller that renders a record on the page it
82
+ * gets back asks this first.
83
+ *
84
+ * `type` is the one discriminator, as the lookup's own comment explains
85
+ * (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read.
86
+ */
87
+ export declare function isRecordPageOf(page: unknown, objectName: string | undefined | null): boolean;
65
88
  /**
66
89
  * Resolve the record PageSchema for the given object name. Returns `null`
67
90
  * when no record Page is configured, signalling the caller to fall back