@object-ui/react 5.0.2 → 5.2.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # @object-ui/react
2
2
 
3
+ ## 5.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@5.2.1
8
+ - @object-ui/core@5.2.1
9
+ - @object-ui/i18n@5.2.1
10
+ - @object-ui/data-objectstack@5.2.1
11
+
12
+ ## 5.2.0
13
+
14
+ ### Minor Changes
15
+
16
+ - b2d1704: feat(cmdk): record search across objects in the Command Palette
17
+ - New `useRecordSearch` hook in `@object-ui/react` debounces a query, fans out
18
+ to `dataSource.find(name, { $search, $top })` across candidate objects, and
19
+ aggregates hits. Race-safe via a monotonic runId; per-object 404s are
20
+ silently dropped via `Promise.allSettled`.
21
+ - `CommandPalette` (`@object-ui/app-shell`) now accepts a `dataSource` prop;
22
+ when supplied, the palette renders a `Records` group at the top with hits
23
+ scoped to the active app's nav objects. Item `value` embeds the live query
24
+ so cmdk's client-side filter doesn't hide async results.
25
+ - Added `console.commandPalette.records` i18n key (`Records` / `记录`).
26
+
27
+ - 3ebba63: Fix silent blank page on shorthand record deep-links.
28
+
29
+ Three related fixes that all addressed the same UX: a user follows a URL
30
+ shaped `/{object}/{recordId}` and sees a completely blank content area.
31
+ 1. **`useNavigationOverlay` produced the broken URL itself.** When
32
+ middle-click / Cmd-click opened a gallery card in a new tab and no
33
+ `onNavigate` was provided, the hook built `/{object}/{id}` — a URL
34
+ shape that does not match any route in the console route table. The
35
+ builder now emits the canonical `/{object}/record/{id}`.
36
+ 2. **Shorthand redirect for externally shared links.** Even with the
37
+ producer fixed, links pasted from email / Slack / older builds
38
+ still use the shorthand. The console now intercepts
39
+ `/{:objectName}/:maybeRecordId` and, when the second segment looks
40
+ like a record id (URL-safe slug ≥ 6 chars, not a reserved keyword),
41
+ redirects to `/{objectName}/record/{recordId}` preserving query and
42
+ hash.
43
+ 3. **Visible 404 fallback.** Routes that match nothing at all now
44
+ render an explicit "Page not found" empty state with a "Go back"
45
+ action instead of leaving the content area blank. Silent failures
46
+ are now visible failures.
47
+
48
+ - aa063db: `useRecordSearch` now orders hits by relevance instead of object-fanout
49
+ order. Tiers (higher wins):
50
+ - 110: exact recordId paste
51
+ - 100: display exactly equals the query
52
+ - 80: display starts with the query
53
+ - 60: any token in display starts with the query
54
+ - 40: display contains the query as a substring
55
+
56
+ This makes `⌘K → "Ada"` rank "Ada Lovelace" above "AdvancedTradingAccount"
57
+ even though Account is queried before Contact in the fanout.
58
+
59
+ `RecordSearchHit` gains a `score` field for callers that want to render
60
+ hint chips, filter low-confidence rows, or further customize ordering.
61
+
62
+ - 7c7400a: feat(react): dev-mode schema validation in SchemaRenderer
63
+
64
+ `SchemaRenderer` now runs the canonical `validateSchema` from
65
+ `@object-ui/core` on every schema object it renders (deduped per-object
66
+ via a WeakSet so re-renders don't re-log). Errors are surfaced via a
67
+ single grouped `console.warn` that includes the offending JSON path,
68
+ and the host element receives `data-obj-schema-invalid="true"` so apps
69
+ can hook a visual cue (e.g. red outline) via CSS.
70
+
71
+ The entire pass is gated on `process.env.NODE_ENV !== 'production'`
72
+ and is a no-op in production builds — zero runtime cost shipped to
73
+ users.
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [de0c5e6]
78
+ - Updated dependencies [9997cae]
79
+ - Updated dependencies [321294c]
80
+ - Updated dependencies [b2d1704]
81
+ - Updated dependencies [0a644f0]
82
+ - Updated dependencies [a3cb88f]
83
+ - Updated dependencies [5425608]
84
+ - Updated dependencies [e919433]
85
+ - Updated dependencies [70b5570]
86
+ - Updated dependencies [d9c3bae]
87
+ - Updated dependencies [d1442e3]
88
+ - @object-ui/types@5.2.0
89
+ - @object-ui/data-objectstack@5.2.0
90
+ - @object-ui/core@5.2.0
91
+ - @object-ui/i18n@5.2.0
92
+
93
+ ## 5.1.1
94
+
95
+ ### Patch Changes
96
+
97
+ - @object-ui/types@5.1.1
98
+ - @object-ui/core@5.1.1
99
+ - @object-ui/i18n@5.1.1
100
+ - @object-ui/data-objectstack@5.1.1
101
+
102
+ ## 5.1.0
103
+
104
+ ### Patch Changes
105
+
106
+ - bd8447d: Three platform-wide detail polish items.
107
+
108
+ **Tighter page rhythm**
109
+ - Outer `PageRenderer` padding `p-4 md:p-6 lg:p-8` → `p-3 md:p-4 lg:p-6`
110
+ and outer body wrap `space-y-8` → `space-y-6` so list / detail / home
111
+ pages share the same edge rhythm. Cuts ~16px of edge slack on lg.
112
+
113
+ **Highlights KPI treatment**
114
+ - `HeaderHighlight` now renders numeric / currency / percent / decimal
115
+ values as KPI numbers (`text-xl md:text-2xl font-semibold tabular-nums`)
116
+ instead of the uniform `text-sm font-semibold`, so amount / probability
117
+ / count fields read as headline stats — Salesforce-style key facts.
118
+
119
+ **Discussion footer upgrade**
120
+ - `RecordActivityTimeline` now uses `RichTextCommentInput` (bold / italic /
121
+ list / code, `@`-mention autocomplete, preview toggle, Send) instead of
122
+ a bare `<textarea>`.
123
+ - `DiscussionContext` gains an optional `mentionSuggestions` array that
124
+ hosts can wire (e.g. team member directory). Falls back to free-text
125
+ `@mention` when omitted.
126
+ - `RecordChatterPanel` threads `mentionSuggestions` through both inline
127
+ and sidebar positions.
128
+
129
+ - d51a577: feat(platform): Discussion attachments + @mention directory + Reference Rail aside
130
+ - **Discussion attachments** — `RichTextCommentInput` now accepts an `extraSlot`
131
+ and a `canSubmitEmpty` flag so hosts can mount the existing
132
+ `CommentAttachment` composer beneath the editor without forking the toolbar.
133
+ `RecordActivityTimeline` plumbs the attachments through
134
+ `DiscussionContext.onUploadAttachments` and submits attachment-only comments.
135
+ - **@mention directory** — `DiscussionContext` gains a `mentionSuggestions`
136
+ field; `RecordDetailView` populates it from the host `sys_user` collection so
137
+ `@` autocomplete in the composer now resolves against real users.
138
+ - **Reference Rail** — New `record:reference_rail` renderer + a dedicated
139
+ `aside` region emitted by `buildDefaultPageSchema` whenever a record has
140
+ ≥ 2 related lists. The rail surfaces a Salesforce/HubSpot-style snapshot
141
+ of related collections (count badge + top 3 records) on `xl+` viewports.
142
+ - **Layout** — `PageRenderer`'s structured-layout `<aside>` wrappers now honor
143
+ `aside.className`, letting schemas attach responsive utilities like
144
+ `hidden xl:flex` to the rail region.
145
+
146
+ - Updated dependencies [1976691]
147
+ - Updated dependencies [cf30cc2]
148
+ - Updated dependencies [5b80cfd]
149
+ - Updated dependencies [49b1760]
150
+ - Updated dependencies [c0b236f]
151
+ - @object-ui/i18n@5.1.0
152
+ - @object-ui/types@5.1.0
153
+ - @object-ui/core@5.1.0
154
+ - @object-ui/data-objectstack@5.1.0
155
+
3
156
  ## 5.0.2
4
157
 
5
158
  ### Patch Changes
@@ -7,9 +7,58 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
7
7
  * LICENSE file in the root directory of this source tree.
8
8
  */
9
9
  import React, { forwardRef, useContext, useMemo, useEffect, useReducer, useState, Component } from 'react';
10
- import { ComponentRegistry, ExpressionEvaluator, isObjectUIError, ERROR_CODES, debugLog, debugTime, debugTimeEnd, DebugCollector, } from '@object-ui/core';
10
+ import { ComponentRegistry, ExpressionEvaluator, isObjectUIError, ERROR_CODES, debugLog, debugTime, debugTimeEnd, DebugCollector, validateSchema, } from '@object-ui/core';
11
11
  import { SchemaRendererContext } from './context/SchemaRendererContext';
12
12
  import { resolveI18nLabel } from './utils/i18n';
13
+ /**
14
+ * Dev-mode schema validation.
15
+ *
16
+ * In development, every schema object is validated exactly once (deduped
17
+ * via a WeakSet) using the canonical {@link validateSchema} from
18
+ * `@object-ui/core`. Errors are reported via `console.warn` with the
19
+ * offending JSON path, and the rendered host element gets a
20
+ * `data-obj-schema-invalid` attribute so apps can opt into a visual cue
21
+ * (e.g. red outline) via CSS.
22
+ *
23
+ * In production this is a no-op: the validation pass is skipped entirely
24
+ * and `data-obj-schema-invalid` is never emitted.
25
+ */
26
+ const __DEV__ = (() => {
27
+ try {
28
+ return globalThis.process?.env?.NODE_ENV !== 'production';
29
+ }
30
+ catch {
31
+ return true;
32
+ }
33
+ })();
34
+ const _validatedSchemas = typeof WeakSet !== 'undefined' ? new WeakSet() : { add() { }, has() { return false; } };
35
+ function validateSchemaOnce(schema) {
36
+ if (!__DEV__ || !schema || typeof schema !== 'object') {
37
+ return { valid: true, messages: [] };
38
+ }
39
+ if (_validatedSchemas.has(schema)) {
40
+ // We don't cache the result intentionally — once a schema object has
41
+ // been logged, we don't re-log on every re-render. Visual invalid flag
42
+ // still derives from the structural presence of required fields below.
43
+ return { valid: true, messages: [] };
44
+ }
45
+ _validatedSchemas.add(schema);
46
+ try {
47
+ const result = validateSchema(schema);
48
+ if (!result.valid) {
49
+ const msgs = result.errors.map(e => `${e.path}: ${e.message}`);
50
+ // eslint-disable-next-line no-console
51
+ console.warn('[ObjectUI] Invalid schema detected:\n' + msgs.join('\n'), schema);
52
+ return { valid: false, messages: msgs };
53
+ }
54
+ }
55
+ catch (err) {
56
+ // Validator itself failed — surface but don't crash render.
57
+ // eslint-disable-next-line no-console
58
+ console.warn('[ObjectUI] Schema validator threw:', err);
59
+ }
60
+ return { valid: true, messages: [] };
61
+ }
13
62
  /**
14
63
  * Extract AriaPropsSchema properties from a schema node and convert
15
64
  * them to standard HTML ARIA attributes.
@@ -166,6 +215,10 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
166
215
  // If schema is just a string, render it as text
167
216
  if (typeof evaluatedSchema === 'string')
168
217
  return _jsx(_Fragment, { children: evaluatedSchema });
218
+ // Dev-mode validation: log once per schema object, attach visual flag
219
+ // when invalid. Production path returns { valid: true, messages: [] }
220
+ // without doing any work.
221
+ const _validation = __DEV__ ? validateSchemaOnce(schema) : { valid: true, messages: [] };
169
222
  debugLog('schema', 'Rendering schema node', { type: evaluatedSchema.type, id: evaluatedSchema.id });
170
223
  const Component = ComponentRegistry.get(evaluatedSchema.type);
171
224
  if (!Component) {
@@ -214,6 +267,7 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
214
267
  className: evaluatedSchema.className,
215
268
  'data-obj-id': evaluatedSchema.id,
216
269
  'data-obj-type': evaluatedSchema.type,
270
+ ...(__DEV__ && !_validation.valid ? { 'data-obj-schema-invalid': 'true' } : {}),
217
271
  ...props
218
272
  }) }));
219
273
  debugTimeEnd(`render:${evaluatedSchema.type}:${evaluatedSchema.id ?? 'anon'}`);
@@ -22,11 +22,32 @@ export interface DiscussionFeedItem {
22
22
  type?: string;
23
23
  [k: string]: any;
24
24
  }
25
+ export interface DiscussionMentionSuggestion {
26
+ id: string;
27
+ label: string;
28
+ avatarUrl?: string;
29
+ }
30
+ export interface DiscussionAttachment {
31
+ id: string;
32
+ name: string;
33
+ size: number;
34
+ type: string;
35
+ url?: string;
36
+ thumbnailUrl?: string;
37
+ }
25
38
  export interface DiscussionContextValue {
26
39
  items: DiscussionFeedItem[];
27
- onAddComment?: (text: string, attachments?: any[]) => void | Promise<void>;
40
+ onAddComment?: (text: string, attachments?: DiscussionAttachment[]) => void | Promise<void>;
28
41
  onAddReply?: (parentId: string, text: string) => void | Promise<void>;
29
42
  onToggleReaction?: (itemId: string, reaction: string) => void | Promise<void>;
43
+ /** Optional list of users (or any mentionable entities) the comment
44
+ * input can suggest after the user types `@`. When omitted the input
45
+ * falls back to free-text @-mentions without autocompletion. */
46
+ mentionSuggestions?: DiscussionMentionSuggestion[];
47
+ /** Optional uploader. When provided, the comment composer renders a
48
+ * drag-and-drop attachment panel; returned attachments are passed
49
+ * alongside the comment text to `onAddComment`. */
50
+ onUploadAttachments?: (files: FileList) => Promise<DiscussionAttachment[]>;
30
51
  loading?: boolean;
31
52
  error?: Error | null;
32
53
  }
@@ -24,6 +24,8 @@ export const DiscussionContextProvider = ({ children, ...value }) => {
24
24
  value.onAddComment,
25
25
  value.onAddReply,
26
26
  value.onToggleReaction,
27
+ value.mentionSuggestions,
28
+ value.onUploadAttachments,
27
29
  value.loading,
28
30
  value.error,
29
31
  ]);
@@ -34,3 +34,4 @@ export * from './useDebugMode';
34
34
  export * from './useActionEngine';
35
35
  export * from './useDataRefresh';
36
36
  export * from './usePageAssignment';
37
+ export * from './useRecordSearch';
@@ -34,3 +34,4 @@ export * from './useDebugMode';
34
34
  export * from './useActionEngine';
35
35
  export * from './useDataRefresh';
36
36
  export * from './usePageAssignment';
37
+ export * from './useRecordSearch';
@@ -98,9 +98,14 @@ export function useNavigationOverlay(options) {
98
98
  onNavigate(recordId, 'new_window');
99
99
  return;
100
100
  }
101
- const viewPath = view ? `/${view}` : '';
101
+ // Build a URL that matches the AppContent route shape
102
+ // `:objectName/record/:recordId`. Previously this used
103
+ // `/{object}/{id}` which is unrouted and produced a silent
104
+ // blank page when users middle-/Cmd-clicked a gallery card.
102
105
  const encodedId = encodeURIComponent(String(recordId));
103
- const url = objectName ? `/${objectName}/${encodedId}${viewPath}` : `/${encodedId}${viewPath}`;
106
+ const url = objectName
107
+ ? `/${objectName}/record/${encodedId}`
108
+ : `/${encodedId}`;
104
109
  window.open(url, '_blank');
105
110
  return;
106
111
  }
@@ -0,0 +1,96 @@
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
+ export interface RecordSearchHit {
9
+ /** Object name the record belongs to. */
10
+ objectName: string;
11
+ /** Human-readable object label (uses `objectDef.label`, falls back to name). */
12
+ objectLabel: string;
13
+ /** Stable record identifier (record.id ?? record._id). */
14
+ recordId: string;
15
+ /** Resolved display name (via objectDef.titleFormat or fallback chain). */
16
+ display: string;
17
+ /** Optional secondary line (e.g. status or email). Always falsy by default. */
18
+ subtitle?: string;
19
+ /** Pass-through icon hint from the object def. */
20
+ icon?: string;
21
+ /**
22
+ * Relevance score (higher = better) used to interleave hits across
23
+ * objects. Stable across runs for the same (query, display) pair.
24
+ */
25
+ score: number;
26
+ /** Raw record payload, for callers that want extra context. */
27
+ raw: any;
28
+ }
29
+ export interface UseRecordSearchOptions {
30
+ /** Current query string from the input. The hook debounces internally. */
31
+ query: string;
32
+ /** All known object definitions; the hook picks candidates from this list. */
33
+ objects: any[];
34
+ /**
35
+ * The data source to query. When null/undefined the hook returns empty
36
+ * results without firing any requests.
37
+ */
38
+ dataSource: any;
39
+ /**
40
+ * Optional whitelist of object names to search (typically derived from the
41
+ * current app's nav). When non-empty, only these objects are queried — in
42
+ * the order provided. When omitted, the hook falls back to every object
43
+ * where `searchable !== false`, capped at `maxObjectsQueried`.
44
+ */
45
+ objectNames?: string[];
46
+ /**
47
+ * Hard cap on parallel object queries. Defaults to 8. Lower this on
48
+ * slow backends.
49
+ */
50
+ maxObjectsQueried?: number;
51
+ /** Per-object result cap. Defaults to 3. */
52
+ topPerObject?: number;
53
+ /** Min query length before any request fires. Defaults to 2. */
54
+ minLength?: number;
55
+ /** Debounce in ms. Defaults to 250. */
56
+ debounceMs?: number;
57
+ /** Set to false to disable the hook entirely (returns empty, no requests). */
58
+ enabled?: boolean;
59
+ /**
60
+ * Optional display-name resolver. Defaults to the same `titleFormat`
61
+ * fallback chain used by app-shell. Pass `getRecordDisplayName` from
62
+ * `@object-ui/app-shell` to share that implementation exactly.
63
+ */
64
+ getDisplayName?: (objectDef: any, record: any) => string;
65
+ }
66
+ export interface UseRecordSearchResult {
67
+ results: RecordSearchHit[];
68
+ isSearching: boolean;
69
+ error?: Error;
70
+ }
71
+ /**
72
+ * Search records across multiple objects in parallel, fanning out to
73
+ * `dataSource.find(name, { $search, $top })`. Designed for the Cmd-K
74
+ * command palette and similar global-search affordances.
75
+ *
76
+ * The hook:
77
+ *
78
+ * - Debounces `query` so fast typing doesn't generate one request per
79
+ * keystroke.
80
+ * - Skips requests when the trimmed query is shorter than `minLength`,
81
+ * when `enabled` is false, when `dataSource` is null, or when there are
82
+ * no candidate objects.
83
+ * - Uses a monotonically-increasing `runId` to discard stale results — if
84
+ * the user keeps typing while requests are still in flight, only the
85
+ * newest run mutates state.
86
+ * - Tolerates per-object failures (404, etc) via `Promise.allSettled`; a
87
+ * backend that doesn't know about one object doesn't poison the rest of
88
+ * the result set.
89
+ *
90
+ * The hook trusts `$search` — backends that silently ignore it would
91
+ * return top-N records unrelated to the query. The ObjectStack mock + REST
92
+ * adapters both honor `$search`; if you need to integrate with a backend
93
+ * that doesn't, set `enabled` to false or wrap the data source to add
94
+ * `$filter` translation.
95
+ */
96
+ export declare function useRecordSearch(opts: UseRecordSearchOptions): UseRecordSearchResult;
@@ -0,0 +1,221 @@
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 { useEffect, useMemo, useRef, useState } from 'react';
9
+ /**
10
+ * Local copy of the display-name resolver used by app-shell. Kept in
11
+ * `@object-ui/react` so hooks can derive a record's visible label without
12
+ * pulling in the shell. Mirrors `app-shell/src/utils#getRecordDisplayName`;
13
+ * the two should stay in sync.
14
+ */
15
+ function defaultDisplayName(objectDef, record) {
16
+ const fmt = objectDef?.titleFormat;
17
+ if (fmt && typeof fmt === 'string') {
18
+ const rendered = fmt.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path) => {
19
+ const v = path.split('.').reduce((acc, k) => (acc == null ? acc : acc[k]), record);
20
+ return v == null ? '' : String(v);
21
+ }).trim();
22
+ if (rendered.length > 0)
23
+ return rendered;
24
+ }
25
+ return (record?.name ||
26
+ record?.full_name ||
27
+ record?.fullName ||
28
+ record?.title ||
29
+ record?.label ||
30
+ record?.subject ||
31
+ record?.id ||
32
+ record?._id ||
33
+ 'Untitled');
34
+ }
35
+ /**
36
+ * Relevance score for a single record hit against the query. Higher wins.
37
+ *
38
+ * Tiers (designed to be distinct enough to survive small noise):
39
+ * 100 Exact case-insensitive match on display.
40
+ * 80 Display starts with the query.
41
+ * 60 A query-token starts at a word boundary inside the display
42
+ * (e.g. "ACME" matches "Acme Corp" or "Globex / ACME").
43
+ * 40 Substring contains.
44
+ * 0 No textual evidence — server returned it but we can't justify
45
+ * ranking it. Still included so the user can see what came back.
46
+ *
47
+ * The hook also nudges score by recordId equality (exact id paste) so
48
+ * "paste an id, hit enter" stays the top hit.
49
+ */
50
+ function scoreHit(query, display, recordId) {
51
+ if (!query)
52
+ return 0;
53
+ const q = query.toLowerCase();
54
+ const d = (display || '').toLowerCase();
55
+ const id = (recordId || '').toLowerCase();
56
+ if (id === q)
57
+ return 110; // exact id wins everything
58
+ if (d === q)
59
+ return 100;
60
+ if (d.startsWith(q))
61
+ return 80;
62
+ // Word-boundary start: split on common separators.
63
+ const tokens = d.split(/[\s_\-/.,:;()[\]]+/).filter(Boolean);
64
+ if (tokens.some((tok) => tok.startsWith(q)))
65
+ return 60;
66
+ if (d.includes(q))
67
+ return 40;
68
+ return 0;
69
+ }
70
+ /**
71
+ * Search records across multiple objects in parallel, fanning out to
72
+ * `dataSource.find(name, { $search, $top })`. Designed for the Cmd-K
73
+ * command palette and similar global-search affordances.
74
+ *
75
+ * The hook:
76
+ *
77
+ * - Debounces `query` so fast typing doesn't generate one request per
78
+ * keystroke.
79
+ * - Skips requests when the trimmed query is shorter than `minLength`,
80
+ * when `enabled` is false, when `dataSource` is null, or when there are
81
+ * no candidate objects.
82
+ * - Uses a monotonically-increasing `runId` to discard stale results — if
83
+ * the user keeps typing while requests are still in flight, only the
84
+ * newest run mutates state.
85
+ * - Tolerates per-object failures (404, etc) via `Promise.allSettled`; a
86
+ * backend that doesn't know about one object doesn't poison the rest of
87
+ * the result set.
88
+ *
89
+ * The hook trusts `$search` — backends that silently ignore it would
90
+ * return top-N records unrelated to the query. The ObjectStack mock + REST
91
+ * adapters both honor `$search`; if you need to integrate with a backend
92
+ * that doesn't, set `enabled` to false or wrap the data source to add
93
+ * `$filter` translation.
94
+ */
95
+ export function useRecordSearch(opts) {
96
+ const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, } = opts;
97
+ const [results, setResults] = useState([]);
98
+ const [isSearching, setIsSearching] = useState(false);
99
+ const [error, setError] = useState(undefined);
100
+ // Stable signature derived from the object metadata fields we actually
101
+ // touch. Prevents pointless re-runs when the parent re-renders with a
102
+ // new array reference but identical content.
103
+ const candidates = useMemo(() => {
104
+ if (!Array.isArray(objects) || objects.length === 0)
105
+ return [];
106
+ let pool = objects;
107
+ if (Array.isArray(objectNames) && objectNames.length > 0) {
108
+ const byName = new Map();
109
+ for (const obj of objects) {
110
+ const name = obj?.name;
111
+ if (typeof name === 'string')
112
+ byName.set(name, obj);
113
+ }
114
+ pool = objectNames
115
+ .map((n) => byName.get(n))
116
+ .filter((obj) => obj != null);
117
+ }
118
+ else {
119
+ pool = objects.filter((o) => o?.searchable !== false);
120
+ }
121
+ return pool.slice(0, maxObjectsQueried);
122
+ }, [objects, objectNames, maxObjectsQueried]);
123
+ const candidateSignature = useMemo(() => {
124
+ return candidates.map((o) => `${o?.name}:${o?.titleField ?? ''}`).join('|');
125
+ }, [candidates]);
126
+ // Run ID for racing-request guarding. Stable across renders.
127
+ const runIdRef = useRef(0);
128
+ useEffect(() => {
129
+ if (!enabled || !dataSource || candidates.length === 0) {
130
+ setResults([]);
131
+ setIsSearching(false);
132
+ setError(undefined);
133
+ return;
134
+ }
135
+ const trimmed = (query ?? '').trim();
136
+ if (trimmed.length < minLength) {
137
+ setResults([]);
138
+ setIsSearching(false);
139
+ setError(undefined);
140
+ return;
141
+ }
142
+ const myRunId = ++runIdRef.current;
143
+ setIsSearching(true);
144
+ setError(undefined);
145
+ const timer = setTimeout(() => {
146
+ // Skip if a newer run started during the debounce window.
147
+ if (runIdRef.current !== myRunId)
148
+ return;
149
+ const requests = candidates.map((obj) => Promise.resolve()
150
+ .then(() => dataSource.find(obj.name, {
151
+ $search: trimmed,
152
+ $top: topPerObject,
153
+ }))
154
+ .then((res) => ({ obj, res, err: null }))
155
+ .catch((err) => ({ obj, res: null, err })));
156
+ Promise.allSettled(requests).then((settled) => {
157
+ // Discard if a newer run started while we were waiting.
158
+ if (runIdRef.current !== myRunId)
159
+ return;
160
+ const hits = [];
161
+ let lastError;
162
+ for (const s of settled) {
163
+ if (s.status !== 'fulfilled')
164
+ continue;
165
+ const { obj, res, err } = s.value;
166
+ if (err) {
167
+ // Tolerate 404 / object_not_found / similar; only surface the
168
+ // last real error so the UI can show a non-blocking hint.
169
+ const status = err?.httpStatus ?? err?.status;
170
+ const code = err?.code;
171
+ if (status === 404 || code === 'object_not_found')
172
+ continue;
173
+ lastError = err instanceof Error ? err : new Error(String(err));
174
+ continue;
175
+ }
176
+ const rows = Array.isArray(res?.data)
177
+ ? res.data
178
+ : Array.isArray(res)
179
+ ? res
180
+ : [];
181
+ for (const record of rows.slice(0, topPerObject)) {
182
+ const recordId = record?.id ?? record?._id;
183
+ if (recordId == null)
184
+ continue;
185
+ const display = getDisplayName(obj, record);
186
+ hits.push({
187
+ objectName: obj.name,
188
+ objectLabel: typeof obj.label === 'string' && obj.label.length > 0
189
+ ? obj.label
190
+ : obj.name,
191
+ recordId: String(recordId),
192
+ display,
193
+ icon: obj?.icon,
194
+ score: scoreHit(trimmed, display, String(recordId)),
195
+ raw: record,
196
+ });
197
+ }
198
+ }
199
+ // Stable sort by score desc, then by original (object-fanout)
200
+ // order so ties preserve the upstream candidate ordering.
201
+ hits.sort((a, b) => b.score - a.score);
202
+ setResults(hits);
203
+ setIsSearching(false);
204
+ setError(lastError);
205
+ });
206
+ }, debounceMs);
207
+ return () => {
208
+ clearTimeout(timer);
209
+ };
210
+ }, [
211
+ query,
212
+ enabled,
213
+ dataSource,
214
+ candidateSignature,
215
+ topPerObject,
216
+ minLength,
217
+ debounceMs,
218
+ getDisplayName,
219
+ ]);
220
+ return { results, isSearching, error };
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/react",
3
- "version": "5.0.2",
3
+ "version": "5.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React bindings and SchemaRenderer component for Object UI",
@@ -26,10 +26,10 @@
26
26
  "dependencies": {
27
27
  "@objectstack/spec": "^4.0.5",
28
28
  "react-hook-form": "^7.76.0",
29
- "@object-ui/core": "5.0.2",
30
- "@object-ui/data-objectstack": "5.0.2",
31
- "@object-ui/i18n": "5.0.2",
32
- "@object-ui/types": "5.0.2"
29
+ "@object-ui/core": "5.2.1",
30
+ "@object-ui/data-objectstack": "5.2.1",
31
+ "@object-ui/i18n": "5.2.1",
32
+ "@object-ui/types": "5.2.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",