@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
@@ -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,
@@ -6,9 +6,10 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  *
8
8
  * `usePageAssignment` — resolve the record PageSchema that should be rendered
9
- * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`
10
- * and returns the first PageSchema whose `pageType === 'record'` and `object`
11
- * matches the requested name.
9
+ * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`,
10
+ * keeps the PageSchemas whose `type === 'record'` and `object` matches the
11
+ * requested name, and returns the object's DEFAULT record page among them
12
+ * (`isDefault: true`), else the first of them.
12
13
  *
13
14
  * Returns a discriminated result by `PageSchema.kind`:
14
15
  * - `kind === 'full'` (default): the schema fully describes the page;
@@ -18,10 +19,17 @@
18
19
  * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
19
20
  * fall through to synthesized defaults.
20
21
  *
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.
22
+ * Selection is by the declared `PageSchema.isDefault`, then by DECLARATION
23
+ * ORDER among pages that are not the default: the spec allows one default
24
+ * record page per object, so the flag needs no tie-break here, and with no
25
+ * default the first matching page wins. No match returns null, so callers can
26
+ * deterministically fall back to the auto-generated DetailView when no record
27
+ * Page is authored.
28
+ *
29
+ * Future work (deferred): recordType / profile / app / formFactor filtering.
30
+ * Any further author-driven ordering would have to be DECLARED on
31
+ * `PageSchema` in `@objectstack/spec` first — see the note on the selection
32
+ * itself below.
25
33
  */
26
34
  export interface PageAssignmentOptions {
27
35
  /** Salesforce-style record type filter (reserved for future use). */
@@ -59,6 +67,24 @@ export interface PageAssignmentResult {
59
67
  /** Loader error, if any. */
60
68
  error: Error | null;
61
69
  }
70
+ /**
71
+ * True when `page` is a record page bound to `objectName`: its `type` is
72
+ * `'record'` and its `object` names that object.
73
+ *
74
+ * This is the ONE reading of "a record page of object O" in the console. The
75
+ * object-based lookup below filters its candidates with it, and a route that
76
+ * names a page explicitly (the console's named record page route,
77
+ * objectui#12715) asks it before rendering the named page for a record of O,
78
+ * so a name that is a page of another object, or not a record page at all, is
79
+ * refused rather than rendered. The `pageName` override of
80
+ * {@link usePageAssignment} does not ask it: that option returns the page with
81
+ * that name whatever it is, and a caller that renders a record on the page it
82
+ * gets back asks this first.
83
+ *
84
+ * `type` is the one discriminator, as the lookup's own comment explains
85
+ * (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read.
86
+ */
87
+ export declare function isRecordPageOf(page: unknown, objectName: string | undefined | null): boolean;
62
88
  /**
63
89
  * Resolve the record PageSchema for the given object name. Returns `null`
64
90
  * when no record Page is configured, signalling the caller to fall back
@@ -6,9 +6,10 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  *
8
8
  * `usePageAssignment` — resolve the record PageSchema that should be rendered
9
- * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`
10
- * and returns the first PageSchema whose `pageType === 'record'` and `object`
11
- * matches the requested name.
9
+ * for a given object. Walks the metadata cache exposed by `<MetadataProvider>`,
10
+ * keeps the PageSchemas whose `type === 'record'` and `object` matches the
11
+ * requested name, and returns the object's DEFAULT record page among them
12
+ * (`isDefault: true`), else the first of them.
12
13
  *
13
14
  * Returns a discriminated result by `PageSchema.kind`:
14
15
  * - `kind === 'full'` (default): the schema fully describes the page;
@@ -18,13 +19,44 @@
18
19
  * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
19
20
  * fall through to synthesized defaults.
20
21
  *
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.
22
+ * Selection is by the declared `PageSchema.isDefault`, then by DECLARATION
23
+ * ORDER among pages that are not the default: the spec allows one default
24
+ * record page per object, so the flag needs no tie-break here, and with no
25
+ * default the first matching page wins. No match returns null, so callers can
26
+ * deterministically fall back to the auto-generated DetailView when no record
27
+ * Page is authored.
28
+ *
29
+ * Future work (deferred): recordType / profile / app / formFactor filtering.
30
+ * Any further author-driven ordering would have to be DECLARED on
31
+ * `PageSchema` in `@objectstack/spec` first — see the note on the selection
32
+ * itself below.
25
33
  */
26
34
  import { useEffect, useMemo, useState } from 'react';
35
+ import { defaultRecordPageObject } from '@objectstack/spec/ui';
27
36
  import { useMetadata } from '../context/AppShellContext.js';
37
+ /**
38
+ * True when `page` is a record page bound to `objectName`: its `type` is
39
+ * `'record'` and its `object` names that object.
40
+ *
41
+ * This is the ONE reading of "a record page of object O" in the console. The
42
+ * object-based lookup below filters its candidates with it, and a route that
43
+ * names a page explicitly (the console's named record page route,
44
+ * objectui#12715) asks it before rendering the named page for a record of O,
45
+ * so a name that is a page of another object, or not a record page at all, is
46
+ * refused rather than rendered. The `pageName` override of
47
+ * {@link usePageAssignment} does not ask it: that option returns the page with
48
+ * that name whatever it is, and a caller that renders a record on the page it
49
+ * gets back asks this first.
50
+ *
51
+ * `type` is the one discriminator, as the lookup's own comment explains
52
+ * (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read.
53
+ */
54
+ export function isRecordPageOf(page, objectName) {
55
+ if (!page || typeof page !== 'object' || !objectName)
56
+ return false;
57
+ const p = page;
58
+ return p.type === 'record' && p.object === objectName;
59
+ }
28
60
  function matchesAssignment(_page, _opts) {
29
61
  // Placeholder — every page matches until rules-based assignment lands.
30
62
  return true;
@@ -75,25 +107,57 @@ export function usePageAssignment(objectName, opts = {}) {
75
107
  const candidates = pages.filter(p => {
76
108
  if (!p)
77
109
  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)
86
- return false;
87
- if (p.object !== objectName)
110
+ // `type` is the ONE discriminator: only `type: 'record'` is a
111
+ // user-facing record-detail page, and every other page type
112
+ // (`home` / `app` / `utility` / `list`) is skipped.
113
+ //
114
+ // `pageType` is deliberately NOT read (objectui#9674). `PageSchema` is
115
+ // a `strictObject` that refuses `pageType` by name — it is a declared
116
+ // ALIAS of `type`, and a page carrying it is a hard parse error that
117
+ // names `type` as the key to write — so no page that parses carries it,
118
+ // and reading it would teach the refused spelling onward. A consumer-side
119
+ // alias for a refused key is the fallback AGENTS.md #0.1 rules out. The
120
+ // designer-side `'record_detail'` is not expressible either: it left
121
+ // `PageTypeSchema` (framework#2265), so `type: 'record_detail'` is an
122
+ // invalid enum value.
123
+ if (!isRecordPageOf(p, objectName))
88
124
  return false;
89
125
  return matchesAssignment(p, opts);
90
126
  });
91
127
  if (!candidates.length)
92
128
  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));
96
- return candidates[0];
129
+ // The object's DEFAULT record page decides; with none, declaration order.
130
+ //
131
+ // At most one `isDefault: true` record page may exist for an object in a
132
+ // deployment. That is the spec's rule on `PageSchema.isDefault` (maintainer
133
+ // ruling A on objectstack#23015, record 6134056653), and the platform
134
+ // refuses a second default, naming both pages, at every door that can add
135
+ // one: `defineStack` / `composeStacks` (so `os validate` and a source boot),
136
+ // package registration, and metadata save. So this hook honours the flag
137
+ // with NO tie-break of its own. It asks `defaultRecordPageObject`, the one
138
+ // reading of "the default record page of an object" each of those doors
139
+ // asks, so the page the platform judges to be the default is the page that
140
+ // opens. Replacing a platform record page stays an ADR-0005 overlay by name
141
+ // (the same page, not a second default) or host composition; neither a
142
+ // second default nor an earlier position in the served list overrides one.
143
+ //
144
+ // Boundary, stated rather than left implicit: at a cold boot every package
145
+ // registers before the environment's stored pages hydrate, so a stored
146
+ // default saved while no package held that object's default, and a package
147
+ // that adds one later, both reach the registry unrefused
148
+ // (objectstack#23213, closed not_planned). This hook invents no precedence
149
+ // for that pair (no layer, package or recency rung); served order stays the
150
+ // only order it has, so the first default served wins.
151
+ //
152
+ // Among pages that are not the default, declaration order is the last
153
+ // resort: the first match wins. This used to sort on a `priority` key read
154
+ // off the candidate. No author could ever set it: `PageSchema` is a
155
+ // `strictObject` and does not declare `priority`, so a page carrying one is
156
+ // a HARD PARSE ERROR, not a page that sorts first. Every candidate therefore
157
+ // read `0`, the comparator returned `0` for every pair, and a stable sort
158
+ // left declaration order untouched — the sort documented an affordance the
159
+ // schema refuses (objectui#7298).
160
+ return candidates.find(p => defaultRecordPageObject(p) === objectName) ?? candidates[0];
97
161
  }, [meta.pages, objectName, opts.pageName, opts.recordType, opts.profile, opts.app, opts.formFactor]);
98
162
  // Discriminate by `kind`: full pages populate `page`, slotted pages
99
163
  // populate `slots`. Missing `kind` defaults to 'full' for backwards
@@ -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[];
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { useEffect, useMemo, useRef, useState } from 'react';
9
- import { getRecordDisplayName as defaultDisplayName } from '@object-ui/core';
9
+ import { getRecordDisplayName as defaultDisplayName, withoutDeniedFields } from '@object-ui/core';
10
10
  import { errorCodeIs } from '@object-ui/types';
11
11
  // ADR-0079: the global-search default display-name resolver is now the ONE
12
12
  // shared `getRecordDisplayName` from `@object-ui/core` (honors titleFormat →
@@ -74,7 +74,10 @@ function scoreHit(query, display, recordId) {
74
74
  * `$filter` translation.
75
75
  */
76
76
  export function useRecordSearch(opts) {
77
- const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, } = opts;
77
+ const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, fieldReadPolicy, } = opts;
78
+ // The one member of the read policy that decides whether a run happens: a
79
+ // primitive, so an inline policy object never re-runs the search by identity.
80
+ const policyLoaded = fieldReadPolicy?.isLoaded === true;
78
81
  const [results, setResults] = useState([]);
79
82
  const [isSearching, setIsSearching] = useState(false);
80
83
  const [error, setError] = useState(undefined);
@@ -111,11 +114,41 @@ export function useRecordSearch(opts) {
111
114
  }
112
115
  return pool;
113
116
  }, [objects, objectNames]);
117
+ // The object NAME is the whole signature: it is the only field of an object
118
+ // definition this effect consumes (`dataSource.find(obj.name, …)` and the
119
+ // `objects` whitelist sent to `searchAll`); `label` / `icon` are read per hit
120
+ // at render time and never decide whether the fanout re-runs.
121
+ //
122
+ // An `o?.titleField ?? ''` half used to be appended to each entry. It could
123
+ // never vary: `@objectstack/spec`'s object schema is a `strictObject` that
124
+ // REJECTS `titleField` with `unrecognized_keys` (objectui#6531), so no legal
125
+ // object metadata carries it and the half was permanently `''` — a constant
126
+ // suffix in a cache key, which also read as evidence that some producer ships
127
+ // the key. Removed in objectui#6557; the display name comes from
128
+ // `getRecordDisplayName`, which does not consult it either.
114
129
  const candidateSignature = useMemo(() => {
115
- return candidates.map((o) => `${o?.name}:${o?.titleField ?? ''}`).join('|');
130
+ return candidates.map((o) => String(o?.name)).join('|');
116
131
  }, [candidates]);
117
132
  // Run ID for racing-request guarding. Stable across renders.
118
133
  const runIdRef = useRef(0);
134
+ // `getDisplayName` is caller-supplied and read only INSIDE a run, to label
135
+ // hits — it never decides whether a run happens. So the search effect reads
136
+ // the latest resolver through this ref and does not key on its identity
137
+ // (AGENTS.md §5 #10, objectui#10044). Keyed on it, an inline resolver (a new
138
+ // function every render) re-ran the effect on each render, and the cleanup
139
+ // cleared the pending debounce timer: renders faster than `debounceMs` never
140
+ // searched, and sparser renders issued a second identical request. Declared
141
+ // before the search effect, so it is current before any timer is armed.
142
+ //
143
+ // `fieldReadPolicy` is read the same way (objectui#10500): the run reads the
144
+ // latest policy through a ref, and only its `isLoaded` primitive
145
+ // (`policyLoaded`) is a dependency of the search effect.
146
+ const getDisplayNameRef = useRef(getDisplayName);
147
+ const fieldReadPolicyRef = useRef(fieldReadPolicy);
148
+ useEffect(() => {
149
+ getDisplayNameRef.current = getDisplayName;
150
+ fieldReadPolicyRef.current = fieldReadPolicy;
151
+ });
119
152
  useEffect(() => {
120
153
  if (!enabled || !dataSource || candidates.length === 0) {
121
154
  setResults([]);
@@ -142,7 +175,7 @@ export function useRecordSearch(opts) {
142
175
  // per-object `find({ $search })` fanout below only runs each object's
143
176
  // metadata-driven search (ADR-0061) and can miss records the global
144
177
  // search index knows about — the command-palette symptom in framework
145
- // #3371, where `/api/v1/search` returned an account/opportunity the
178
+ // objectstack-ai/objectstack#3371, where `/api/v1/search` returned an account/opportunity the
146
179
  // palette never showed. When `searchAll` is absent (mock/test adapters,
147
180
  // non-ObjectStack backends) we fall back to the fanout.
148
181
  const searchAll = dataSource.searchAll;
@@ -184,7 +217,9 @@ export function useRecordSearch(opts) {
184
217
  continue;
185
218
  const objDef = byName.get(objectName) ?? { name: objectName };
186
219
  const title = typeof h?.title === 'string' && h.title.trim() !== '' ? h.title.trim() : '';
187
- const display = title || getDisplayName(objDef, h?.record ?? {}) || `Record #${recordId}`;
220
+ const display = title ||
221
+ getDisplayNameRef.current(objDef, withoutDeniedFields(h?.record ?? {}, fieldReadPolicyRef.current, objectName)) ||
222
+ `Record #${recordId}`;
188
223
  const snippet = typeof h?.snippet === 'string' ? h.snippet.trim() : '';
189
224
  hits.push({
190
225
  objectName,
@@ -256,7 +291,7 @@ export function useRecordSearch(opts) {
256
291
  const recordId = record?.id ?? record?._id;
257
292
  if (recordId == null)
258
293
  continue;
259
- const display = getDisplayName(obj, record);
294
+ const display = getDisplayNameRef.current(obj, withoutDeniedFields(record, fieldReadPolicyRef.current, obj.name));
260
295
  hits.push({
261
296
  objectName: obj.name,
262
297
  objectLabel: typeof obj.label === 'string' && obj.label.length > 0
@@ -290,7 +325,7 @@ export function useRecordSearch(opts) {
290
325
  maxObjectsQueried,
291
326
  minLength,
292
327
  debounceMs,
293
- getDisplayName,
328
+ policyLoaded,
294
329
  ]);
295
330
  return { results, isSearching, error };
296
331
  }
@@ -0,0 +1,46 @@
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
+ import { type FilterTokenScope } from '@object-ui/core';
9
+ /**
10
+ * Resolve a data node's own authored `filter` ONCE, and hold the result.
11
+ *
12
+ * Every context token the spec defines for filters (`{current_user_id}`,
13
+ * `{current_org_id}`, `{record_id}` where a record is in view, and the date
14
+ * macros such as `{today}`) is expanded by `@object-ui/core`'s shared
15
+ * `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
16
+ * session scope the host provides plus the mounted record's id
17
+ * (objectui#7297). Pass `useFilterScope()` as `scope`:
18
+ *
19
+ * ```tsx
20
+ * const filterScope = useFilterScope();
21
+ * const filter = useResolvedFilter(schema.filter, filterScope);
22
+ * // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
23
+ * ```
24
+ *
25
+ * ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
26
+ * is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
27
+ * warning naming it).
28
+ *
29
+ * The result is HELD against its inputs, the shape `plugin-view`'s
30
+ * `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
31
+ * resolved copy minted on every render would refetch on every render; and a
32
+ * date macro such as `{now}` resolves to a new value at every call, so
33
+ * comparing OUTPUTS cannot stop that. The key is the authored filter, compared
34
+ * by structure (a host that rebuilds an equal filter inline must not
35
+ * re-query), plus each scope member read one by one (`recordId` among them,
36
+ * so moving to another record resolves again), never the scope object's
37
+ * identity (AGENTS.md #10). The held pair lives in state, so the
38
+ * value handed out is always the one React committed. A structurally different
39
+ * filter, or a changed scope member, resolves again and hands out a new value.
40
+ *
41
+ * A filter the resolver leaves unchanged (no token, or only tokens the scope
42
+ * cannot resolve) is handed out as the authored value itself, not as the
43
+ * resolver's equal copy, so a token-free filter reaches the query exactly as
44
+ * it did before any resolution.
45
+ */
46
+ export declare function useResolvedFilter<T>(authored: T, scope: FilterTokenScope): T;
@@ -0,0 +1,130 @@
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
+ /**
9
+ * The ONE hold every data node uses to resolve its OWN authored `filter`
10
+ * (objectui#10666). PR objectui#10656 wrote it for `list-view`
11
+ * (`useResolvedAuthoredFilter`, local to `ListView.tsx`); PR objectui#10705
12
+ * moved it into a module of `plugin-list` for `object-gallery`; the sweep that
13
+ * closed objectui#10666 moved it here, beside `useFilterScope`, so the nine
14
+ * packages whose nodes send their own filter share one copy instead of growing
15
+ * nine. The hold, its key and its comparator moved unchanged; the one addition
16
+ * is that a filter the resolver leaves unchanged is handed out as authored (see
17
+ * `resolveHeldFilter`).
18
+ */
19
+ import * as React from 'react';
20
+ import { resolveFilterPlaceholders } from '@object-ui/core';
21
+ function resolveHeldFilter(authored, scope) {
22
+ const resolved = resolveFilterPlaceholders(authored, scope);
23
+ return {
24
+ authored,
25
+ currentUserId: scope.currentUserId,
26
+ currentOrgId: scope.currentOrgId,
27
+ recordId: scope.recordId,
28
+ onUnresolved: scope.onUnresolved,
29
+ // `resolveFilterPlaceholders` copies every array and plain object it walks,
30
+ // so a filter with nothing to resolve comes back EQUAL but not the same
31
+ // reference. Such a filter is handed out as authored: a node whose filter
32
+ // carries no token sends the very value it was given, as it did before it
33
+ // resolved anything (the `object-kanban` / `object-calendar` / `object-tree`
34
+ // member pins read `$filter` by identity). Only a filter the resolver
35
+ // actually changed is handed out as the resolved copy.
36
+ resolved: isSameAuthoredFilter(resolved, authored) ? authored : resolved,
37
+ };
38
+ }
39
+ /** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */
40
+ const AUTHORED_FILTER_MAX_DEPTH = 12;
41
+ function isPlainFilterObject(value) {
42
+ if (value === null || typeof value !== 'object')
43
+ return false;
44
+ const proto = Object.getPrototypeOf(value);
45
+ return proto === Object.prototype || proto === null;
46
+ }
47
+ /**
48
+ * Compare two authored filters by structure, never by serialising them.
49
+ *
50
+ * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460):
51
+ * primitives by `Object.is`, a `Date` by its instant, arrays element by
52
+ * element and in order, plain objects by key set and value. Anything else is
53
+ * equal only as the same reference, and a structure deeper than the bound is
54
+ * "changed". Every uncertainty answers "changed", which re-resolves and
55
+ * re-queries; the comparison can drop a redundant query, never a needed one.
56
+ */
57
+ function isSameAuthoredFilter(a, b, depth = 0) {
58
+ if (Object.is(a, b))
59
+ return true;
60
+ if (depth >= AUTHORED_FILTER_MAX_DEPTH)
61
+ return false;
62
+ if (a instanceof Date || b instanceof Date) {
63
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
64
+ }
65
+ if (Array.isArray(a) || Array.isArray(b)) {
66
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
67
+ return false;
68
+ return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1));
69
+ }
70
+ if (isPlainFilterObject(a) && isPlainFilterObject(b)) {
71
+ const keys = Object.keys(a);
72
+ if (keys.length !== Object.keys(b).length)
73
+ return false;
74
+ return keys.every((key) => (Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1)));
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Resolve a data node's own authored `filter` ONCE, and hold the result.
80
+ *
81
+ * Every context token the spec defines for filters (`{current_user_id}`,
82
+ * `{current_org_id}`, `{record_id}` where a record is in view, and the date
83
+ * macros such as `{today}`) is expanded by `@object-ui/core`'s shared
84
+ * `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
85
+ * session scope the host provides plus the mounted record's id
86
+ * (objectui#7297). Pass `useFilterScope()` as `scope`:
87
+ *
88
+ * ```tsx
89
+ * const filterScope = useFilterScope();
90
+ * const filter = useResolvedFilter(schema.filter, filterScope);
91
+ * // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
92
+ * ```
93
+ *
94
+ * ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
95
+ * is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
96
+ * warning naming it).
97
+ *
98
+ * The result is HELD against its inputs, the shape `plugin-view`'s
99
+ * `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
100
+ * resolved copy minted on every render would refetch on every render; and a
101
+ * date macro such as `{now}` resolves to a new value at every call, so
102
+ * comparing OUTPUTS cannot stop that. The key is the authored filter, compared
103
+ * by structure (a host that rebuilds an equal filter inline must not
104
+ * re-query), plus each scope member read one by one (`recordId` among them,
105
+ * so moving to another record resolves again), never the scope object's
106
+ * identity (AGENTS.md #10). The held pair lives in state, so the
107
+ * value handed out is always the one React committed. A structurally different
108
+ * filter, or a changed scope member, resolves again and hands out a new value.
109
+ *
110
+ * A filter the resolver leaves unchanged (no token, or only tokens the scope
111
+ * cannot resolve) is handed out as the authored value itself, not as the
112
+ * resolver's equal copy, so a token-free filter reaches the query exactly as
113
+ * it did before any resolution.
114
+ */
115
+ export function useResolvedFilter(authored, scope) {
116
+ const [held, setHeld] = React.useState(() => resolveHeldFilter(authored, scope));
117
+ if (held.currentUserId !== scope.currentUserId
118
+ || held.currentOrgId !== scope.currentOrgId
119
+ || held.recordId !== scope.recordId
120
+ || held.onUnresolved !== scope.onUnresolved
121
+ || !isSameAuthoredFilter(held.authored, authored)) {
122
+ // React's documented "information from previous renders" shape: a set
123
+ // during render re-renders this component at once, before any child sees
124
+ // the discarded pass, and the re-render finds the inputs equal.
125
+ const next = resolveHeldFilter(authored, scope);
126
+ setHeld(next);
127
+ return next.resolved;
128
+ }
129
+ return held.resolved;
130
+ }