@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 +153 -0
- package/dist/SchemaRenderer.js +55 -1
- package/dist/context/DiscussionContext.d.ts +22 -1
- package/dist/context/DiscussionContext.js +2 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useNavigationOverlay.js +7 -2
- package/dist/hooks/useRecordSearch.d.ts +96 -0
- package/dist/hooks/useRecordSearch.js +221 -0
- package/package.json +5 -5
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
|
package/dist/SchemaRenderer.js
CHANGED
|
@@ -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?:
|
|
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
|
}
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -98,9 +98,14 @@ export function useNavigationOverlay(options) {
|
|
|
98
98
|
onNavigate(recordId, 'new_window');
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
|
-
|
|
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
|
|
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.
|
|
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.
|
|
30
|
-
"@object-ui/data-objectstack": "5.
|
|
31
|
-
"@object-ui/i18n": "5.
|
|
32
|
-
"@object-ui/types": "5.
|
|
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",
|