@object-ui/react 17.6.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 (103) hide show
  1. package/CHANGELOG.md +4263 -0
  2. package/README.md +383 -42
  3. package/dist/SchemaRenderer.d.ts +64 -7
  4. package/dist/SchemaRenderer.js +1326 -106
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/AppShellContext.d.ts +27 -0
  7. package/dist/context/DiscussionContext.d.ts +27 -0
  8. package/dist/context/DiscussionContext.js +4 -0
  9. package/dist/context/DrillNavigationContext.d.ts +13 -0
  10. package/dist/context/InlineEditContext.d.ts +151 -1
  11. package/dist/context/InlineEditContext.js +36 -2
  12. package/dist/context/PrintPageContext.d.ts +60 -0
  13. package/dist/context/PrintPageContext.js +32 -0
  14. package/dist/context/RecordContext.d.ts +48 -5
  15. package/dist/context/RecordContext.js +10 -1
  16. package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
  17. package/dist/context/RelatedRecordActionsContext.js +4 -0
  18. package/dist/context/SchemaRendererContext.d.ts +61 -6
  19. package/dist/context/SchemaRendererContext.js +39 -6
  20. package/dist/context/ThemeContext.js +1 -1
  21. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  22. package/dist/context/actionRunnerTranslator.js +37 -0
  23. package/dist/context/index.d.ts +1 -0
  24. package/dist/context/index.js +1 -0
  25. package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
  26. package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
  27. package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
  28. package/dist/element-data-source/ElementDataSourceGate.js +401 -19
  29. package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
  30. package/dist/element-data-source/useElementApiDataSource.js +198 -0
  31. package/dist/hooks/index.d.ts +3 -1
  32. package/dist/hooks/index.js +10 -1
  33. package/dist/hooks/useActionEngine.js +46 -3
  34. package/dist/hooks/useActionRunner.js +4 -0
  35. package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
  36. package/dist/hooks/useActionTextLocalizer.js +70 -14
  37. package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
  38. package/dist/hooks/useConfigBagEvaluator.js +40 -0
  39. package/dist/hooks/useDatasetDimensionLabels.js +25 -10
  40. package/dist/hooks/useDensityMode.d.ts +8 -2
  41. package/dist/hooks/useDensityMode.js +8 -2
  42. package/dist/hooks/useDiscovery.d.ts +1 -10
  43. package/dist/hooks/useETagCache.js +25 -4
  44. package/dist/hooks/useElementDataSource.d.ts +15 -2
  45. package/dist/hooks/useElementDataSource.js +24 -7
  46. package/dist/hooks/useExpression.d.ts +52 -59
  47. package/dist/hooks/useExpression.js +58 -90
  48. package/dist/hooks/useFilterScope.d.ts +22 -2
  49. package/dist/hooks/useFilterScope.js +29 -4
  50. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  51. package/dist/hooks/useGlobalUndo.js +112 -46
  52. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  53. package/dist/hooks/useNavigationOverlay.js +119 -9
  54. package/dist/hooks/useOffline.js +65 -11
  55. package/dist/hooks/usePageAssignment.d.ts +33 -7
  56. package/dist/hooks/usePageAssignment.js +85 -21
  57. package/dist/hooks/useRecordSearch.d.ts +34 -1
  58. package/dist/hooks/useRecordSearch.js +42 -7
  59. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  60. package/dist/hooks/useResolvedFilter.js +130 -0
  61. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  62. package/dist/hooks/useSchemaPersistence.js +144 -5
  63. package/dist/hooks/useSettledSchema.d.ts +123 -0
  64. package/dist/hooks/useSettledSchema.js +186 -0
  65. package/dist/hooks/useViewData.d.ts +10 -3
  66. package/dist/hooks/useViewData.js +5 -14
  67. package/dist/index.d.ts +9 -1
  68. package/dist/index.js +48 -1
  69. package/dist/schema-input.d.ts +38 -8
  70. package/dist/schema-input.js +39 -7
  71. package/dist/utils/configBag.d.ts +68 -0
  72. package/dist/utils/configBag.js +70 -0
  73. package/dist/utils/error-message.d.ts +1 -1
  74. package/dist/utils/error-message.js +1 -1
  75. package/dist/utils/inlineAria.d.ts +61 -0
  76. package/dist/utils/inlineAria.js +70 -0
  77. package/dist/utils/localeMap.d.ts +27 -0
  78. package/dist/utils/localeMap.js +106 -0
  79. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  80. package/dist/utils/nonGridRowCeiling.js +78 -0
  81. package/dist/utils/paramsBag.d.ts +28 -0
  82. package/dist/utils/paramsBag.js +146 -0
  83. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  84. package/dist/utils/propsBagDiagnostic.js +422 -0
  85. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  86. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  87. package/dist/utils/unevaluatedExpression.d.ts +44 -1
  88. package/dist/utils/unevaluatedExpression.js +92 -11
  89. package/dist/utils/visibilityDiagnostic.d.ts +282 -21
  90. package/dist/utils/visibilityDiagnostic.js +321 -46
  91. package/package.json +10 -10
  92. package/dist/hooks/useClientNotifications.d.ts +0 -47
  93. package/dist/hooks/useClientNotifications.js +0 -162
  94. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  95. package/dist/spec-bridge/SpecBridge.js +0 -56
  96. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  97. package/dist/spec-bridge/bridges/form-view.js +0 -170
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -268
  100. package/dist/spec-bridge/index.d.ts +0 -11
  101. package/dist/spec-bridge/index.js +0 -10
  102. package/dist/spec-bridge/types.d.ts +0 -55
  103. package/dist/spec-bridge/types.js +0 -8
@@ -20,11 +20,31 @@ export declare function FilterScopeProvider({ currentUserId, currentOrgId, child
20
20
  children: ReactNode;
21
21
  }): import("react").FunctionComponentElement<import("react").ProviderProps<FilterTokenScope>>;
22
22
  /**
23
- * Read the session scope for filter placeholder resolution.
23
+ * Read the scope for filter placeholder resolution: the session scope the host
24
+ * mounted, plus the id of the record in view when there is one.
24
25
  *
25
26
  * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
26
27
  * `@object-ui/core` — that helper expands every placeholder vocabulary in one
27
28
  * call, which is the point: resolving only some of them is the defect behind
28
- * #3574.
29
+ * objectstack-ai/objectstack#3574.
30
+ *
31
+ * ## `{record_id}` reads the MOUNTED record (objectui#7297)
32
+ *
33
+ * `recordId` comes from the nearest `RecordContextProvider` — the record a
34
+ * `type: 'record'` page is showing, the same provider whose row a component's
35
+ * `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
36
+ * parameter, not a page variable, and not a prop of `FilterScopeProvider`,
37
+ * which stays session-only. Every data node that already resolves its filter
38
+ * through this hook therefore resolves `{record_id}` on a record page without
39
+ * a line of its own, and refuses it by name everywhere else (a list view, a
40
+ * dashboard, a page that is not a record page), because there the member is
41
+ * simply absent.
42
+ *
43
+ * Outside a record context the session scope is handed back as the very
44
+ * object the provider published, so nothing changes for a surface that shows
45
+ * no record. Inside one, the combined scope is rebuilt only when the session
46
+ * scope or the record id changes. A consumer that holds a resolution compares
47
+ * the members one by one — `recordId` among them — never this object's
48
+ * identity (AGENTS.md #10).
29
49
  */
30
50
  export declare function useFilterScope(): FilterTokenScope;
@@ -6,9 +6,12 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
+ import { useRecordContext } from '../context/RecordContext.js';
9
10
  /**
10
11
  * The session values filter placeholders resolve against — `{current_user_id}`
11
- * and `{current_org_id}` (framework #3574).
12
+ * and `{current_org_id}` (objectstack-ai/objectstack#3574). The record-scoped
13
+ * `{record_id}` is not a session value and is not provided here: see
14
+ * {@link useFilterScope}.
12
15
  *
13
16
  * ## Why a dedicated context
14
17
  *
@@ -43,13 +46,35 @@ export function FilterScopeProvider({ currentUserId, currentOrgId, children, })
43
46
  return createElement(FilterScopeContext.Provider, { value }, children);
44
47
  }
45
48
  /**
46
- * Read the session scope for filter placeholder resolution.
49
+ * Read the scope for filter placeholder resolution: the session scope the host
50
+ * mounted, plus the id of the record in view when there is one.
47
51
  *
48
52
  * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
49
53
  * `@object-ui/core` — that helper expands every placeholder vocabulary in one
50
54
  * call, which is the point: resolving only some of them is the defect behind
51
- * #3574.
55
+ * objectstack-ai/objectstack#3574.
56
+ *
57
+ * ## `{record_id}` reads the MOUNTED record (objectui#7297)
58
+ *
59
+ * `recordId` comes from the nearest `RecordContextProvider` — the record a
60
+ * `type: 'record'` page is showing, the same provider whose row a component's
61
+ * `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
62
+ * parameter, not a page variable, and not a prop of `FilterScopeProvider`,
63
+ * which stays session-only. Every data node that already resolves its filter
64
+ * through this hook therefore resolves `{record_id}` on a record page without
65
+ * a line of its own, and refuses it by name everywhere else (a list view, a
66
+ * dashboard, a page that is not a record page), because there the member is
67
+ * simply absent.
68
+ *
69
+ * Outside a record context the session scope is handed back as the very
70
+ * object the provider published, so nothing changes for a surface that shows
71
+ * no record. Inside one, the combined scope is rebuilt only when the session
72
+ * scope or the record id changes. A consumer that holds a resolution compares
73
+ * the members one by one — `recordId` among them — never this object's
74
+ * identity (AGENTS.md #10).
52
75
  */
53
76
  export function useFilterScope() {
54
- return useContext(FilterScopeContext);
77
+ const session = useContext(FilterScopeContext);
78
+ const recordId = useRecordContext()?.recordId;
79
+ return useMemo(() => (recordId == null ? session : { ...session, recordId }), [session, recordId]);
55
80
  }
@@ -23,6 +23,16 @@ export interface UseGlobalUndoOptions {
23
23
  *
24
24
  * Provides reactive undo/redo state, executes data operations through the
25
25
  * supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
26
+ *
27
+ * Keyboard: however many instances are mounted, one `window` `keydown`
28
+ * listener serves them all and one keypress pops at most one operation. It is
29
+ * answered by the longest-mounted live instance — its `dataSource` runs the
30
+ * write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
31
+ * target is an `input`, `textarea`, `select` or contenteditable region is left
32
+ * to that field: nothing is popped and the default is not prevented.
33
+ *
34
+ * The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
35
+ * THIS instance's `dataSource` and callbacks.
26
36
  */
27
37
  export declare function useGlobalUndo(options?: UseGlobalUndoOptions): {
28
38
  undo: () => Promise<void>;
@@ -5,7 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
8
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
9
9
  import { globalUndoManager } from '@object-ui/core';
10
10
  function getSnapshot() {
11
11
  return {
@@ -28,59 +28,125 @@ function subscribe(callback) {
28
28
  });
29
29
  }
30
30
  function getCachedSnapshot() { return cachedSnapshot; }
31
+ async function executeOp(optionsRef, op, data, mode) {
32
+ const ds = optionsRef.current.dataSource;
33
+ if (!ds)
34
+ return;
35
+ const action = mode === 'undo'
36
+ ? { create: 'delete', update: 'update', delete: 'create' }[op.type]
37
+ : op.type;
38
+ if (action === 'delete')
39
+ await ds.delete(op.objectName, op.recordId);
40
+ else if (action === 'update')
41
+ await ds.update(op.objectName, op.recordId, data);
42
+ else
43
+ await ds.create(op.objectName, data);
44
+ }
45
+ async function runUndo(optionsRef) {
46
+ const op = globalUndoManager.popUndo();
47
+ if (!op)
48
+ return;
49
+ await executeOp(optionsRef, op, op.undoData, 'undo');
50
+ optionsRef.current.onUndo?.(op);
51
+ }
52
+ async function runRedo(optionsRef) {
53
+ const op = globalUndoManager.popRedo();
54
+ if (!op)
55
+ return;
56
+ await executeOp(optionsRef, op, op.redoData, 'redo');
57
+ optionsRef.current.onRedo?.(op);
58
+ }
59
+ // ── Keyboard shortcuts: ONE listener for the one shared stack (objectui#11081) ──
60
+ //
61
+ // Every instance used to add its own `window` `keydown` listener, and each of
62
+ // them popped `globalUndoManager`, so one Ctrl+Z undid one operation PER
63
+ // MOUNTED INSTANCE (the console mounts several at once). Now the instances
64
+ // register here and a single module-level listener answers each keypress,
65
+ // popping at most one operation.
66
+ //
67
+ // Which instance answers is decided once: the longest-mounted live one, the
68
+ // first entry of this insertion-ordered set. An `UndoableOperation` carries
69
+ // nothing that names the instance whose runner pushed it, so "the owner of the
70
+ // operation" is not readable here. The oldest live instance is also exactly
71
+ // the one that answered a keypress before this change whenever a single
72
+ // operation was stacked (its listener was first on `window` and popped it; the
73
+ // rest found the stack empty), so the one-operation keypress behaves as it
74
+ // always did. When it unmounts, the next-oldest takes over.
75
+ const keyboardInstances = new Set();
76
+ /** A field whose own Ctrl+Z (the browser's text undo) must not be taken over. */
77
+ function isEditableTarget(target) {
78
+ const el = target;
79
+ if (!el || typeof el.tagName !== 'string')
80
+ return false;
81
+ if (el.isContentEditable)
82
+ return true;
83
+ const tag = el.tagName.toUpperCase();
84
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
85
+ }
86
+ function onUndoShortcut(e) {
87
+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
88
+ return;
89
+ // Inside a text field Ctrl+Z is the field's own undo: no pop, no preventDefault.
90
+ if (isEditableTarget(e.target))
91
+ return;
92
+ const owner = keyboardInstances.values().next().value;
93
+ if (!owner)
94
+ return;
95
+ e.preventDefault();
96
+ if (e.shiftKey) {
97
+ void runRedo(owner);
98
+ }
99
+ else {
100
+ void runUndo(owner);
101
+ }
102
+ }
103
+ function registerKeyboardInstance(optionsRef) {
104
+ keyboardInstances.add(optionsRef);
105
+ if (keyboardInstances.size === 1)
106
+ window.addEventListener('keydown', onUndoShortcut);
107
+ return () => {
108
+ keyboardInstances.delete(optionsRef);
109
+ if (keyboardInstances.size === 0)
110
+ window.removeEventListener('keydown', onUndoShortcut);
111
+ };
112
+ }
31
113
  /**
32
114
  * React hook that wraps the global UndoManager for use in console components.
33
115
  *
34
116
  * Provides reactive undo/redo state, executes data operations through the
35
117
  * supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
118
+ *
119
+ * Keyboard: however many instances are mounted, one `window` `keydown`
120
+ * listener serves them all and one keypress pops at most one operation. It is
121
+ * answered by the longest-mounted live instance — its `dataSource` runs the
122
+ * write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
123
+ * target is an `input`, `textarea`, `select` or contenteditable region is left
124
+ * to that field: nothing is popped and the default is not prevented.
125
+ *
126
+ * The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
127
+ * THIS instance's `dataSource` and callbacks.
36
128
  */
37
129
  export function useGlobalUndo(options = {}) {
38
130
  const optionsRef = useRef(options);
39
- optionsRef.current = options;
131
+ // Refreshed in the MUTATION phase, never in the render body. Every caller
132
+ // passes an inline object literal with inline `onUndo` / `onRedo` closures,
133
+ // so `options` is a new object on every render; `undo` / `redo` and the
134
+ // keyboard registration hold the ref (whose identity React does keep) and
135
+ // still reach the newest callbacks. Writing it during render published the
136
+ // options of renders React discards or replays.
137
+ // `useInsertionEffect` runs ahead of every layout effect, ref attachment and
138
+ // paint in the commit, so the only window this moves is the render phase
139
+ // itself — where `undo` / `redo`, being async data mutations, are not
140
+ // callable. `useEffectEvent` would be idiomatic but is React 19.2+, and this
141
+ // package's peer range starts at React 18.
142
+ useInsertionEffect(() => {
143
+ optionsRef.current = options;
144
+ });
40
145
  const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
41
- const executeOp = useCallback(async (op, data, mode) => {
42
- const ds = optionsRef.current.dataSource;
43
- if (!ds)
44
- return;
45
- const action = mode === 'undo'
46
- ? { create: 'delete', update: 'update', delete: 'create' }[op.type]
47
- : op.type;
48
- if (action === 'delete')
49
- await ds.delete(op.objectName, op.recordId);
50
- else if (action === 'update')
51
- await ds.update(op.objectName, op.recordId, data);
52
- else
53
- await ds.create(op.objectName, data);
54
- }, []);
55
- const undo = useCallback(async () => {
56
- const op = globalUndoManager.popUndo();
57
- if (!op)
58
- return;
59
- await executeOp(op, op.undoData, 'undo');
60
- optionsRef.current.onUndo?.(op);
61
- }, [executeOp]);
62
- const redo = useCallback(async () => {
63
- const op = globalUndoManager.popRedo();
64
- if (!op)
65
- return;
66
- await executeOp(op, op.redoData, 'redo');
67
- optionsRef.current.onRedo?.(op);
68
- }, [executeOp]);
69
- // Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
70
- useEffect(() => {
71
- const handler = (e) => {
72
- if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
73
- return;
74
- e.preventDefault();
75
- if (e.shiftKey) {
76
- void redo();
77
- }
78
- else {
79
- void undo();
80
- }
81
- };
82
- window.addEventListener('keydown', handler);
83
- return () => window.removeEventListener('keydown', handler);
84
- }, [undo, redo]);
146
+ const undo = useCallback(() => runUndo(optionsRef), []);
147
+ const redo = useCallback(() => runRedo(optionsRef), []);
148
+ // Registered once per mount, keyed on the ref and on no memoised identity, so
149
+ // this instance's place in the keyboard order is its mount order.
150
+ useEffect(() => registerKeyboardInstance(optionsRef), []);
85
151
  return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
86
152
  }
@@ -6,6 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
9
+ import type { RecordNavigateAction } from '@object-ui/types';
9
10
  import type { SpecAuthoredInput } from '../spec-input.js';
10
11
  /**
11
12
  * The spec's `NavigationConfigSchema`, authoring side — by reference, with no
@@ -22,8 +23,10 @@ import type { SpecAuthoredInput } from '../spec-input.js';
22
23
  * `mode` is OPTIONAL, because the spec says so and this hook agrees.
23
24
  * `packages/spec/src/ui/view.zod.ts` declares
24
25
  * `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the
25
- * AUTHORING side as `| undefined` — so `navigation: { view: 'summary_view' }`
26
- * is legal authored metadata that lets the mode default.
26
+ * AUTHORING side as `| undefined` — so `navigation: { size: 'lg' }` is legal
27
+ * authored metadata that lets the mode default. ⛔ This sentence used to make
28
+ * the same point with a `view` member; see "The RETIRED `view` key" below for
29
+ * why no example in this file may spell that key again.
27
30
  *
28
31
  * Until objectui#4550 this alias `Omit`ted `mode` and re-added it as
29
32
  * `NonNullable<…>`, on the stated reasoning that "this hook dispatches on
@@ -51,9 +54,9 @@ import type { SpecAuthoredInput } from '../spec-input.js';
51
54
  *
52
55
  * Two per-key notes the hand copy carried, kept here because a derived alias
53
56
  * has no members to hang them on:
54
- * - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
57
+ * - `size` is the coarse overlay bucket added by objectstack-ai/objectstack#2578; `resolveOverlayWidth`
55
58
  * below maps it through {@link OVERLAY_SIZE_WIDTHS}.
56
- * - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
59
+ * - `width` is DEPRECATED by objectstack-ai/objectstack#2578 in favour of `size`. It still wins when
57
60
  * present, because app-shell pre-resolves `size` into it.
58
61
  */
59
62
  export type NavigationConfig = SpecAuthoredInput<typeof NavigationConfigSchema>;
@@ -95,10 +98,50 @@ export interface UseNavigationOverlayOptions {
95
98
  navigation?: NavigationConfig;
96
99
  /** Object name — used to build default URLs for page/new_window modes */
97
100
  objectName?: string;
98
- /** External onNavigate callback (e.g., from ActionProvider or parent) */
99
- onNavigate?: (recordId: string | number, action?: string) => void;
100
- /** External onRowClick callback — if set, takes full priority */
101
- onRowClick?: (record: Record<string, unknown>) => void;
101
+ /**
102
+ * Called for page-level navigation with the record id and a
103
+ * {@link RecordNavigateAction} — a navigation-MODE token from a closed
104
+ * vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
105
+ * new browser tab.
106
+ *
107
+ * `handleClick` below is the one producer of that token, and every call it
108
+ * makes is typed against this declaration — so a third token is a compile
109
+ * error here, not a branch a host's handler silently never matches
110
+ * (objectui#9547). Supplied by the host that renders the list (a
111
+ * `schema.onNavigate`, a component prop, or the host's own router closure).
112
+ *
113
+ * When it is absent, an authored `page` click goes to the record navigator
114
+ * the host publishes on `RelatedRecordActionsContext` instead
115
+ * (objectui#11293; see the module docblock). Supplied, it wins.
116
+ */
117
+ onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
118
+ /**
119
+ * External onRowClick callback — if set, takes full priority.
120
+ *
121
+ * Declares BOTH arguments `handleClick` hands it: the record, and the
122
+ * optional modifier payload (`HandleClickModifiers`, declared just below)
123
+ * that lets a host implement Cmd/Ctrl/middle-click for itself. The second
124
+ * argument was always delivered; until objectui#9357 this line declared only
125
+ * the first and `handleClick` asserted the declaration away in order to make
126
+ * the call — so the payload was invisible on the one line a host reads, and
127
+ * every consumer that passes its own handler through copied the understated
128
+ * spelling outward.
129
+ *
130
+ * A one-parameter handler stays assignable here, and so does one that leaves
131
+ * its second parameter unannotated. The exception, and the one class that has
132
+ * to change: a handler whose second parameter is annotated NARROWER than
133
+ * `HandleClickModifiers` — React's `MouseEvent` is the shape this hits in
134
+ * practice, because the payload used to be discoverable only from the
135
+ * implementation — is refused from this card on with TS2322. The parameter is
136
+ * checked contravariantly, so the annotation has to ADMIT
137
+ * `HandleClickModifiers`. The fix is one line at that call site: annotate the
138
+ * parameter `HandleClickModifiers` (exported from this module, and from
139
+ * `@object-ui/react`), or drop the annotation and let it be inferred; either
140
+ * way the handler keeps receiving exactly what it received before. What the
141
+ * widening buys everyone else is that a caller who WANTS the payload can now
142
+ * see, from the published type, that it exists.
143
+ */
144
+ onRowClick?: (record: Record<string, unknown>, event?: HandleClickModifiers) => void;
102
145
  }
103
146
  /**
104
147
  * Optional event-like payload accepted by `handleClick`. We don't depend on
@@ -139,18 +182,40 @@ export interface NavigationOverlayState {
139
182
  width: string | number | undefined;
140
183
  /** Whether navigation is an overlay mode (drawer/modal/split/popover) */
141
184
  isOverlay: boolean;
142
- /** The target view/form name from NavigationConfig */
143
- view: string | undefined;
144
185
  }
145
186
  /**
146
187
  * Hook for NavigationConfig-driven navigation overlay.
147
188
  *
189
+ * ## What to hand `objectName`
190
+ *
191
+ * The block's RECORD SOURCE — the object the clicked rows actually came from —
192
+ * and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
193
+ * published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
194
+ * then `staticData`, then `objectName`), not as a parallel "page object"
195
+ * concept, so a block has exactly ONE record source. `handleClick` below builds
196
+ * the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
197
+ * a caller that hands it the top-level key while its rows came from
198
+ * `data.object` navigates to a record that the URL's own object does not
199
+ * contain (`2ce2612df`).
200
+ *
201
+ * A caller that resolves a data config reads that ladder through the ONE shared
202
+ * reader — `resolveRecordSourceObjectName` from `@object-ui/core`
203
+ * (`b041b9c0c`) — as the example does. A caller with NO data config has
204
+ * nothing above rung three, and its `schema.objectName` already IS its record
205
+ * source; that spelling is correct there and needs no conversion.
206
+ *
148
207
  * @example
149
208
  * ```tsx
209
+ * import {
210
+ * resolveRecordSourceConfig,
211
+ * resolveRecordSourceObjectName,
212
+ * } from '@object-ui/core';
213
+ *
214
+ * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
150
215
  * const { handleClick, isOpen, selectedRecord, mode, close, width } =
151
216
  * useNavigationOverlay({
152
217
  * navigation: schema.navigation,
153
- * objectName: schema.objectName,
218
+ * objectName: resolveRecordSourceObjectName(schema, dataConfig),
154
219
  * onNavigate: schema.onNavigate,
155
220
  * onRowClick: props.onRowClick,
156
221
  * });
@@ -14,8 +14,80 @@
14
14
  *
15
15
  * Used by plugin-grid, plugin-list, plugin-detail and any component that needs
16
16
  * NavigationConfig support.
17
+ *
18
+ * ## The RETIRED `view` key (objectui#9874)
19
+ *
20
+ * `view.list.navigation.view` was removed in `@objectstack/spec` 17.5.0 under
21
+ * ADR-0049 (enforce-or-remove). This hook was its one shipped consumer, and the
22
+ * spec's own tombstone describes THIS file: the authored value "was passed
23
+ * straight into the navigation-MODE argument of the console's `onNavigate`,
24
+ * where anything other than `edit` or `view` matched no branch, so the key
25
+ * selected nothing and could silently deaden the row click."
26
+ *
27
+ * ⭐ The point that is one character wide in the source and total in meaning:
28
+ * `onNavigate`'s second argument is the navigation MODE token, not a view name.
29
+ * An authored `view` did not SELECT a view, it SUBSTITUTED for the mode — so
30
+ * `{ mode: 'page', view: 'summary_view' }` dispatched `'summary_view'` into the
31
+ * slot a host reads against its closed `edit`/`view` vocabulary, matching
32
+ * neither branch. Two reads (`view ?? 'view'`, in the no-config and the `page`
33
+ * branch) are now the literal `'view'`, which is what every config without the
34
+ * key already dispatched and what every config dispatches now.
35
+ *
36
+ * The replacement route is NOT in this hook and no fallback belongs here. Per
37
+ * the same tombstone: to choose what opens for a record, assign a `record` page
38
+ * to the object and let `isDefault` pick the one that opens — page assignment
39
+ * is the machinery that resolves a detail layout, and a list view's
40
+ * `navigation` block only decides HOW the detail is surfaced (`mode`, `size`).
41
+ *
42
+ * ⚠️ `NavigationOverlayState` lost its `view` member with the read. It could
43
+ * only ever carry the retired key, so keeping it would publish a field that is
44
+ * permanently `undefined` — the same "declared, consumed, and wrong" state the
45
+ * retirement exists to end. Measured before removing it: zero readers of that
46
+ * member in this repository (`(navOverlay|nav|state).view` → no lines, against
47
+ * a lit control on its sibling members in the same command). Hosts outside this
48
+ * repository were NOT measured.
49
+ *
50
+ * ## `page` with no `onNavigate`: the HOST's record navigator (objectui#11293)
51
+ *
52
+ * `page` is the spec's default `mode`, so it is what `{ mode: 'page' }` and
53
+ * every authored block written without `mode` (`{ size: 'lg' }`) resolve to.
54
+ * The branch used to call `onNavigate` and nothing else, and a host that
55
+ * renders a block without wiring one — a board, a calendar, a gallery or a
56
+ * grid placed on a page as a standalone block — has no `onNavigate`: the
57
+ * click did nothing at all. Measured before the change: a standalone
58
+ * `object-kanban` / `object-calendar` with either spelling opened no overlay,
59
+ * called no navigation and no `window.open`, and a standalone `object-grid`
60
+ * did the same, because the grid's own `onNavigate` is host-supplied too.
61
+ *
62
+ * So when no `onNavigate` is supplied, a `page` click is handed to the record
63
+ * navigator the host PUBLISHES on `RelatedRecordActionsContext`
64
+ * (`openRecord(objectName, recordId)`). That is the seam the grid's link
65
+ * column and the lookup cells already read: the route is built by the host
66
+ * that owns it and never re-derived here, which is why this file still builds
67
+ * no record URL for `page`. Precedence is unchanged above it: an `onRowClick`
68
+ * takes the click first, and a supplied `onNavigate` wins over the published
69
+ * navigator, so every host that wires one (the grid's, the console's list
70
+ * views) behaves exactly as before. A host that publishes no navigator (the
71
+ * Studio preview, a bare React mount) has no record page to open, and the
72
+ * click still opens nothing: the context's documented absent state.
73
+ *
74
+ * ⚠️ Scoped to an AUTHORED block that resolves to `page`. An absent
75
+ * `navigation` stays the host's call (`onNavigate`, or nothing), and that is
76
+ * load-bearing rather than cautious: a host WITHHOLDS navigation by passing
77
+ * neither callback. `RelatedList` renders its mobile rows as an
78
+ * `object-gallery` with no `navigation` and an `onRowClick` that is undefined
79
+ * when the bridge withheld `onView`; reading the published navigator on the
80
+ * absent-key path would open those records anyway, under the very bridge that
81
+ * withheld them. So `{}` and no config still agree whenever an `onNavigate`
82
+ * is supplied, and part only where a host published a navigator but wired no
83
+ * callback. The kanban, calendar and gantt default an absent key to a drawer
84
+ * before it reaches this hook in any case. Modifier clicks and `new_window`
85
+ * are untouched as well. Pinned by
86
+ * `__tests__/useNavigationOverlay.hostRecordNavigator-11293.test.tsx` here and
87
+ * at the mounts by the kanban's and calendar's `NavigationMembers-8652` pins.
17
88
  */
18
89
  import { useState, useCallback, useMemo } from 'react';
90
+ import { useRelatedRecordActions } from '../context/RelatedRecordActionsContext.js';
19
91
  /**
20
92
  * Pixel cap per overlay `size` bucket, clamped to the viewport at render —
21
93
  * mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns
@@ -53,12 +125,36 @@ export function resolveOverlayWidth(navigation) {
53
125
  /**
54
126
  * Hook for NavigationConfig-driven navigation overlay.
55
127
  *
128
+ * ## What to hand `objectName`
129
+ *
130
+ * The block's RECORD SOURCE — the object the clicked rows actually came from —
131
+ * and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
132
+ * published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
133
+ * then `staticData`, then `objectName`), not as a parallel "page object"
134
+ * concept, so a block has exactly ONE record source. `handleClick` below builds
135
+ * the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
136
+ * a caller that hands it the top-level key while its rows came from
137
+ * `data.object` navigates to a record that the URL's own object does not
138
+ * contain (`2ce2612df`).
139
+ *
140
+ * A caller that resolves a data config reads that ladder through the ONE shared
141
+ * reader — `resolveRecordSourceObjectName` from `@object-ui/core`
142
+ * (`b041b9c0c`) — as the example does. A caller with NO data config has
143
+ * nothing above rung three, and its `schema.objectName` already IS its record
144
+ * source; that spelling is correct there and needs no conversion.
145
+ *
56
146
  * @example
57
147
  * ```tsx
148
+ * import {
149
+ * resolveRecordSourceConfig,
150
+ * resolveRecordSourceObjectName,
151
+ * } from '@object-ui/core';
152
+ *
153
+ * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
58
154
  * const { handleClick, isOpen, selectedRecord, mode, close, width } =
59
155
  * useNavigationOverlay({
60
156
  * navigation: schema.navigation,
61
- * objectName: schema.objectName,
157
+ * objectName: resolveRecordSourceObjectName(schema, dataConfig),
62
158
  * onNavigate: schema.onNavigate,
63
159
  * onRowClick: props.onRowClick,
64
160
  * });
@@ -79,11 +175,13 @@ export function resolveOverlayWidth(navigation) {
79
175
  */
80
176
  export function useNavigationOverlay(options) {
81
177
  const { navigation, objectName, onNavigate, onRowClick } = options;
178
+ // The host's published record navigator — read for the `page` branch only,
179
+ // and only when no `onNavigate` was supplied (objectui#11293).
180
+ const openRecord = useRelatedRecordActions()?.openRecord;
82
181
  const [isOpen, setIsOpen] = useState(false);
83
182
  const [selectedRecord, setSelectedRecord] = useState(null);
84
183
  const mode = navigation?.mode ?? 'page';
85
184
  const width = resolveOverlayWidth(navigation);
86
- const view = navigation?.view;
87
185
  const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
88
186
  const close = useCallback(() => {
89
187
  setIsOpen(false);
@@ -97,6 +195,12 @@ export function useNavigationOverlay(options) {
97
195
  // External onRowClick takes full priority. Forward the modifier event
98
196
  // so parent handlers (e.g. ObjectView) can still implement Cmd/Ctrl/
99
197
  // middle-click → open in new tab.
198
+ //
199
+ // Called straight through the declaration (objectui#9357). This used to
200
+ // carry a type assertion widening the option to two parameters at the
201
+ // call — the producer paying for its own understated declaration, which
202
+ // AGENTS.md #0.1 sends back to the producer. The producer is this file,
203
+ // and the option above now declares what this line passes.
100
204
  if (onRowClick) {
101
205
  onRowClick(record, event);
102
206
  return;
@@ -117,7 +221,7 @@ export function useNavigationOverlay(options) {
117
221
  if (!navigation) {
118
222
  const recordId = record.id || record._id;
119
223
  if (onNavigate && recordId != null) {
120
- onNavigate(recordId, view ?? 'view');
224
+ onNavigate(recordId, 'view');
121
225
  }
122
226
  return;
123
227
  }
@@ -143,11 +247,18 @@ export function useNavigationOverlay(options) {
143
247
  window.open(url, '_blank');
144
248
  return;
145
249
  }
146
- // page — delegate to onNavigate callback
250
+ // page — delegate to onNavigate; with none supplied, to the record
251
+ // navigator the host publishes (objectui#11293). No URL is built here.
147
252
  if (mode === 'page') {
148
253
  const recordId = record.id || record._id;
149
- if (onNavigate && recordId != null) {
150
- onNavigate(recordId, view ?? 'view');
254
+ if (recordId == null)
255
+ return;
256
+ if (onNavigate) {
257
+ onNavigate(recordId, 'view');
258
+ return;
259
+ }
260
+ if (objectName && openRecord) {
261
+ openRecord(objectName, recordId);
151
262
  }
152
263
  return;
153
264
  }
@@ -157,7 +268,7 @@ export function useNavigationOverlay(options) {
157
268
  setIsOpen(true);
158
269
  return;
159
270
  }
160
- }, [onRowClick, navigation, mode, objectName, onNavigate, isOverlay, view]);
271
+ }, [onRowClick, navigation, mode, objectName, onNavigate, openRecord, isOverlay]);
161
272
  return useMemo(() => ({
162
273
  isOpen,
163
274
  selectedRecord,
@@ -167,7 +278,6 @@ export function useNavigationOverlay(options) {
167
278
  setIsOpen,
168
279
  handleClick,
169
280
  width,
170
- view,
171
281
  isOverlay,
172
- }), [isOpen, selectedRecord, mode, close, open, handleClick, width, view, isOverlay]);
282
+ }), [isOpen, selectedRecord, mode, close, open, handleClick, width, isOverlay]);
173
283
  }