@object-ui/react 16.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +790 -1
  2. package/README.md +41 -0
  3. package/dist/SchemaRenderer.js +12 -2
  4. package/dist/context/AppShellContext.js +51 -23
  5. package/dist/context/InlineEditContext.d.ts +64 -0
  6. package/dist/context/InlineEditContext.js +8 -2
  7. package/dist/context/NotificationContext.d.ts +245 -17
  8. package/dist/context/NotificationContext.js +222 -20
  9. package/dist/context/ThemeContext.js +4 -8
  10. package/dist/hooks/index.d.ts +2 -1
  11. package/dist/hooks/index.js +3 -1
  12. package/dist/hooks/useAnimation.d.ts +40 -4
  13. package/dist/hooks/useAnimation.js +41 -10
  14. package/dist/hooks/useCapabilityGate.d.ts +16 -0
  15. package/dist/hooks/useCapabilityGate.js +60 -0
  16. package/dist/hooks/useClientNotifications.d.ts +0 -6
  17. package/dist/hooks/useClientNotifications.js +7 -53
  18. package/dist/hooks/useFilterScope.d.ts +30 -0
  19. package/dist/hooks/useFilterScope.js +55 -0
  20. package/dist/hooks/useNavigationOverlay.d.ts +34 -10
  21. package/dist/hooks/useNavigationOverlay.js +35 -1
  22. package/dist/hooks/useOffline.d.ts +51 -33
  23. package/dist/hooks/useOffline.js +3 -3
  24. package/dist/hooks/usePageVariables.js +23 -11
  25. package/dist/hooks/usePerformance.d.ts +3 -3
  26. package/dist/hooks/usePerformance.js +2 -2
  27. package/dist/hooks/useRecordSearch.js +98 -4
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +1 -1
  30. package/dist/spec-bridge/SpecBridge.d.ts +0 -4
  31. package/dist/spec-bridge/SpecBridge.js +0 -12
  32. package/dist/spec-bridge/bridges/list-view.js +5 -11
  33. package/dist/spec-bridge/index.d.ts +0 -2
  34. package/dist/spec-bridge/index.js +0 -2
  35. package/dist/spec-input.d.ts +37 -0
  36. package/dist/spec-input.js +8 -0
  37. package/dist/utils/error-message.d.ts +31 -0
  38. package/dist/utils/error-message.js +49 -0
  39. package/package.json +8 -8
  40. package/dist/hooks/useColumnSummary.d.ts +0 -38
  41. package/dist/hooks/useColumnSummary.js +0 -54
  42. package/dist/spec-bridge/bridges/dashboard.d.ts +0 -81
  43. package/dist/spec-bridge/bridges/dashboard.js +0 -121
  44. package/dist/spec-bridge/bridges/page.d.ts +0 -58
  45. 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
- * and `registerDevice(request)`.
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
+ }
@@ -5,18 +5,42 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import type { NavigationConfigSchema } from '@objectstack/spec/ui';
9
+ import type { SpecAuthoredInput } from '../spec-input';
8
10
  /**
9
- * Inline ViewNavigationConfig to avoid importing from @object-ui/types
10
- * (which may not be a direct dependency of @object-ui/react).
11
- * Mirrors the canonical definition in @object-ui/types/objectql.ts.
11
+ * The spec's `NavigationConfigSchema`, authoring side, with `mode` required.
12
+ *
13
+ * This was a hand copy carrying the note "inline … to avoid importing from
14
+ * @object-ui/types (which may not be a direct dependency of @object-ui/react)".
15
+ * That reasoning is about the wrong package: the vocabulary belongs to
16
+ * `@objectstack/spec`, which IS a direct dependency of this one — the same
17
+ * expired "kept local to avoid a dependency" comment objectui#3169 found in
18
+ * `@object-ui/app-shell`, where the dependency had likewise been there all
19
+ * along. Check `package.json` before believing such a note (objectstack#4115).
20
+ *
21
+ * The ONE divergence: the spec defaults `mode`, so its authoring side makes it
22
+ * optional; this hook dispatches on `mode` and its callers always supply one,
23
+ * so it is required here. Every other key is the spec's, by reference. Pinned
24
+ * by `__tests__/offline-nav-performance-spec-parity.test.ts`.
25
+ *
26
+ * Two per-key notes the hand copy carried, kept here because a derived alias
27
+ * has no members to hang them on:
28
+ * - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
29
+ * below maps it through {@link OVERLAY_SIZE_WIDTHS}.
30
+ * - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
31
+ * present, because app-shell pre-resolves `size` into it.
12
32
  */
13
- export interface NavigationConfig {
14
- mode: 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none';
15
- view?: string;
16
- preventNavigation?: boolean;
17
- openNewTab?: boolean;
18
- width?: string | number;
19
- }
33
+ export type NavigationConfig = Omit<SpecAuthoredInput<typeof NavigationConfigSchema>, 'mode'> & {
34
+ mode: NonNullable<SpecAuthoredInput<typeof NavigationConfigSchema>['mode']>;
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 declare function resolveOverlayWidth(navigation: NavigationConfig | undefined): string | number | undefined;
20
44
  export type NavigationMode = NavigationConfig['mode'];
21
45
  export interface UseNavigationOverlayOptions {
22
46
  /** 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?.width;
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(() => {
@@ -5,14 +5,25 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import type { OfflineStrategy, ConflictResolution, PersistStorage, EvictionPolicy, OfflineCacheConfigSchema, OfflineConfigSchema, SyncConfigSchema } from '@objectstack/spec/ui';
9
+ import type { SpecAuthoredInput } from '../spec-input';
8
10
  /** Offline strategy determines how data is fetched when connectivity is limited. */
9
- export type OfflineStrategy = 'cache_first' | 'network_first' | 'stale_while_revalidate' | 'network_only' | 'cache_only';
10
- /** Conflict resolution strategy for sync operations. */
11
- export type ConflictResolutionStrategy = 'manual' | 'client_wins' | 'server_wins' | 'last_write_wins';
11
+ export type { OfflineStrategy };
12
+ /**
13
+ * Conflict resolution strategy for sync operations.
14
+ *
15
+ * Renamed from `ConflictResolutionStrategy` — that name belongs to a DIFFERENT
16
+ * spec export (`@objectstack/spec/api`: `error | priority | first-wins |
17
+ * last-wins`, the metadata-merge policy). The spec's name for the union this
18
+ * hook actually uses is `ConflictResolution`, so the fix was to take the spec's
19
+ * own name and its binding at once, the same move `FieldGroup` →
20
+ * `ObjectFieldGroup` made in objectui#3169.
21
+ */
22
+ export type { ConflictResolution };
12
23
  /** Persist storage backend. */
13
- export type PersistStorageType = 'indexeddb' | 'localstorage' | 'sqlite';
24
+ export type PersistStorageType = PersistStorage;
14
25
  /** Eviction policy for cache management. */
15
- export type EvictionPolicyType = 'lru' | 'lfu' | 'fifo';
26
+ export type EvictionPolicyType = EvictionPolicy;
16
27
  /** Sync state of the offline system. */
17
28
  export type SyncState = 'idle' | 'syncing' | 'error' | 'offline';
18
29
  /** A queued mutation waiting to be synced. */
@@ -23,31 +34,38 @@ export interface QueuedMutation {
23
34
  resource: string;
24
35
  data?: Record<string, unknown>;
25
36
  }
26
- /** Cache configuration aligned with OfflineCacheConfigSchema. */
27
- export interface OfflineCacheConfig {
28
- maxSize?: number;
29
- ttl?: number;
30
- persistStorage?: PersistStorageType;
31
- evictionPolicy?: EvictionPolicyType;
32
- }
33
- /** Sync configuration aligned with SyncConfigSchema. */
34
- export interface OfflineSyncConfig {
35
- strategy?: OfflineStrategy;
36
- conflictResolution?: ConflictResolutionStrategy;
37
- retryInterval?: number;
38
- maxRetries?: number;
39
- batchSize?: number;
40
- }
41
- /** Top-level offline configuration aligned with OfflineConfigSchema. */
42
- export interface OfflineConfig {
43
- enabled?: boolean;
44
- strategy?: OfflineStrategy;
45
- cache?: OfflineCacheConfig;
46
- sync?: OfflineSyncConfig;
47
- offlineIndicator?: boolean;
48
- offlineMessage?: string;
49
- queueMaxSize?: number;
50
- }
37
+ /**
38
+ * Cache configuration — the AUTHORING side of the spec's
39
+ * `OfflineCacheConfigSchema`.
40
+ *
41
+ * `persistStorage` and `evictionPolicy` carry `.default()`s, so they are
42
+ * optional to write and present after a parse. This hook is handed what an app
43
+ * author wrote, hence the input side; see {@link SpecAuthoredInput}.
44
+ */
45
+ export type OfflineCacheConfig = SpecAuthoredInput<typeof OfflineCacheConfigSchema>;
46
+ /**
47
+ * Sync configuration — the AUTHORING side of the spec's `SyncConfigSchema`.
48
+ *
49
+ * `strategy` and `conflictResolution` carry `.default()`s, so the output type
50
+ * makes them required; this hook is handed what an app author wrote.
51
+ */
52
+ export type OfflineSyncConfig = SpecAuthoredInput<typeof SyncConfigSchema>;
53
+ /**
54
+ * Top-level offline configuration — the AUTHORING side of the spec's
55
+ * `OfflineConfigSchema`.
56
+ *
57
+ * This name stays with `@object-ui/react` (objectui#3156 / objectui#3159): the
58
+ * `OfflineConfig` that used to sit in `@object-ui/types` was a service-worker
59
+ * ROUTE cache and has been renamed `PWAOfflineConfig`, so there is no
60
+ * cross-package clash left — this hook's config is the spec's concept, key for
61
+ * key, and takes the spec's binding.
62
+ *
63
+ * Input side, not `z.infer`, and here the reason is visible in the signature:
64
+ * `useOffline(config: OfflineConfig = {})` defaults to the empty object, which
65
+ * the output type — where `enabled`, `strategy` and `offlineIndicator` are all
66
+ * required once their `.default()`s have run — would reject outright.
67
+ */
68
+ export type OfflineConfig = SpecAuthoredInput<typeof OfflineConfigSchema>;
51
69
  /** Result returned by the useOffline hook. */
52
70
  export interface OfflineResult {
53
71
  /** Whether the browser is currently online. */
@@ -72,9 +90,9 @@ export interface OfflineResult {
72
90
  offlineMessage: string;
73
91
  }
74
92
  /**
75
- * Hook for offline mode detection and sync queue management.
76
- * Implements OfflineConfigSchema, SyncConfigSchema, and ConflictResolutionSchema
77
- * from @objectstack/spec v2.0.7.
93
+ * Hook for offline mode detection and sync queue management. Its config types
94
+ * ARE the spec's `OfflineConfigSchema` / `SyncConfigSchema` / `ConflictResolution`
95
+ * (authoring side) — see {@link OfflineConfig}.
78
96
  *
79
97
  * @example
80
98
  * ```tsx
@@ -66,9 +66,9 @@ const DEFAULTS = {
66
66
  queueMaxSize: 100,
67
67
  };
68
68
  /**
69
- * Hook for offline mode detection and sync queue management.
70
- * Implements OfflineConfigSchema, SyncConfigSchema, and ConflictResolutionSchema
71
- * from @objectstack/spec v2.0.7.
69
+ * Hook for offline mode detection and sync queue management. Its config types
70
+ * ARE the spec's `OfflineConfigSchema` / `SyncConfigSchema` / `ConflictResolution`
71
+ * (authoring side) — see {@link OfflineConfig}.
72
72
  *
73
73
  * @example
74
74
  * ```tsx
@@ -117,18 +117,30 @@ PageVariablesProvider.displayName = 'PageVariablesProvider';
117
117
  */
118
118
  export function usePageVariables() {
119
119
  const ctx = useContext(PageVariablesContext);
120
- if (!ctx) {
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
  */
@@ -13,7 +13,7 @@ export interface VirtualScrollConfig {
13
13
  itemHeight?: number;
14
14
  overscan?: number;
15
15
  }
16
- /** Performance configuration aligned with PerformanceConfigSchema. */
16
+ /** Performance configuration. */
17
17
  export interface PerformanceConfig {
18
18
  /** Whether to lazy-load components/data. */
19
19
  lazyLoad?: boolean;
@@ -55,8 +55,8 @@ export interface PerformanceResult {
55
55
  debounce: <T extends (...args: unknown[]) => void>(fn: T) => T;
56
56
  }
57
57
  /**
58
- * Hook for performance monitoring and configuration aligned with
59
- * PerformanceConfigSchema from @objectstack/spec v2.0.7.
58
+ * Hook for performance monitoring and configuration. Its config type IS the
59
+ * spec's `PerformanceConfigSchema` (authoring side) — see {@link PerformanceConfig}.
60
60
  *
61
61
  * Provides resolved config values, Web Vitals metrics, and utility functions
62
62
  * (debounce, render marking) for performance-aware components.
@@ -52,8 +52,8 @@ function getLCP() {
52
52
  // usePerformance hook
53
53
  // ---------------------------------------------------------------------------
54
54
  /**
55
- * Hook for performance monitoring and configuration aligned with
56
- * PerformanceConfigSchema from @objectstack/spec v2.0.7.
55
+ * Hook for performance monitoring and configuration. Its config type IS the
56
+ * spec's `PerformanceConfigSchema` (authoring side) — see {@link PerformanceConfig}.
57
57
  *
58
58
  * Provides resolved config values, Web Vitals metrics, and utility functions
59
59
  * (debounce, render marking) for performance-aware components.
@@ -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.slice(0, maxObjectsQueried);
103
- }, [objects, objectNames, maxObjectsQueried]);
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
- const requests = candidates.map((obj) => Promise.resolve()
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 || code === 'object_not_found')
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
  }