@object-ui/react 17.2.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 +956 -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 +94 -17
  21. package/dist/hooks/useExpression.js +96 -34
  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 +10 -10
@@ -0,0 +1,199 @@
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
+ * Mapping half of consuming `PageComponentSchema.dataSource` — the spec's
9
+ * `ElementDataSourceSchema` per-element data binding (objectstack#5576 landed
10
+ * the resolution; objectstack#6953 wires it to the remaining blocks).
11
+ *
12
+ * `@object-ui/core` owns the pure half (telling the binding apart from a runtime
13
+ * adapter, matching a view name, composing the view with the binding's own
14
+ * keys). {@link useElementDataSource} owns the fetch half. This module owns the
15
+ * LAST hop: writing the composed result onto the schema keys a given block
16
+ * actually reads, and rendering the two non-final states.
17
+ *
18
+ * It exists because that hop was the part every block would otherwise copy.
19
+ * `dataSource` is declared on EVERY page component, so eight blocks need the
20
+ * same precedence table (below) with only the KEY NAMES differing — which is a
21
+ * mapping description, not eight algorithms. A per-block copy is how the two
22
+ * halves drift: one block ANDs the filters and the next replaces them, and the
23
+ * spec's "additional filter criteria" quietly becomes two dialects.
24
+ *
25
+ * `plugin-list`'s `ListViewBlock` carried the ORIGINAL copy of the table
26
+ * (objectstack#5576, which predates this module) and was collapsed onto this one
27
+ * by objectui#4038 — on the acceptance criterion that objectstack#5576's whole
28
+ * suite passes against this implementation untouched, which it does. Every
29
+ * object-bound block now reads the rules below from here, so this is the one
30
+ * place a change to them belongs.
31
+ *
32
+ * ## Precedence — one table, applied everywhere
33
+ *
34
+ * Three kinds of value arrive and they do not have the same standing:
35
+ *
36
+ * - **`dataSource.*` keys are authoritative.** The author wrote them on THIS
37
+ * placement and the spec says the binding "overrides page-level object
38
+ * context", so they beat the component's own same-named key.
39
+ * - **View-sourced values are a baseline.** A `view` is a *reference*; a key
40
+ * written on the component itself is more specific than the view it points
41
+ * at, so the component's key wins over one the view supplied.
42
+ * - **`filter` never overrides — it combines.** The spec describes the
43
+ * binding's filter as "*Additional* filter criteria", so component filter,
44
+ * view filter and binding filter all AND together through
45
+ * {@link mergeFilterNodes}. A binding can only narrow what the view already
46
+ * restricts, never widen it: a mistyped per-element filter cannot expose rows
47
+ * the saved view excluded.
48
+ *
49
+ * An authored-but-EMPTY `columns` counts as "not authored": `[]` is what the
50
+ * designer emits for an unconfigured column list, and supplying the columns is
51
+ * exactly why a view was named.
52
+ *
53
+ * ## What a mapping may NOT do
54
+ *
55
+ * {@link ElementDataSourceMapping} names only keys the target block genuinely
56
+ * reads. Writing a composed key onto a schema key the block ignores would
57
+ * reproduce the very defect this wiring removes — a value accepted and dropped —
58
+ * one layer further in, where it is even harder to see. A block with no row cap
59
+ * therefore leaves `limit` unmapped rather than parking it somewhere plausible,
60
+ * and the gap is recorded at the call site instead of being papered over here.
61
+ *
62
+ * ## An unresolvable `view` fails loudly
63
+ *
64
+ * When the named view does not exist the gate renders a configuration error
65
+ * instead of letting the block fall back to the object's default scope. Silently
66
+ * widening a named view to "all records" is the failure class the binding exists
67
+ * to remove, and it is the one an AI-authored page hides best: the page looks
68
+ * like it works.
69
+ */
70
+ import * as React from 'react';
71
+ import { type ElementDataSourceConfig } from '@object-ui/core';
72
+ import { type ElementDataSourceStatus } from '../hooks/useElementDataSource';
73
+ /**
74
+ * Where a block's row cap lives. Three spellings are real in this repo and each
75
+ * is the ONLY one its block reads: `list-view`/`object-grid` read
76
+ * `pagination.pageSize` (falling back to a flat `pageSize`), and
77
+ * `record:related_list` reads the spec's flat `limit`.
78
+ */
79
+ export type ElementDataSourceLimitKey = 'limit' | 'pageSize' | 'pagination.pageSize';
80
+ /**
81
+ * Which schema keys a block reads, so the composed binding lands on those and
82
+ * nothing else.
83
+ *
84
+ * Every field is opt-IN. The default mapping writes only the object name,
85
+ * because that is the one key every object-bound block in this repo reads; a
86
+ * block that also reads a filter, a sort, a column list or a row cap says so
87
+ * here, naming the key it reads.
88
+ */
89
+ export interface ElementDataSourceMapping {
90
+ /**
91
+ * Schema key the binding's `object` lands on — `'objectName'` for every
92
+ * object-bound block in this repo, which is the default. `false` maps nothing
93
+ * (for a block that reads the composed object itself, like
94
+ * `element:record_picker`, whose object lives under `properties`).
95
+ */
96
+ object?: string | false;
97
+ /**
98
+ * Set when the block renders a FIELD column list a saved view can supply.
99
+ * Leave unset for a block whose `columns` mean something else — an
100
+ * `object-kanban`'s `columns` are its swimlane groups, not fields, and a
101
+ * view's field list written there would render a broken board.
102
+ */
103
+ columns?: boolean;
104
+ /** Set when the block reads `schema.filter` as its query filter. */
105
+ filter?: boolean;
106
+ /** Set when the block reads `schema.sort` as its query ordering. */
107
+ sort?: boolean;
108
+ /** The key a row cap lands on; omit for a block that enforces no cap. */
109
+ limit?: ElementDataSourceLimitKey;
110
+ /**
111
+ * Set when the block renders several view kinds off `schema.viewType`
112
+ * (`list-view` is the only such container: its registry `inputs` enumerate
113
+ * grid/kanban/gallery/…, so naming a saved kanban view and rendering a grid
114
+ * would be a silently wrong answer).
115
+ */
116
+ viewType?: boolean;
117
+ }
118
+ export interface UseElementDataSourceSchemaResult<S> {
119
+ /** Resolution state; see {@link ElementDataSourceStatus}. */
120
+ status: ElementDataSourceStatus;
121
+ /**
122
+ * The schema with the composed binding applied. Returned by REFERENCE when
123
+ * there is nothing to apply, so a block that carries no binding never sees a
124
+ * new schema identity (which would remount it and refetch on every render).
125
+ */
126
+ schema: S;
127
+ /** The binding as authored, or `undefined` when the node carries none. */
128
+ config?: ElementDataSourceConfig;
129
+ /** Author-facing explanation, set only for `missing`. */
130
+ error?: string;
131
+ }
132
+ /**
133
+ * Resolve a block's `dataSource` binding and apply it to the block's own schema
134
+ * keys, per {@link ElementDataSourceMapping}.
135
+ *
136
+ * @param schema The block's schema node (its `dataSource` is read).
137
+ * @param mapping Which schema keys this block reads.
138
+ * @param dataSource Explicit adapter; falls back to `SchemaRendererContext`.
139
+ *
140
+ * @example
141
+ * ```tsx
142
+ * const bound = useElementDataSourceSchema(schema, { filter: true, sort: true });
143
+ * if (bound.status === 'missing') return <ElementDataSourceErrorPanel message={bound.error} />;
144
+ * return <ObjectCalendar schema={bound.schema} />;
145
+ * ```
146
+ */
147
+ export declare function useElementDataSourceSchema<S>(schema: S, mapping?: ElementDataSourceMapping, dataSource?: unknown): UseElementDataSourceSchemaResult<S>;
148
+ export interface ElementDataSourceStatusPanelProps {
149
+ /**
150
+ * `data-testid` stem, normally the block's registry key — the panels append
151
+ * `-datasource-error` / `-resolving-view`, so a test names the block it is
152
+ * asserting about rather than a shared anonymous id.
153
+ */
154
+ testId: string;
155
+ /** Panel heading; defaults to a block-neutral sentence. */
156
+ title?: string;
157
+ /** The author-facing explanation from {@link useElementDataSourceSchema}. */
158
+ message?: string;
159
+ }
160
+ /**
161
+ * The "named view does not resolve" panel. Same posture (and shape) as
162
+ * `SchemaRenderer`'s "Unknown component type": authored metadata pointing at
163
+ * something that is not there, reported where the author can see it.
164
+ */
165
+ export declare function ElementDataSourceErrorPanel({ testId, title, message, }: ElementDataSourceStatusPanelProps): React.ReactElement;
166
+ /**
167
+ * The "saved views are still being fetched" placeholder. Distinct from the error
168
+ * panel because a component that treated "not resolved yet" as "does not exist"
169
+ * would flash a configuration error on every mount.
170
+ */
171
+ export declare function ElementDataSourceLoadingPanel({ testId, }: Pick<ElementDataSourceStatusPanelProps, 'testId'>): React.ReactElement;
172
+ export interface ElementDataSourceGateProps<S> {
173
+ /** The block's schema node. */
174
+ schema: S;
175
+ /** Which schema keys this block reads; see {@link ElementDataSourceMapping}. */
176
+ mapping?: ElementDataSourceMapping;
177
+ /** Explicit adapter; falls back to `SchemaRendererContext`. */
178
+ dataSource?: unknown;
179
+ /** `data-testid` stem for the two status panels — the block's registry key. */
180
+ testId: string;
181
+ /** Heading for the unresolvable-view panel. */
182
+ errorTitle?: string;
183
+ /**
184
+ * Renders the block with the bound schema. Called during the gate's own
185
+ * render, so it must RETURN AN ELEMENT and never call hooks itself — the
186
+ * block's hooks belong to the block.
187
+ */
188
+ children: (schema: S) => React.ReactElement | null;
189
+ }
190
+ /**
191
+ * Wrap an object-bound block so its `dataSource` binding reaches the keys it
192
+ * reads, and so the two non-final resolution states render once, the same way,
193
+ * for every block.
194
+ *
195
+ * The block itself is not mounted while a named view is unresolved: rendering it
196
+ * against a half-resolved query is how a page shows a wider answer than the one
197
+ * that was authored.
198
+ */
199
+ export declare function ElementDataSourceGate<S>({ schema, mapping, dataSource, testId, errorTitle, children, }: ElementDataSourceGateProps<S>): React.ReactElement | null;
@@ -0,0 +1,195 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ObjectUI
4
+ * Copyright (c) 2024-present ObjectStack Inc.
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ * Mapping half of consuming `PageComponentSchema.dataSource` — the spec's
10
+ * `ElementDataSourceSchema` per-element data binding (objectstack#5576 landed
11
+ * the resolution; objectstack#6953 wires it to the remaining blocks).
12
+ *
13
+ * `@object-ui/core` owns the pure half (telling the binding apart from a runtime
14
+ * adapter, matching a view name, composing the view with the binding's own
15
+ * keys). {@link useElementDataSource} owns the fetch half. This module owns the
16
+ * LAST hop: writing the composed result onto the schema keys a given block
17
+ * actually reads, and rendering the two non-final states.
18
+ *
19
+ * It exists because that hop was the part every block would otherwise copy.
20
+ * `dataSource` is declared on EVERY page component, so eight blocks need the
21
+ * same precedence table (below) with only the KEY NAMES differing — which is a
22
+ * mapping description, not eight algorithms. A per-block copy is how the two
23
+ * halves drift: one block ANDs the filters and the next replaces them, and the
24
+ * spec's "additional filter criteria" quietly becomes two dialects.
25
+ *
26
+ * `plugin-list`'s `ListViewBlock` carried the ORIGINAL copy of the table
27
+ * (objectstack#5576, which predates this module) and was collapsed onto this one
28
+ * by objectui#4038 — on the acceptance criterion that objectstack#5576's whole
29
+ * suite passes against this implementation untouched, which it does. Every
30
+ * object-bound block now reads the rules below from here, so this is the one
31
+ * place a change to them belongs.
32
+ *
33
+ * ## Precedence — one table, applied everywhere
34
+ *
35
+ * Three kinds of value arrive and they do not have the same standing:
36
+ *
37
+ * - **`dataSource.*` keys are authoritative.** The author wrote them on THIS
38
+ * placement and the spec says the binding "overrides page-level object
39
+ * context", so they beat the component's own same-named key.
40
+ * - **View-sourced values are a baseline.** A `view` is a *reference*; a key
41
+ * written on the component itself is more specific than the view it points
42
+ * at, so the component's key wins over one the view supplied.
43
+ * - **`filter` never overrides — it combines.** The spec describes the
44
+ * binding's filter as "*Additional* filter criteria", so component filter,
45
+ * view filter and binding filter all AND together through
46
+ * {@link mergeFilterNodes}. A binding can only narrow what the view already
47
+ * restricts, never widen it: a mistyped per-element filter cannot expose rows
48
+ * the saved view excluded.
49
+ *
50
+ * An authored-but-EMPTY `columns` counts as "not authored": `[]` is what the
51
+ * designer emits for an unconfigured column list, and supplying the columns is
52
+ * exactly why a view was named.
53
+ *
54
+ * ## What a mapping may NOT do
55
+ *
56
+ * {@link ElementDataSourceMapping} names only keys the target block genuinely
57
+ * reads. Writing a composed key onto a schema key the block ignores would
58
+ * reproduce the very defect this wiring removes — a value accepted and dropped —
59
+ * one layer further in, where it is even harder to see. A block with no row cap
60
+ * therefore leaves `limit` unmapped rather than parking it somewhere plausible,
61
+ * and the gap is recorded at the call site instead of being papered over here.
62
+ *
63
+ * ## An unresolvable `view` fails loudly
64
+ *
65
+ * When the named view does not exist the gate renders a configuration error
66
+ * instead of letting the block fall back to the object's default scope. Silently
67
+ * widening a named view to "all records" is the failure class the binding exists
68
+ * to remove, and it is the one an AI-authored page hides best: the page looks
69
+ * like it works.
70
+ */
71
+ import * as React from 'react';
72
+ import { isElementDataSourceConfig, mergeFilterNodes, } from '@object-ui/core';
73
+ import { useElementDataSource, } from '../hooks/useElementDataSource';
74
+ const readLimit = (base, key) => {
75
+ if (key === 'pagination.pageSize')
76
+ return base.pagination?.pageSize;
77
+ return base[key];
78
+ };
79
+ const writeLimit = (next, base, key, limit) => {
80
+ if (key === 'pagination.pageSize') {
81
+ next.pagination = { ...(base.pagination ?? {}), pageSize: limit };
82
+ return;
83
+ }
84
+ next[key] = limit;
85
+ };
86
+ /**
87
+ * Resolve a block's `dataSource` binding and apply it to the block's own schema
88
+ * keys, per {@link ElementDataSourceMapping}.
89
+ *
90
+ * @param schema The block's schema node (its `dataSource` is read).
91
+ * @param mapping Which schema keys this block reads.
92
+ * @param dataSource Explicit adapter; falls back to `SchemaRendererContext`.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * const bound = useElementDataSourceSchema(schema, { filter: true, sort: true });
97
+ * if (bound.status === 'missing') return <ElementDataSourceErrorPanel message={bound.error} />;
98
+ * return <ObjectCalendar schema={bound.schema} />;
99
+ * ```
100
+ */
101
+ export function useElementDataSourceSchema(schema, mapping = {}, dataSource) {
102
+ // Defence in depth for the collision the binding and the adapter share by
103
+ // NAME: even though `SchemaRenderer` no longer spreads `schema.dataSource` as
104
+ // a prop, a host (or an older cached bundle) handing us the spec BINDING under
105
+ // this argument must never be mistaken for an adapter — that is how a
106
+ // spec-compliant page reported "this data source cannot list the saved views".
107
+ // Same guard `ListViewBlock` carries, applied for every block at once.
108
+ const adapter = isElementDataSourceConfig(dataSource) ? undefined : dataSource;
109
+ const binding = useElementDataSource(schema, adapter);
110
+ const { object: objectKey = 'objectName', columns, filter, sort, limit, viewType } = mapping;
111
+ const mapped = React.useMemo(() => {
112
+ const composed = binding.composed;
113
+ if (!composed)
114
+ return schema;
115
+ const base = (schema ?? {});
116
+ const next = { ...base };
117
+ if (objectKey !== false)
118
+ next[objectKey] = composed.object;
119
+ if (columns) {
120
+ // `[]` is the designer's "not configured yet", and supplying the columns
121
+ // is the reason a view was named — so an empty authored list yields.
122
+ const authored = Array.isArray(base.columns) && base.columns.length > 0;
123
+ if (!authored && composed.columns !== undefined)
124
+ next.columns = composed.columns;
125
+ }
126
+ if (filter) {
127
+ // Component filter AND (view filter AND binding filter). `composed.filter`
128
+ // already carries the latter pair; a single surviving source comes back
129
+ // unwrapped, so the common "only the view filters" case stays flat.
130
+ const merged = mergeFilterNodes(base.filter, composed.filter);
131
+ if (merged !== undefined)
132
+ next.filter = merged;
133
+ else
134
+ delete next.filter;
135
+ }
136
+ // `composed.sort`/`composed.limit` are the BINDING's when it declared one,
137
+ // else the view's — so the component's own key may only win over the latter.
138
+ if (sort && composed.sort !== undefined) {
139
+ const fromView = binding.config?.sort === undefined;
140
+ if (!fromView || base.sort === undefined)
141
+ next.sort = composed.sort;
142
+ }
143
+ if (limit && composed.limit !== undefined) {
144
+ const fromView = binding.config?.limit === undefined;
145
+ if (!fromView || readLimit(base, limit) === undefined) {
146
+ writeLimit(next, base, limit, composed.limit);
147
+ }
148
+ }
149
+ if (viewType && composed.viewType !== undefined && base.viewType === undefined) {
150
+ next.viewType = composed.viewType;
151
+ }
152
+ return next;
153
+ }, [schema, binding.composed, binding.config, objectKey, columns, filter, sort, limit, viewType]);
154
+ return React.useMemo(() => ({
155
+ status: binding.status,
156
+ schema: mapped,
157
+ config: binding.config,
158
+ error: binding.error,
159
+ }), [binding.status, binding.config, binding.error, mapped]);
160
+ }
161
+ /**
162
+ * The "named view does not resolve" panel. Same posture (and shape) as
163
+ * `SchemaRenderer`'s "Unknown component type": authored metadata pointing at
164
+ * something that is not there, reported where the author can see it.
165
+ */
166
+ export function ElementDataSourceErrorPanel({ testId, title = 'This component’s data source could not be resolved', message, }) {
167
+ return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", role: "alert", "data-testid": `${testId}-datasource-error`, children: [_jsx("p", { className: "font-medium", children: title }), message ? _jsx("p", { className: "text-sm mt-1", children: message }) : null] }));
168
+ }
169
+ /**
170
+ * The "saved views are still being fetched" placeholder. Distinct from the error
171
+ * panel because a component that treated "not resolved yet" as "does not exist"
172
+ * would flash a configuration error on every mount.
173
+ */
174
+ export function ElementDataSourceLoadingPanel({ testId, }) {
175
+ return (_jsx("div", { className: "p-2 text-sm text-muted-foreground animate-pulse", role: "status", "aria-live": "polite", "data-testid": `${testId}-resolving-view`, children: "Loading view\u2026" }));
176
+ }
177
+ /**
178
+ * Wrap an object-bound block so its `dataSource` binding reaches the keys it
179
+ * reads, and so the two non-final resolution states render once, the same way,
180
+ * for every block.
181
+ *
182
+ * The block itself is not mounted while a named view is unresolved: rendering it
183
+ * against a half-resolved query is how a page shows a wider answer than the one
184
+ * that was authored.
185
+ */
186
+ export function ElementDataSourceGate({ schema, mapping, dataSource, testId, errorTitle, children, }) {
187
+ const bound = useElementDataSourceSchema(schema, mapping, dataSource);
188
+ if (bound.status === 'missing') {
189
+ return _jsx(ElementDataSourceErrorPanel, { testId: testId, title: errorTitle, message: bound.error });
190
+ }
191
+ if (bound.status === 'loading') {
192
+ return _jsx(ElementDataSourceLoadingPanel, { testId: testId });
193
+ }
194
+ return children(bound.schema);
195
+ }
@@ -12,6 +12,7 @@ export * from './useNavigationOverlay';
12
12
  export * from './usePageVariables';
13
13
  export * from './usePageVariableActionBridge';
14
14
  export * from './useViewData';
15
+ export * from './useElementDataSource';
15
16
  export * from './useDynamicApp';
16
17
  export * from './useDiscovery';
17
18
  export * from './useFocusTrap';
@@ -33,7 +34,9 @@ export * from './useSchemaPersistence';
33
34
  export * from './useGlobalUndo';
34
35
  export * from './useDebugMode';
35
36
  export * from './useActionEngine';
37
+ export * from './useActionTextLocalizer';
36
38
  export * from './useCapabilityGate';
39
+ export * from './useDatasetDimensionLabels';
37
40
  export * from './useDataRefresh';
38
41
  export * from './usePageAssignment';
39
42
  export * from './useRecordSearch';
@@ -13,6 +13,8 @@ export * from './useNavigationOverlay';
13
13
  export * from './usePageVariables';
14
14
  export * from './usePageVariableActionBridge';
15
15
  export * from './useViewData';
16
+ // PageComponentSchema.dataSource — the spec's per-element data binding.
17
+ export * from './useElementDataSource';
16
18
  export * from './useDynamicApp';
17
19
  export * from './useDiscovery';
18
20
  export * from './useFocusTrap';
@@ -34,7 +36,15 @@ export * from './useSchemaPersistence';
34
36
  export * from './useGlobalUndo';
35
37
  export * from './useDebugMode';
36
38
  export * from './useActionEngine';
39
+ // The ONE application of `useObjectLabel`'s action resolvers over the three
40
+ // authored strings an action carries (label / confirmText / successMessage).
41
+ export * from './useActionTextLocalizer';
37
42
  export * from './useCapabilityGate';
43
+ // The analytics label net's React glue, consumed by BOTH plugin-dashboard's
44
+ // `DatasetWidget` and plugin-report's dataset block (objectui#4389). It lives
45
+ // here rather than in `@object-ui/core` because it reads `SchemaRendererContext`
46
+ // — see the file header for the measured dependency direction.
47
+ export * from './useDatasetDimensionLabels';
38
48
  export * from './useDataRefresh';
39
49
  export * from './usePageAssignment';
40
50
  export * from './useRecordSearch';
@@ -0,0 +1,69 @@
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
+ * `useActionTextLocalizer` — the ONE place a declared action's authored,
9
+ * user-visible strings are resolved against the active locale's bundle.
10
+ *
11
+ * ## Why this hook exists (objectui#4265)
12
+ *
13
+ * A TranslationBundle entry for an action carries THREE keys, all under the
14
+ * same `_actions.<name>.*` node:
15
+ *
16
+ * ```ts
17
+ * convert_lead: {
18
+ * label: '...', // the button
19
+ * confirmText: '...', // the confirm dialog BODY
20
+ * successMessage: '...', // the success toast
21
+ * }
22
+ * ```
23
+ *
24
+ * `useObjectLabel()` has always exposed a resolver for each of them
25
+ * (`actionLabel` / `actionConfirm` / `actionSuccess`, one `resolve()` and one
26
+ * key convention behind all three). What drifted is the CALL SITES: some
27
+ * render surfaces called all three, others called only `actionLabel`. On those
28
+ * surfaces one bundle entry met two fates — the button rendered the
29
+ * translation while the confirm dialog rendered the authored English literal,
30
+ * which is exactly what objectui#4265 reports.
31
+ *
32
+ * So this is deliberately NOT a new resolution dialect. It is a single
33
+ * application of the existing resolvers over the three keys, so a surface can
34
+ * no longer localize one of them and forget the others: there is one function
35
+ * to call, and it can only do all three.
36
+ *
37
+ * ## Pinned semantics
38
+ *
39
+ * - **Fallback**: no bundle entry, or an entry lacking the key, renders the
40
+ * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
+ * here overrides it.
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.
44
+ * - **`confirmText` / `successMessage` are resolved only when the action
45
+ * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
+ * onto an action whose metadata never asked for one (nor a success toast).
47
+ * Translation localizes what exists; it does not add behaviour. This mirrors
48
+ * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
+ * `DeclaredActionsBar`) each spelled out by hand.
50
+ * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
+ * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
+ * collapsed to the active language BEFORE it is offered to the bundle as the
53
+ * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
+ * what "the authored label" is.
55
+ */
56
+ export interface ActionTextLocalizerOptions {
57
+ /**
58
+ * Last-resort display text, used only when the action declares neither a
59
+ * `label` nor a `name` (e.g. a positional `Action 3` placeholder).
60
+ */
61
+ fallbackLabel?: string;
62
+ }
63
+ /**
64
+ * Returns a copy of `action` with `label` — plus `confirmText` and
65
+ * `successMessage` when the action declares them — resolved from the active
66
+ * locale's bundle, falling back to the authored literals.
67
+ */
68
+ export type ActionTextLocalizer = <T extends Record<string, any>>(objectName: string | undefined, action: T, options?: ActionTextLocalizerOptions) => T;
69
+ export declare function useActionTextLocalizer(): ActionTextLocalizer;
@@ -0,0 +1,91 @@
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
+ * `useActionTextLocalizer` — the ONE place a declared action's authored,
9
+ * user-visible strings are resolved against the active locale's bundle.
10
+ *
11
+ * ## Why this hook exists (objectui#4265)
12
+ *
13
+ * A TranslationBundle entry for an action carries THREE keys, all under the
14
+ * same `_actions.<name>.*` node:
15
+ *
16
+ * ```ts
17
+ * convert_lead: {
18
+ * label: '...', // the button
19
+ * confirmText: '...', // the confirm dialog BODY
20
+ * successMessage: '...', // the success toast
21
+ * }
22
+ * ```
23
+ *
24
+ * `useObjectLabel()` has always exposed a resolver for each of them
25
+ * (`actionLabel` / `actionConfirm` / `actionSuccess`, one `resolve()` and one
26
+ * key convention behind all three). What drifted is the CALL SITES: some
27
+ * render surfaces called all three, others called only `actionLabel`. On those
28
+ * surfaces one bundle entry met two fates — the button rendered the
29
+ * translation while the confirm dialog rendered the authored English literal,
30
+ * which is exactly what objectui#4265 reports.
31
+ *
32
+ * So this is deliberately NOT a new resolution dialect. It is a single
33
+ * application of the existing resolvers over the three keys, so a surface can
34
+ * no longer localize one of them and forget the others: there is one function
35
+ * to call, and it can only do all three.
36
+ *
37
+ * ## Pinned semantics
38
+ *
39
+ * - **Fallback**: no bundle entry, or an entry lacking the key, renders the
40
+ * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
+ * here overrides it.
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.
44
+ * - **`confirmText` / `successMessage` are resolved only when the action
45
+ * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
+ * onto an action whose metadata never asked for one (nor a success toast).
47
+ * Translation localizes what exists; it does not add behaviour. This mirrors
48
+ * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
+ * `DeclaredActionsBar`) each spelled out by hand.
50
+ * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
+ * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
+ * collapsed to the active language BEFORE it is offered to the bundle as the
53
+ * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
+ * what "the authored label" is.
55
+ */
56
+ import { useMemo } from 'react';
57
+ import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n';
58
+ export function useActionTextLocalizer() {
59
+ const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
60
+ const { language } = useObjectTranslation();
61
+ return useMemo(() => {
62
+ return ((objectName, action, options) => {
63
+ if (!action || typeof action !== 'object')
64
+ return action;
65
+ // An `I18nLabel` map collapses to the active language here, so what the
66
+ // bundle receives as its fallback is always a plain string.
67
+ const authoredLabel = pickLocalized(action.label, language);
68
+ const name = typeof action.name === 'string' && action.name
69
+ ? action.name
70
+ : undefined;
71
+ const displayLabel = authoredLabel || name || options?.fallbackLabel || '';
72
+ // No name, no translation key: the literals are the answer. The label is
73
+ // still normalized (map to string, last-resort placeholder applied) so
74
+ // every caller reads one shape.
75
+ if (!name) {
76
+ return displayLabel === action.label ? action : { ...action, label: displayLabel };
77
+ }
78
+ const out = {
79
+ ...action,
80
+ label: actionLabel(objectName, name, displayLabel),
81
+ };
82
+ if (action.confirmText !== undefined) {
83
+ out.confirmText = actionConfirm(objectName, name, action.confirmText);
84
+ }
85
+ if (action.successMessage !== undefined) {
86
+ out.successMessage = actionSuccess(objectName, name, action.successMessage);
87
+ }
88
+ return out;
89
+ });
90
+ }, [actionLabel, actionConfirm, actionSuccess, language]);
91
+ }