@object-ui/react 16.1.0 → 17.1.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.
- package/CHANGELOG.md +639 -1
- package/README.md +41 -0
- package/dist/SchemaRenderer.js +12 -2
- package/dist/context/InlineEditContext.d.ts +21 -0
- package/dist/context/InlineEditContext.js +7 -2
- package/dist/context/NotificationContext.d.ts +245 -17
- package/dist/context/NotificationContext.js +222 -20
- package/dist/context/ThemeContext.js +4 -8
- package/dist/hooks/index.d.ts +2 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/useAnimation.d.ts +40 -4
- package/dist/hooks/useAnimation.js +41 -10
- package/dist/hooks/useCapabilityGate.d.ts +16 -0
- package/dist/hooks/useCapabilityGate.js +60 -0
- package/dist/hooks/useClientNotifications.d.ts +0 -6
- package/dist/hooks/useClientNotifications.js +7 -53
- package/dist/hooks/useFilterScope.d.ts +30 -0
- package/dist/hooks/useFilterScope.js +55 -0
- package/dist/hooks/useNavigationOverlay.d.ts +11 -0
- package/dist/hooks/useNavigationOverlay.js +35 -1
- package/dist/hooks/usePageVariables.js +23 -11
- package/dist/hooks/useRecordSearch.js +98 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/spec-bridge/SpecBridge.d.ts +0 -4
- package/dist/spec-bridge/SpecBridge.js +0 -12
- package/dist/spec-bridge/bridges/list-view.js +5 -11
- package/dist/spec-bridge/index.d.ts +0 -2
- package/dist/spec-bridge/index.js +0 -2
- package/dist/utils/error-message.d.ts +31 -0
- package/dist/utils/error-message.js +49 -0
- package/package.json +7 -7
- package/dist/hooks/useColumnSummary.d.ts +0 -38
- package/dist/hooks/useColumnSummary.js +0 -54
- package/dist/spec-bridge/bridges/dashboard.d.ts +0 -81
- package/dist/spec-bridge/bridges/dashboard.js +0 -121
- package/dist/spec-bridge/bridges/page.d.ts +0 -58
- package/dist/spec-bridge/bridges/page.js +0 -101
|
@@ -17,8 +17,13 @@
|
|
|
17
17
|
* `sys_inbox_message` materialization and the `sys_notification_receipt`
|
|
18
18
|
* read-state spine (the re-modeled `sys_notification` L2 event carries no
|
|
19
19
|
* recipient/read columns). This hook only needs to call the SDK with its
|
|
20
|
-
* current signatures: `list(options)`, `markRead(ids[])`, `markAllRead()
|
|
21
|
-
*
|
|
20
|
+
* current signatures: `list(options)`, `markRead(ids[])`, `markAllRead()`.
|
|
21
|
+
*
|
|
22
|
+
* The former registerDevice/getPreferences/updatePreferences delegates were
|
|
23
|
+
* removed (objectstack#3612): the SDK deleted those methods because the
|
|
24
|
+
* /notifications/devices and /notifications/preferences server routes were
|
|
25
|
+
* never built — every call was a guaranteed error. They return when the
|
|
26
|
+
* server side exists.
|
|
22
27
|
*/
|
|
23
28
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
24
29
|
import { SchemaRendererContext } from '../context/SchemaRendererContext';
|
|
@@ -113,54 +118,6 @@ export function useClientNotifications(options = {}) {
|
|
|
113
118
|
setLoading(false);
|
|
114
119
|
}
|
|
115
120
|
}, [client, notify]);
|
|
116
|
-
/* ---- registerDevice ---- */
|
|
117
|
-
const registerDevice = useCallback(async (token, platform) => {
|
|
118
|
-
if (!client?.notifications)
|
|
119
|
-
return;
|
|
120
|
-
setError(null);
|
|
121
|
-
try {
|
|
122
|
-
// ADR-0030 / @objectstack/client v7+: registerDevice takes a single
|
|
123
|
-
// RegisterDeviceRequest object (`{ token, platform }`), not positional
|
|
124
|
-
// args. `platform` is the device platform enum.
|
|
125
|
-
await client.notifications.registerDevice({
|
|
126
|
-
token,
|
|
127
|
-
platform: platform,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
catch (err) {
|
|
131
|
-
const wrapped = err instanceof Error ? err : new Error('Failed to register device');
|
|
132
|
-
setError(wrapped);
|
|
133
|
-
throw wrapped;
|
|
134
|
-
}
|
|
135
|
-
}, [client]);
|
|
136
|
-
/* ---- getPreferences ---- */
|
|
137
|
-
const getPreferences = useCallback(async () => {
|
|
138
|
-
if (!client?.notifications)
|
|
139
|
-
return null;
|
|
140
|
-
setError(null);
|
|
141
|
-
try {
|
|
142
|
-
return await client.notifications.getPreferences();
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
const wrapped = err instanceof Error ? err : new Error('Failed to get preferences');
|
|
146
|
-
setError(wrapped);
|
|
147
|
-
throw wrapped;
|
|
148
|
-
}
|
|
149
|
-
}, [client]);
|
|
150
|
-
/* ---- updatePreferences ---- */
|
|
151
|
-
const updatePreferences = useCallback(async (prefs) => {
|
|
152
|
-
if (!client?.notifications)
|
|
153
|
-
return;
|
|
154
|
-
setError(null);
|
|
155
|
-
try {
|
|
156
|
-
await client.notifications.updatePreferences(prefs);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
159
|
-
const wrapped = err instanceof Error ? err : new Error('Failed to update preferences');
|
|
160
|
-
setError(wrapped);
|
|
161
|
-
throw wrapped;
|
|
162
|
-
}
|
|
163
|
-
}, [client]);
|
|
164
121
|
/* ---- markAsRead (server + local) ---- */
|
|
165
122
|
const markAsRead = useCallback(async (id) => {
|
|
166
123
|
// Optimistically mark locally first
|
|
@@ -198,9 +155,6 @@ export function useClientNotifications(options = {}) {
|
|
|
198
155
|
}, [pollInterval, client, fetchNotifications]);
|
|
199
156
|
return {
|
|
200
157
|
fetchNotifications,
|
|
201
|
-
registerDevice,
|
|
202
|
-
getPreferences,
|
|
203
|
-
updatePreferences,
|
|
204
158
|
markAsRead,
|
|
205
159
|
loading,
|
|
206
160
|
error,
|
|
@@ -0,0 +1,30 @@
|
|
|
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 ReactNode } from 'react';
|
|
9
|
+
import type { FilterTokenScope } from '@object-ui/core';
|
|
10
|
+
/**
|
|
11
|
+
* Provide the session scope used to resolve filter placeholders.
|
|
12
|
+
*
|
|
13
|
+
* Mount once, above any surface that renders filtered data. The console shell
|
|
14
|
+
* mounts it next to `ExpressionProvider`, where both the signed-in user and
|
|
15
|
+
* the active organization are already resolved.
|
|
16
|
+
*/
|
|
17
|
+
export declare function FilterScopeProvider({ currentUserId, currentOrgId, children, }: {
|
|
18
|
+
currentUserId?: string | null;
|
|
19
|
+
currentOrgId?: string | null;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<FilterTokenScope>>;
|
|
22
|
+
/**
|
|
23
|
+
* Read the session scope for filter placeholder resolution.
|
|
24
|
+
*
|
|
25
|
+
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
26
|
+
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
27
|
+
* call, which is the point: resolving only some of them is the defect behind
|
|
28
|
+
* #3574.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useFilterScope(): FilterTokenScope;
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { createContext, createElement, useContext, useMemo } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* The session values filter placeholders resolve against — `{current_user_id}`
|
|
11
|
+
* and `{current_org_id}` (framework #3574).
|
|
12
|
+
*
|
|
13
|
+
* ## Why a dedicated context
|
|
14
|
+
*
|
|
15
|
+
* The renderer packages (`plugin-dashboard`, `plugin-charts`, `plugin-list`)
|
|
16
|
+
* deliberately do not depend on `@object-ui/auth`; they receive everything
|
|
17
|
+
* they need as schema or context. But a widget cannot resolve
|
|
18
|
+
* `{current_user_id}` without knowing who is signed in, and the surfaces that
|
|
19
|
+
* DO know (the app-shell) sit above them.
|
|
20
|
+
*
|
|
21
|
+
* `PredicateScopeContext` already carries `current_user` and would have
|
|
22
|
+
* covered the user id, but it is the *expression* evaluation scope — it
|
|
23
|
+
* carries no organization, and widening it for an unrelated consumer would
|
|
24
|
+
* couple filter resolution to predicate semantics. A separate, two-field
|
|
25
|
+
* context keeps the contract explicit and lets a non-console host provide
|
|
26
|
+
* exactly these values without adopting the expression stack.
|
|
27
|
+
*
|
|
28
|
+
* When no provider is mounted this returns an empty scope. That is a safe
|
|
29
|
+
* default: unresolved tokens are left intact by the resolver, so the filter
|
|
30
|
+
* matches nothing rather than silently widening, and the resolver emits one
|
|
31
|
+
* warning naming the token.
|
|
32
|
+
*/
|
|
33
|
+
const FilterScopeContext = createContext({});
|
|
34
|
+
/**
|
|
35
|
+
* Provide the session scope used to resolve filter placeholders.
|
|
36
|
+
*
|
|
37
|
+
* Mount once, above any surface that renders filtered data. The console shell
|
|
38
|
+
* mounts it next to `ExpressionProvider`, where both the signed-in user and
|
|
39
|
+
* the active organization are already resolved.
|
|
40
|
+
*/
|
|
41
|
+
export function FilterScopeProvider({ currentUserId, currentOrgId, children, }) {
|
|
42
|
+
const value = useMemo(() => ({ currentUserId, currentOrgId }), [currentUserId, currentOrgId]);
|
|
43
|
+
return createElement(FilterScopeContext.Provider, { value }, children);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the session scope for filter placeholder resolution.
|
|
47
|
+
*
|
|
48
|
+
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
49
|
+
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
50
|
+
* call, which is the point: resolving only some of them is the defect behind
|
|
51
|
+
* #3574.
|
|
52
|
+
*/
|
|
53
|
+
export function useFilterScope() {
|
|
54
|
+
return useContext(FilterScopeContext);
|
|
55
|
+
}
|
|
@@ -15,8 +15,19 @@ export interface NavigationConfig {
|
|
|
15
15
|
view?: string;
|
|
16
16
|
preventNavigation?: boolean;
|
|
17
17
|
openNewTab?: boolean;
|
|
18
|
+
/** Spec `NavigationConfig.size` (#2578) — coarse overlay bucket. */
|
|
19
|
+
size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
20
|
+
/** @deprecated [#2578 → `size`] explicit pixel/percent width. */
|
|
18
21
|
width?: string | number;
|
|
19
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the overlay width from a NavigationConfig: an explicit `width` wins
|
|
25
|
+
* (app-shell pre-resolves `size` into it); otherwise a declared bucket maps
|
|
26
|
+
* through {@link OVERLAY_SIZE_WIDTHS}. `'auto'`/absent stays `undefined` — the
|
|
27
|
+
* host's default width — because deriving `auto` needs the object's field
|
|
28
|
+
* count, which only schema-aware hosts have. Exported for the parity test.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveOverlayWidth(navigation: NavigationConfig | undefined): string | number | undefined;
|
|
20
31
|
export type NavigationMode = NavigationConfig['mode'];
|
|
21
32
|
export interface UseNavigationOverlayOptions {
|
|
22
33
|
/** The navigation configuration from the schema */
|
|
@@ -16,6 +16,40 @@
|
|
|
16
16
|
* NavigationConfig support.
|
|
17
17
|
*/
|
|
18
18
|
import { useState, useCallback, useMemo } from 'react';
|
|
19
|
+
/**
|
|
20
|
+
* Pixel cap per overlay `size` bucket, clamped to the viewport at render —
|
|
21
|
+
* mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns
|
|
22
|
+
* the `size: 'auto'` field-count derivation this layer cannot perform (no
|
|
23
|
+
* object schema here). Kept in lockstep by the spec-parity test.
|
|
24
|
+
*
|
|
25
|
+
* Before #2942 this hook only read the deprecated `width`, so an authored
|
|
26
|
+
* `size` bucket was silently ignored by every host except app-shell (which
|
|
27
|
+
* pre-resolves it before calling in).
|
|
28
|
+
*/
|
|
29
|
+
const OVERLAY_SIZE_WIDTHS = {
|
|
30
|
+
sm: 'min(92vw, 480px)',
|
|
31
|
+
md: 'min(92vw, 720px)',
|
|
32
|
+
lg: 'min(92vw, 960px)',
|
|
33
|
+
xl: 'min(92vw, 1200px)',
|
|
34
|
+
full: 'min(92vw, 1600px)',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the overlay width from a NavigationConfig: an explicit `width` wins
|
|
38
|
+
* (app-shell pre-resolves `size` into it); otherwise a declared bucket maps
|
|
39
|
+
* through {@link OVERLAY_SIZE_WIDTHS}. `'auto'`/absent stays `undefined` — the
|
|
40
|
+
* host's default width — because deriving `auto` needs the object's field
|
|
41
|
+
* count, which only schema-aware hosts have. Exported for the parity test.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveOverlayWidth(navigation) {
|
|
44
|
+
if (!navigation)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (navigation.width !== undefined)
|
|
47
|
+
return navigation.width;
|
|
48
|
+
const size = navigation.size;
|
|
49
|
+
if (size && size !== 'auto')
|
|
50
|
+
return OVERLAY_SIZE_WIDTHS[size];
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
19
53
|
/**
|
|
20
54
|
* Hook for NavigationConfig-driven navigation overlay.
|
|
21
55
|
*
|
|
@@ -48,7 +82,7 @@ export function useNavigationOverlay(options) {
|
|
|
48
82
|
const [isOpen, setIsOpen] = useState(false);
|
|
49
83
|
const [selectedRecord, setSelectedRecord] = useState(null);
|
|
50
84
|
const mode = navigation?.mode ?? 'page';
|
|
51
|
-
const width = navigation
|
|
85
|
+
const width = resolveOverlayWidth(navigation);
|
|
52
86
|
const view = navigation?.view;
|
|
53
87
|
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
|
|
54
88
|
const close = useCallback(() => {
|
|
@@ -117,18 +117,30 @@ PageVariablesProvider.displayName = 'PageVariablesProvider';
|
|
|
117
117
|
*/
|
|
118
118
|
export function usePageVariables() {
|
|
119
119
|
const ctx = useContext(PageVariablesContext);
|
|
120
|
-
|
|
121
|
-
// Graceful fallback — allows components to work outside a Page
|
|
122
|
-
return {
|
|
123
|
-
variables: {},
|
|
124
|
-
definitions: [],
|
|
125
|
-
setVariable: () => { },
|
|
126
|
-
setVariables: () => { },
|
|
127
|
-
resetVariables: () => { },
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
return ctx;
|
|
120
|
+
return ctx ?? OUTSIDE_PAGE;
|
|
131
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Graceful fallback — allows components to work outside a Page.
|
|
124
|
+
*
|
|
125
|
+
* MUST be a module constant, not a fresh literal per call. `SchemaRenderer`
|
|
126
|
+
* feeds `variables` into the `evaluatedSchema` memo's dependency list, so a new
|
|
127
|
+
* object each render defeats that memo for every component rendered outside a
|
|
128
|
+
* PageVariablesProvider — and hands children a new schema identity every time.
|
|
129
|
+
* A `kind:'react'` page memoises its compiled source on that identity, so the
|
|
130
|
+
* page was being remounted and its `useState` reset (objectui#2954).
|
|
131
|
+
*
|
|
132
|
+
* Frozen through, because sharing one instance means a stray write would no
|
|
133
|
+
* longer be scoped to one render — it would leak to every consumer outside a
|
|
134
|
+
* provider. There is no writer today (the setters are the API, and here they
|
|
135
|
+
* are no-ops); the freeze keeps it that way.
|
|
136
|
+
*/
|
|
137
|
+
const OUTSIDE_PAGE = Object.freeze({
|
|
138
|
+
variables: Object.freeze({}),
|
|
139
|
+
definitions: Object.freeze([]),
|
|
140
|
+
setVariable: () => { },
|
|
141
|
+
setVariables: () => { },
|
|
142
|
+
resetVariables: () => { },
|
|
143
|
+
});
|
|
132
144
|
/**
|
|
133
145
|
* Hook to check if a PageVariablesProvider is available.
|
|
134
146
|
*/
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { getRecordDisplayName as defaultDisplayName } from '@object-ui/core';
|
|
10
|
+
import { errorCodeIs } from '@object-ui/types';
|
|
10
11
|
// ADR-0079: the global-search default display-name resolver is now the ONE
|
|
11
12
|
// shared `getRecordDisplayName` from `@object-ui/core` (honors titleFormat →
|
|
12
13
|
// `displayNameField` → type-aware derivation → `Record #<id>`). Callers can
|
|
@@ -80,6 +81,15 @@ export function useRecordSearch(opts) {
|
|
|
80
81
|
// Stable signature derived from the object metadata fields we actually
|
|
81
82
|
// touch. Prevents pointless re-runs when the parent re-renders with a
|
|
82
83
|
// new array reference but identical content.
|
|
84
|
+
//
|
|
85
|
+
// Deliberately UNCAPPED: `maxObjectsQueried` bounds the number of parallel
|
|
86
|
+
// per-object requests on the fanout path (applied there), not the search
|
|
87
|
+
// scope itself. Capping here also truncated the `objects` whitelist sent to
|
|
88
|
+
// the single-request `/api/v1/search` call, so the palette silently searched
|
|
89
|
+
// only the first N nav objects — records in every later object were
|
|
90
|
+
// unfindable no matter what the user typed. Measured symptom: ⌘K in an app
|
|
91
|
+
// with 40+ searchable objects only ever queried the first 8 nav entries,
|
|
92
|
+
// so which sidebar group happened to come first decided what was findable.
|
|
83
93
|
const candidates = useMemo(() => {
|
|
84
94
|
if (!Array.isArray(objects) || objects.length === 0)
|
|
85
95
|
return [];
|
|
@@ -99,8 +109,8 @@ export function useRecordSearch(opts) {
|
|
|
99
109
|
else {
|
|
100
110
|
pool = objects.filter((o) => o?.searchable !== false);
|
|
101
111
|
}
|
|
102
|
-
return pool
|
|
103
|
-
}, [objects, objectNames
|
|
112
|
+
return pool;
|
|
113
|
+
}, [objects, objectNames]);
|
|
104
114
|
const candidateSignature = useMemo(() => {
|
|
105
115
|
return candidates.map((o) => `${o?.name}:${o?.titleField ?? ''}`).join('|');
|
|
106
116
|
}, [candidates]);
|
|
@@ -127,7 +137,90 @@ export function useRecordSearch(opts) {
|
|
|
127
137
|
// Skip if a newer run started during the debounce window.
|
|
128
138
|
if (runIdRef.current !== myRunId)
|
|
129
139
|
return;
|
|
130
|
-
|
|
140
|
+
// Prefer the platform's unified global-search endpoint when the data
|
|
141
|
+
// source exposes it (ObjectStackAdapter → GET /api/v1/search?q=). The
|
|
142
|
+
// per-object `find({ $search })` fanout below only runs each object's
|
|
143
|
+
// metadata-driven search (ADR-0061) and can miss records the global
|
|
144
|
+
// search index knows about — the command-palette symptom in framework
|
|
145
|
+
// #3371, where `/api/v1/search` returned an account/opportunity the
|
|
146
|
+
// palette never showed. When `searchAll` is absent (mock/test adapters,
|
|
147
|
+
// non-ObjectStack backends) we fall back to the fanout.
|
|
148
|
+
const searchAll = dataSource.searchAll;
|
|
149
|
+
if (typeof searchAll === 'function') {
|
|
150
|
+
// Scope to the candidate objects (the app's searchable nav objects) so
|
|
151
|
+
// the palette doesn't surface records from objects the current app
|
|
152
|
+
// can't route to. We both ask the server to scope (`objects`) and
|
|
153
|
+
// re-filter locally, guarding servers that ignore the param.
|
|
154
|
+
const byName = new Map();
|
|
155
|
+
for (const obj of candidates) {
|
|
156
|
+
if (typeof obj?.name === 'string')
|
|
157
|
+
byName.set(obj.name, obj);
|
|
158
|
+
}
|
|
159
|
+
Promise.resolve()
|
|
160
|
+
.then(() => searchAll.call(dataSource, trimmed, {
|
|
161
|
+
limit: maxObjectsQueried * topPerObject,
|
|
162
|
+
objects: Array.from(byName.keys()),
|
|
163
|
+
}))
|
|
164
|
+
.then((res) => {
|
|
165
|
+
// Discard if a newer run started while we were waiting.
|
|
166
|
+
if (runIdRef.current !== myRunId)
|
|
167
|
+
return;
|
|
168
|
+
const rawHits = Array.isArray(res?.hits)
|
|
169
|
+
? res.hits
|
|
170
|
+
: Array.isArray(res)
|
|
171
|
+
? res
|
|
172
|
+
: [];
|
|
173
|
+
const hits = [];
|
|
174
|
+
for (const h of rawHits) {
|
|
175
|
+
const objectName = h?.object ?? h?.objectName;
|
|
176
|
+
if (typeof objectName !== 'string')
|
|
177
|
+
continue;
|
|
178
|
+
// Keep only whitelisted objects (no-op when the server already
|
|
179
|
+
// honored `objects`).
|
|
180
|
+
if (byName.size > 0 && !byName.has(objectName))
|
|
181
|
+
continue;
|
|
182
|
+
const recordId = h?.id ?? h?.record?.id ?? h?.record?._id;
|
|
183
|
+
if (recordId == null)
|
|
184
|
+
continue;
|
|
185
|
+
const objDef = byName.get(objectName) ?? { name: objectName };
|
|
186
|
+
const title = typeof h?.title === 'string' && h.title.trim() !== '' ? h.title.trim() : '';
|
|
187
|
+
const display = title || getDisplayName(objDef, h?.record ?? {}) || `Record #${recordId}`;
|
|
188
|
+
const snippet = typeof h?.snippet === 'string' ? h.snippet.trim() : '';
|
|
189
|
+
hits.push({
|
|
190
|
+
objectName,
|
|
191
|
+
objectLabel: typeof objDef.label === 'string' && objDef.label.length > 0
|
|
192
|
+
? objDef.label
|
|
193
|
+
: objectName,
|
|
194
|
+
recordId: String(recordId),
|
|
195
|
+
display,
|
|
196
|
+
// Only surface a snippet when it adds information beyond the title.
|
|
197
|
+
subtitle: snippet && snippet !== display ? snippet : undefined,
|
|
198
|
+
icon: objDef?.icon,
|
|
199
|
+
score: scoreHit(trimmed, display, String(recordId)),
|
|
200
|
+
raw: h?.record ?? h,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// The server already ranks hits across objects; preserve that order.
|
|
204
|
+
// Only float an exact-id paste to the very top (parity with the
|
|
205
|
+
// fanout path). Array.prototype.sort is stable, so every other hit
|
|
206
|
+
// keeps the server ordering.
|
|
207
|
+
hits.sort((a, b) => (b.score === 110 ? 1 : 0) - (a.score === 110 ? 1 : 0));
|
|
208
|
+
setResults(hits);
|
|
209
|
+
setIsSearching(false);
|
|
210
|
+
setError(undefined);
|
|
211
|
+
})
|
|
212
|
+
.catch((err) => {
|
|
213
|
+
if (runIdRef.current !== myRunId)
|
|
214
|
+
return;
|
|
215
|
+
setResults([]);
|
|
216
|
+
setIsSearching(false);
|
|
217
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Fanout fallback only: one request per object, so THIS is where the
|
|
222
|
+
// parallel-request cap applies.
|
|
223
|
+
const requests = candidates.slice(0, maxObjectsQueried).map((obj) => Promise.resolve()
|
|
131
224
|
.then(() => dataSource.find(obj.name, {
|
|
132
225
|
$search: trimmed,
|
|
133
226
|
$top: topPerObject,
|
|
@@ -149,7 +242,7 @@ export function useRecordSearch(opts) {
|
|
|
149
242
|
// last real error so the UI can show a non-blocking hint.
|
|
150
243
|
const status = err?.httpStatus ?? err?.status;
|
|
151
244
|
const code = err?.code;
|
|
152
|
-
if (status === 404 ||
|
|
245
|
+
if (status === 404 || errorCodeIs(err, 'OBJECT_NOT_FOUND'))
|
|
153
246
|
continue;
|
|
154
247
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
155
248
|
continue;
|
|
@@ -194,6 +287,7 @@ export function useRecordSearch(opts) {
|
|
|
194
287
|
dataSource,
|
|
195
288
|
candidateSignature,
|
|
196
289
|
topPerObject,
|
|
290
|
+
maxObjectsQueried,
|
|
197
291
|
minLength,
|
|
198
292
|
debounceMs,
|
|
199
293
|
getDisplayName,
|
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +12,6 @@ export * from './LazyPluginLoader';
|
|
|
12
12
|
export * from './spec-bridge';
|
|
13
13
|
export * from './data-invalidation';
|
|
14
14
|
export { resolveI18nLabel } from './utils/i18n';
|
|
15
|
-
export { extractWriteErrorMessage, isPermissionError } from './utils/error-message';
|
|
15
|
+
export { extractWriteErrorMessage, isPermissionError, extractFieldErrors } from './utils/error-message';
|
|
16
|
+
export type { WriteFieldError } from './utils/error-message';
|
|
16
17
|
export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type I18nConfig, type I18nProviderProps, type TranslationKeys, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from '@object-ui/i18n';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,6 @@ export * from './data-invalidation';
|
|
|
15
15
|
export { resolveI18nLabel } from './utils/i18n';
|
|
16
16
|
// Write-error surfacing utilities (shared by drag-write plugins so a failed
|
|
17
17
|
// PATCH — e.g. an RLS 403 — is never silently swallowed).
|
|
18
|
-
export { extractWriteErrorMessage, isPermissionError } from './utils/error-message';
|
|
18
|
+
export { extractWriteErrorMessage, isPermissionError, extractFieldErrors } from './utils/error-message';
|
|
19
19
|
// Built-in i18n support
|
|
20
20
|
export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, } from '@object-ui/i18n';
|
|
@@ -18,9 +18,5 @@ export declare class SpecBridge {
|
|
|
18
18
|
transformListView(spec: any): SchemaNode;
|
|
19
19
|
/** Transform a FormView spec */
|
|
20
20
|
transformFormView(spec: any): SchemaNode;
|
|
21
|
-
/** Transform a Page spec */
|
|
22
|
-
transformPage(spec: any): SchemaNode;
|
|
23
|
-
/** Transform a Dashboard spec */
|
|
24
|
-
transformDashboard(spec: any): SchemaNode;
|
|
25
21
|
updateContext(ctx: Partial<BridgeContext>): void;
|
|
26
22
|
}
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { bridgeListView } from './bridges/list-view';
|
|
9
9
|
import { bridgeFormView } from './bridges/form-view';
|
|
10
|
-
import { bridgePage } from './bridges/page';
|
|
11
|
-
import { bridgeDashboard } from './bridges/dashboard';
|
|
12
10
|
export class SpecBridge {
|
|
13
11
|
constructor(context = {}) {
|
|
14
12
|
Object.defineProperty(this, "bridges", {
|
|
@@ -26,8 +24,6 @@ export class SpecBridge {
|
|
|
26
24
|
this.context = context;
|
|
27
25
|
this.register('list', bridgeListView);
|
|
28
26
|
this.register('form', bridgeFormView);
|
|
29
|
-
this.register('page', bridgePage);
|
|
30
|
-
this.register('dashboard', bridgeDashboard);
|
|
31
27
|
}
|
|
32
28
|
register(specType, bridge) {
|
|
33
29
|
this.bridges.set(specType, bridge);
|
|
@@ -48,14 +44,6 @@ export class SpecBridge {
|
|
|
48
44
|
transformFormView(spec) {
|
|
49
45
|
return this.transform('form', spec);
|
|
50
46
|
}
|
|
51
|
-
/** Transform a Page spec */
|
|
52
|
-
transformPage(spec) {
|
|
53
|
-
return this.transform('page', spec);
|
|
54
|
-
}
|
|
55
|
-
/** Transform a Dashboard spec */
|
|
56
|
-
transformDashboard(spec) {
|
|
57
|
-
return this.transform('dashboard', spec);
|
|
58
|
-
}
|
|
59
47
|
updateContext(ctx) {
|
|
60
48
|
this.context = { ...this.context, ...ctx };
|
|
61
49
|
}
|
|
@@ -134,17 +134,11 @@ export const bridgeListView = (spec, _context) => {
|
|
|
134
134
|
// P1.6 — i18n & ARIA
|
|
135
135
|
if (spec.aria)
|
|
136
136
|
node.aria = spec.aria;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
if (sharing.type && sharing.enabled == null) {
|
|
144
|
-
sharing.enabled = true;
|
|
145
|
-
}
|
|
146
|
-
node.sharing = sharing;
|
|
147
|
-
}
|
|
137
|
+
// `sharing` is already the spec's `ViewSharing` shape on both sides (#2890) —
|
|
138
|
+
// this used to DOWNGRADE it here, inventing a legacy `visibility` audience and
|
|
139
|
+
// an `enabled` flag that the renderer then had to fold back.
|
|
140
|
+
if (spec.sharing)
|
|
141
|
+
node.sharing = spec.sharing;
|
|
148
142
|
if (spec.hiddenFields)
|
|
149
143
|
node.hiddenFields = spec.hiddenFields;
|
|
150
144
|
if (spec.fieldOrder)
|
|
@@ -9,5 +9,3 @@ export { SpecBridge } from './SpecBridge';
|
|
|
9
9
|
export type { BridgeContext, BridgeFn, ObjectDefLite } from './types';
|
|
10
10
|
export { bridgeListView } from './bridges/list-view';
|
|
11
11
|
export { bridgeFormView } from './bridges/form-view';
|
|
12
|
-
export { bridgePage } from './bridges/page';
|
|
13
|
-
export { bridgeDashboard } from './bridges/dashboard';
|
|
@@ -12,6 +12,37 @@
|
|
|
12
12
|
* usable can be extracted, so callers fall back to a generic localized string.
|
|
13
13
|
*/
|
|
14
14
|
export declare function extractWriteErrorMessage(err: unknown): string | null;
|
|
15
|
+
/** One field the server rejected, normalised for `form.setError`. */
|
|
16
|
+
export interface WriteFieldError {
|
|
17
|
+
/** Field name, matching the form control's `name`. */
|
|
18
|
+
field: string;
|
|
19
|
+
/** Human-readable reason, already localized by the server when it can be. */
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Per-field errors from a rejected write, or `null` when the failure was not
|
|
24
|
+
* field-scoped.
|
|
25
|
+
*
|
|
26
|
+
* The server has always sent these. `@objectstack/objectql`'s validators throw
|
|
27
|
+
* `{ code: 'VALIDATION_FAILED', fields: [{ field, code, message }] }`, and both
|
|
28
|
+
* the REST layer and the runtime dispatcher serve that as a 400 with `fields[]`
|
|
29
|
+
* intact. What was missing was the last hop: every form caught the rejection,
|
|
30
|
+
* showed one generic toast, and dropped `fields[]` on the floor — so a user told
|
|
31
|
+
* "Validation failed" had to guess WHICH input was wrong, on a surface that
|
|
32
|
+
* already knows how to mark it.
|
|
33
|
+
*
|
|
34
|
+
* Reads three shapes, because the error can arrive from either side of the
|
|
35
|
+
* adapter boundary:
|
|
36
|
+
* - `validationErrors` — a `ValidationError` from `@object-ui/data-objectstack`;
|
|
37
|
+
* - `details.fields` — the raw `@objectstack/client` error, whose `details`
|
|
38
|
+
* falls back to the whole response body;
|
|
39
|
+
* - `fields` — a hand-rolled error of the same shape (the server duck-types
|
|
40
|
+
* these identically, so we do too).
|
|
41
|
+
*
|
|
42
|
+
* Entries without a usable `field` are dropped rather than guessed at: a wrong
|
|
43
|
+
* mark on an innocent input is worse than the generic toast we already show.
|
|
44
|
+
*/
|
|
45
|
+
export declare function extractFieldErrors(err: unknown): WriteFieldError[] | null;
|
|
15
46
|
/**
|
|
16
47
|
* True when a failed write was rejected for authorization reasons (HTTP 403 /
|
|
17
48
|
* `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial). Lets a
|
|
@@ -68,6 +68,55 @@ export function extractWriteErrorMessage(err) {
|
|
|
68
68
|
.trim();
|
|
69
69
|
return clean || null;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-field errors from a rejected write, or `null` when the failure was not
|
|
73
|
+
* field-scoped.
|
|
74
|
+
*
|
|
75
|
+
* The server has always sent these. `@objectstack/objectql`'s validators throw
|
|
76
|
+
* `{ code: 'VALIDATION_FAILED', fields: [{ field, code, message }] }`, and both
|
|
77
|
+
* the REST layer and the runtime dispatcher serve that as a 400 with `fields[]`
|
|
78
|
+
* intact. What was missing was the last hop: every form caught the rejection,
|
|
79
|
+
* showed one generic toast, and dropped `fields[]` on the floor — so a user told
|
|
80
|
+
* "Validation failed" had to guess WHICH input was wrong, on a surface that
|
|
81
|
+
* already knows how to mark it.
|
|
82
|
+
*
|
|
83
|
+
* Reads three shapes, because the error can arrive from either side of the
|
|
84
|
+
* adapter boundary:
|
|
85
|
+
* - `validationErrors` — a `ValidationError` from `@object-ui/data-objectstack`;
|
|
86
|
+
* - `details.fields` — the raw `@objectstack/client` error, whose `details`
|
|
87
|
+
* falls back to the whole response body;
|
|
88
|
+
* - `fields` — a hand-rolled error of the same shape (the server duck-types
|
|
89
|
+
* these identically, so we do too).
|
|
90
|
+
*
|
|
91
|
+
* Entries without a usable `field` are dropped rather than guessed at: a wrong
|
|
92
|
+
* mark on an innocent input is worse than the generic toast we already show.
|
|
93
|
+
*/
|
|
94
|
+
export function extractFieldErrors(err) {
|
|
95
|
+
const e = err;
|
|
96
|
+
if (!e || typeof e !== 'object')
|
|
97
|
+
return null;
|
|
98
|
+
const raw = (Array.isArray(e.validationErrors) && e.validationErrors) ||
|
|
99
|
+
(Array.isArray(e.details?.fields) && e.details.fields) ||
|
|
100
|
+
(Array.isArray(e.fields) && e.fields) ||
|
|
101
|
+
null;
|
|
102
|
+
if (!raw || !Array.isArray(raw))
|
|
103
|
+
return null;
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const entry of raw) {
|
|
106
|
+
if (!entry || typeof entry !== 'object')
|
|
107
|
+
continue;
|
|
108
|
+
const rec = entry;
|
|
109
|
+
const field = firstString(rec.field, rec.name, rec.path);
|
|
110
|
+
if (!field)
|
|
111
|
+
continue;
|
|
112
|
+
// `code` is the machine enum (`required`, `invalid_option`, …). It is a
|
|
113
|
+
// last resort only: the server builds `message` to be shown verbatim, and
|
|
114
|
+
// an untranslated enum in the UI reads as a bug.
|
|
115
|
+
const message = firstString(rec.message, rec.error, rec.code) ?? '';
|
|
116
|
+
out.push({ field, message });
|
|
117
|
+
}
|
|
118
|
+
return out.length > 0 ? out : null;
|
|
119
|
+
}
|
|
71
120
|
/**
|
|
72
121
|
* True when a failed write was rejected for authorization reasons (HTTP 403 /
|
|
73
122
|
* `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial). Lets a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "React bindings and SchemaRenderer component for Object UI",
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@objectstack/spec": "^
|
|
28
|
-
"react-hook-form": "^7.
|
|
29
|
-
"@object-ui/core": "
|
|
30
|
-
"@object-ui/data-objectstack": "
|
|
31
|
-
"@object-ui/i18n": "
|
|
32
|
-
"@object-ui/types": "
|
|
27
|
+
"@objectstack/spec": "^17.0.0-rc.0",
|
|
28
|
+
"react-hook-form": "^7.83.0",
|
|
29
|
+
"@object-ui/core": "17.1.0",
|
|
30
|
+
"@object-ui/data-objectstack": "17.1.0",
|
|
31
|
+
"@object-ui/i18n": "17.1.0",
|
|
32
|
+
"@object-ui/types": "17.1.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"react": "^18.0.0 || ^19.0.0",
|