@object-ui/react 17.5.0 → 17.7.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 +4100 -0
  2. package/README.md +303 -46
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.d.ts +38 -7
  6. package/dist/SchemaRenderer.js +1603 -60
  7. package/dist/context/ActionContext.js +40 -1
  8. package/dist/context/DiscussionContext.d.ts +12 -0
  9. package/dist/context/DiscussionContext.js +2 -0
  10. package/dist/context/DrillNavigationContext.d.ts +13 -0
  11. package/dist/context/HostNavigationContext.d.ts +82 -0
  12. package/dist/context/HostNavigationContext.js +65 -0
  13. package/dist/context/InlineEditContext.d.ts +81 -1
  14. package/dist/context/InlineEditContext.js +8 -2
  15. package/dist/context/RecordContext.d.ts +48 -5
  16. package/dist/context/RecordContext.js +10 -1
  17. package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
  18. package/dist/context/SchemaRendererContext.d.ts +54 -2
  19. package/dist/context/SchemaRendererContext.js +38 -5
  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 +13 -12
  24. package/dist/context/index.js +13 -12
  25. package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
  26. package/dist/element-data-source/ElementDataSourceGate.js +407 -15
  27. package/dist/hooks/index.d.ts +37 -35
  28. package/dist/hooks/index.js +44 -35
  29. package/dist/hooks/useActionEngine.js +47 -4
  30. package/dist/hooks/useActionRunner.js +4 -0
  31. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  32. package/dist/hooks/useActionTextLocalizer.js +33 -8
  33. package/dist/hooks/useCapabilityGate.js +6 -5
  34. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  35. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  36. package/dist/hooks/useCrudShortcuts.js +1 -1
  37. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  38. package/dist/hooks/useDensityMode.d.ts +6 -2
  39. package/dist/hooks/useDensityMode.js +6 -2
  40. package/dist/hooks/useDiscovery.d.ts +1 -10
  41. package/dist/hooks/useDiscovery.js +1 -1
  42. package/dist/hooks/useETagCache.js +25 -4
  43. package/dist/hooks/useElementDataSource.d.ts +15 -2
  44. package/dist/hooks/useElementDataSource.js +29 -9
  45. package/dist/hooks/useExpression.d.ts +52 -38
  46. package/dist/hooks/useExpression.js +62 -42
  47. package/dist/hooks/useFilterScope.d.ts +22 -2
  48. package/dist/hooks/useFilterScope.js +29 -4
  49. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  50. package/dist/hooks/useGlobalUndo.js +112 -46
  51. package/dist/hooks/useNavigationOverlay.d.ts +77 -12
  52. package/dist/hooks/useNavigationOverlay.js +119 -9
  53. package/dist/hooks/useOffline.js +65 -11
  54. package/dist/hooks/usePageAssignment.d.ts +8 -5
  55. package/dist/hooks/usePageAssignment.js +38 -17
  56. package/dist/hooks/usePageTransition.js +1 -1
  57. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  58. package/dist/hooks/useRecordSearch.d.ts +34 -1
  59. package/dist/hooks/useRecordSearch.js +42 -7
  60. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  61. package/dist/hooks/useResolvedFilter.js +130 -0
  62. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  63. package/dist/hooks/useSchemaPersistence.js +144 -5
  64. package/dist/hooks/useSettledSchema.d.ts +123 -0
  65. package/dist/hooks/useSettledSchema.js +186 -0
  66. package/dist/hooks/useViewData.d.ts +7 -2
  67. package/dist/hooks/useViewData.js +1 -1
  68. package/dist/hooks/useViewTransition.js +1 -1
  69. package/dist/index.d.ts +18 -11
  70. package/dist/index.js +54 -10
  71. package/dist/schema-input.d.ts +55 -14
  72. package/dist/schema-input.js +55 -12
  73. package/dist/utils/configBag.d.ts +68 -0
  74. package/dist/utils/configBag.js +70 -0
  75. package/dist/utils/error-message.d.ts +59 -0
  76. package/dist/utils/error-message.js +116 -1
  77. package/dist/utils/inlineAria.d.ts +61 -0
  78. package/dist/utils/inlineAria.js +70 -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 +421 -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 +110 -0
  88. package/dist/utils/unevaluatedExpression.js +257 -0
  89. package/dist/utils/visibilityDiagnostic.d.ts +371 -0
  90. package/dist/utils/visibilityDiagnostic.js +551 -0
  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 -82
  97. package/dist/spec-bridge/bridges/form-view.js +0 -126
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -241
  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
@@ -6,7 +6,8 @@
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 { SpecAuthoredInput } from '../spec-input';
9
+ import type { RecordNavigateAction } from '@object-ui/types';
10
+ import type { SpecAuthoredInput } from '../spec-input.js';
10
11
  /**
11
12
  * The spec's `NavigationConfigSchema`, authoring side — by reference, with no
12
13
  * divergence of its own.
@@ -22,8 +23,10 @@ import type { SpecAuthoredInput } from '../spec-input';
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';
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
  }
@@ -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, useState, useSyncExternalStore } from 'react';
8
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
9
9
  // ---------------------------------------------------------------------------
10
10
  // Online/offline external store (SSR-safe)
11
11
  // ---------------------------------------------------------------------------
@@ -93,7 +93,31 @@ export function useOffline(config = {}) {
93
93
  const [queue, setQueue] = useState(loadQueue);
94
94
  const [syncState, setSyncState] = useState('idle');
95
95
  const syncConfigRef = useRef(syncConfig);
96
- syncConfigRef.current = syncConfig;
96
+ // `sync` below is keyed on `[enabled, queue]`, so a config-only change keeps
97
+ // the SAME `sync` closure alive — and the auto-sync effect deliberately
98
+ // captures one and fires it 100ms later. The ref is how those retained
99
+ // closures still read the newest `sync` config; putting `syncConfig` in
100
+ // `sync`'s deps instead would rebuild `sync` (and the memoised result) on
101
+ // every render, because callers pass `sync: { ... }` as an inline literal.
102
+ // Only the write moves out of the render body: `useInsertionEffect` runs in
103
+ // the mutation phase, ahead of every layout effect and paint, so no caller
104
+ // that could legally reach `sync()` observes a different value than before.
105
+ useInsertionEffect(() => {
106
+ syncConfigRef.current = syncConfig;
107
+ });
108
+ // The queue reaches `sync` through the SAME commit-phase mirror the sync
109
+ // config uses (objectui#6818). It used to come from `sync`'s own closure
110
+ // while `batchSize` came from the ref above, so the two halves of one call
111
+ // disagreed about how current they were: a retained `sync` — and the
112
+ // auto-sync effect below deliberately retains one and fires it 100ms later —
113
+ // batched a queue snapshot from an older render against the newest
114
+ // `batchSize`. Mirroring the queue makes both halves the newest COMMITTED
115
+ // value, and it is what takes `queue` out of `sync`'s dependency list, so
116
+ // `sync` stops changing identity on every queued mutation.
117
+ const queueRef = useRef(queue);
118
+ useInsertionEffect(() => {
119
+ queueRef.current = queue;
120
+ });
97
121
  // Persist queue to localStorage whenever it changes
98
122
  useEffect(() => {
99
123
  persistQueue(queue);
@@ -130,14 +154,16 @@ export function useOffline(config = {}) {
130
154
  setQueue([]);
131
155
  }, []);
132
156
  const sync = useCallback(async () => {
133
- if (!enabled || queue.length === 0)
157
+ // Newest committed queue, not this closure's snapshot — see `queueRef`.
158
+ const pending = queueRef.current;
159
+ if (!enabled || pending.length === 0)
134
160
  return;
135
161
  setSyncState('syncing');
136
162
  try {
137
163
  // In a real implementation, this would batch-send mutations to the server.
138
164
  // For now, we simulate a successful sync by clearing the queue.
139
- const batchSize = syncConfigRef.current?.batchSize ?? queue.length;
140
- const batch = queue.slice(0, batchSize);
165
+ const batchSize = syncConfigRef.current?.batchSize ?? pending.length;
166
+ const batch = pending.slice(0, batchSize);
141
167
  // Simulate network round-trip
142
168
  await new Promise((resolve) => setTimeout(resolve, 0));
143
169
  // Remove synced mutations from queue
@@ -147,18 +173,46 @@ export function useOffline(config = {}) {
147
173
  catch {
148
174
  setSyncState('error');
149
175
  }
150
- }, [enabled, queue]);
151
- // Auto-sync when coming back online (short stabilization delay)
176
+ }, [enabled]);
177
+ // Auto-sync while online and holding queued mutations (short stabilization
178
+ // delay).
179
+ //
180
+ // The dependency list used to be `[isOnline, enabled]` with
181
+ // `react-hooks/exhaustive-deps` suppressed, on the stated grounds that
182
+ // re-running "on every queue change" would restart the 100ms timer once per
183
+ // queued mutation. That reason is real and is preserved below — the
184
+ // dependency is the BOOLEAN `hasPendingMutations`, never `queue` and never
185
+ // `queue.length`, so queueing a second mutation while a timer is already
186
+ // armed does not re-run this effect and does not restart the timer.
187
+ //
188
+ // What the suppression also did, and never justified, was evaluate the
189
+ // emptiness guard against a queue snapshot from whenever `isOnline` or
190
+ // `enabled` last changed. `queueMutation` accepts entries whenever the hook
191
+ // is enabled — it has never been conditional on being offline — so anything
192
+ // queued while ALREADY online found this effect asleep and had no auto-sync
193
+ // path at all; only an explicit `sync()` drained it (objectui#6818). Keying
194
+ // on the boolean re-evaluates the guard exactly when it can change answer.
195
+ //
196
+ // `sync` is now stable across queue changes (it reads the queue through
197
+ // `queueRef`), so naming it here costs no extra timer restart and the array
198
+ // is genuinely exhaustive — the suppression is gone rather than reworded.
199
+ //
200
+ // Known remaining edge, deliberately not widened here: a `batchSize` smaller
201
+ // than the queue drains one batch and leaves `hasPendingMutations` true, so
202
+ // the remainder waits for the next transition rather than chaining a second
203
+ // batch. That is the pre-existing behaviour, not a regression. Whether one
204
+ // auto-sync should drain the whole queue batch-by-batch is a semantics
205
+ // question about `batchSize` rather than a bug in this guard, and it is
206
+ // filed separately as objectui#6857.
207
+ const hasPendingMutations = queue.length > 0;
152
208
  useEffect(() => {
153
- if (!enabled || !isOnline || queue.length === 0)
209
+ if (!enabled || !isOnline || !hasPendingMutations)
154
210
  return;
155
211
  const timer = setTimeout(() => {
156
212
  void sync();
157
213
  }, 100);
158
214
  return () => clearTimeout(timer);
159
- // Only trigger on isOnline changes, not on every queue change
160
- // eslint-disable-next-line react-hooks/exhaustive-deps
161
- }, [isOnline, enabled]);
215
+ }, [isOnline, enabled, hasPendingMutations, sync]);
162
216
  return useMemo(() => ({
163
217
  isOnline,
164
218
  enabled,
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * `usePageAssignment` — resolve the record PageSchema that should be rendered
9
9
  * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`
10
- * and returns the first PageSchema whose `pageType === 'record'` and `object`
10
+ * and returns the first PageSchema whose `type === 'record'` and `object`
11
11
  * matches the requested name.
12
12
  *
13
13
  * Returns a discriminated result by `PageSchema.kind`:
@@ -18,10 +18,13 @@
18
18
  * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
19
19
  * fall through to synthesized defaults.
20
20
  *
21
- * Future work (deferred): recordType / profile / app / formFactor filtering
22
- * and priority-based selection. For now we return the first match so that
23
- * callers can deterministically fall back to the auto-generated DetailView
24
- * when no record Page is authored.
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.
24
+ *
25
+ * 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.
25
28
  */
26
29
  export interface PageAssignmentOptions {
27
30
  /** Salesforce-style record type filter (reserved for future use). */
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * `usePageAssignment` — resolve the record PageSchema that should be rendered
9
9
  * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`
10
- * and returns the first PageSchema whose `pageType === 'record'` and `object`
10
+ * and returns the first PageSchema whose `type === 'record'` and `object`
11
11
  * matches the requested name.
12
12
  *
13
13
  * Returns a discriminated result by `PageSchema.kind`:
@@ -18,13 +18,16 @@
18
18
  * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
19
19
  * fall through to synthesized defaults.
20
20
  *
21
- * Future work (deferred): recordType / profile / app / formFactor filtering
22
- * and priority-based selection. For now we return the first match so that
23
- * callers can deterministically fall back to the auto-generated DetailView
24
- * when no record Page is authored.
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.
24
+ *
25
+ * 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.
25
28
  */
26
29
  import { useEffect, useMemo, useState } from 'react';
27
- import { useMetadata } from '../context/AppShellContext';
30
+ import { useMetadata } from '../context/AppShellContext.js';
28
31
  function matchesAssignment(_page, _opts) {
29
32
  // Placeholder — every page matches until rules-based assignment lands.
30
33
  return true;
@@ -75,14 +78,20 @@ export function usePageAssignment(objectName, opts = {}) {
75
78
  const candidates = pages.filter(p => {
76
79
  if (!p)
77
80
  return false;
78
- // Only `pageType: 'record'` (or bare `type: 'record'`) is a
79
- // user-facing record-detail page. Any other `pageType` — notably the
80
- // designer-side `'record_detail'`, dropped from `PageType` in
81
- // framework#2265 but still expressible in raw metadata — must NOT be
82
- // picked up here, otherwise opening any record would render it.
83
- const pt = p.pageType ?? (p.type === 'record' ? 'record' : undefined);
84
- const isRecord = pt === 'record';
85
- if (!isRecord)
81
+ // `type` is the ONE discriminator: only `type: 'record'` is a
82
+ // user-facing record-detail page, and every other page type
83
+ // (`home` / `app` / `utility` / `list`) is skipped.
84
+ //
85
+ // `pageType` is deliberately NOT read (objectui#9674). `PageSchema` is
86
+ // a `strictObject` that refuses `pageType` by name — it is a declared
87
+ // ALIAS of `type`, and a page carrying it is a hard parse error that
88
+ // names `type` as the key to write — so no page that parses carries it,
89
+ // and reading it would teach the refused spelling onward. A consumer-side
90
+ // alias for a refused key is the fallback AGENTS.md #0.1 rules out. The
91
+ // designer-side `'record_detail'` is not expressible either: it left
92
+ // `PageTypeSchema` (framework#2265), so `type: 'record_detail'` is an
93
+ // invalid enum value.
94
+ if (p.type !== 'record')
86
95
  return false;
87
96
  if (p.object !== objectName)
88
97
  return false;
@@ -90,9 +99,21 @@ export function usePageAssignment(objectName, opts = {}) {
90
99
  });
91
100
  if (!candidates.length)
92
101
  return null;
93
- // Stable ordering: respect explicit `priority` if present (higher wins),
94
- // otherwise fall back to declaration order.
95
- candidates.sort((a, b) => (b?.priority ?? 0) - (a?.priority ?? 0));
102
+ // Declaration order decides: the first match wins.
103
+ //
104
+ // This used to sort on a `priority` key read off the candidate. No author
105
+ // could ever set it: `PageSchema` is a `strictObject` and does not declare
106
+ // `priority`, so a page carrying one is a HARD PARSE ERROR, not a page that
107
+ // sorts first. Every candidate therefore read `0`, the comparator returned
108
+ // `0` for every pair, and a stable sort left declaration order untouched —
109
+ // the sort documented an affordance the schema refuses (objectui#7298).
110
+ //
111
+ // `isDefault` is not consulted here either. It IS declared on `PageSchema`,
112
+ // so unlike `priority` it is writable — but nothing in this decision reads
113
+ // it, which makes the flag on a shipped page (e.g. the platform's
114
+ // `sys_user_detail`) inert while looking decisive. Reading it would be new
115
+ // behaviour, not a repair; it is recorded here so the next reader does not
116
+ // mistake the flag for the thing that picks the page.
96
117
  return candidates[0];
97
118
  }, [meta.pages, objectName, opts.pageName, opts.recordType, opts.profile, opts.app, opts.formFactor]);
98
119
  // Discriminate by `kind`: full pages populate `page`, slotted pages
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { useMemo } from 'react';
9
- import { useReducedMotion } from './useReducedMotion';
9
+ import { useReducedMotion } from './useReducedMotion.js';
10
10
  // ---------------------------------------------------------------------------
11
11
  // Constants
12
12
  // ---------------------------------------------------------------------------
@@ -6,8 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import * as React from 'react';
9
- import { usePageVariables } from './usePageVariables';
10
- import { useAction } from '../context';
9
+ import { usePageVariables } from './usePageVariables.js';
10
+ import { useAction } from '../context/index.js';
11
11
  /**
12
12
  * PageVariableActionBridge — publishes the live page-variable snapshot into the
13
13
  * shared ActionRunner context as `pageVariables`, so a submit action (`api` /
@@ -23,7 +23,10 @@ export interface RecordSearchHit {
23
23
  * objects. Stable across runs for the same (query, display) pair.
24
24
  */
25
25
  score: number;
26
- /** Raw record payload, for callers that want extra context. */
26
+ /**
27
+ * Raw record payload, for callers that want extra context. It is the row as
28
+ * served: `fieldReadPolicy` gates the `display` label, not this payload.
29
+ */
27
30
  raw: any;
28
31
  }
29
32
  export interface UseRecordSearchOptions {
@@ -60,8 +63,38 @@ export interface UseRecordSearchOptions {
60
63
  * Optional display-name resolver. Defaults to the same `titleFormat`
61
64
  * fallback chain used by app-shell. Pass `getRecordDisplayName` from
62
65
  * `@object-ui/app-shell` to share that implementation exactly.
66
+ *
67
+ * Safe to pass inline: the resolver's identity never re-runs the search.
68
+ * Each run reads the resolver of the latest render, so a swapped resolver
69
+ * labels the hits of the next run; results already shown keep their labels.
63
70
  */
64
71
  getDisplayName?: (objectDef: any, record: any) => string;
72
+ /**
73
+ * Optional field-level read policy: pass `usePermissions()` from
74
+ * `@object-ui/permissions` (objectui#10500). Once it is loaded, a hit is
75
+ * labelled from its record with the fields the policy denies on the hit's
76
+ * object removed, `id` and `_id` kept. The row is gated BEFORE
77
+ * `getDisplayName` reads it, so a caller-supplied resolver is gated too. A
78
+ * denied field then reads exactly as an absent one and the resolver falls
79
+ * through to its next rung, the label a backend that strips denied fields
80
+ * (ObjectStack's `FieldMasker`) already yields. The rule is
81
+ * `withoutDeniedFields` from `@object-ui/core` (objectui#10594), the one the
82
+ * record title (objectui#10434) and the lookup option label (objectui#10411)
83
+ * call too.
84
+ *
85
+ * Omitted, or not loaded yet, the row is labelled as served. A title the
86
+ * server computed (`searchAll`'s `hit.title`) is the server's answer and is
87
+ * not re-judged here.
88
+ *
89
+ * Safe to pass inline: only `isLoaded` decides whether a run happens. When
90
+ * it changes the search re-runs, so hits labelled before the policy loaded
91
+ * are relabelled from the gated row. Otherwise each run reads the policy of
92
+ * the latest render, as it does `getDisplayName`.
93
+ */
94
+ fieldReadPolicy?: {
95
+ isLoaded: boolean;
96
+ checkField: (object: string, field: string, action: 'read') => boolean;
97
+ };
65
98
  }
66
99
  export interface UseRecordSearchResult {
67
100
  results: RecordSearchHit[];