@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +639 -1
  2. package/README.md +41 -0
  3. package/dist/SchemaRenderer.js +12 -2
  4. package/dist/context/InlineEditContext.d.ts +21 -0
  5. package/dist/context/InlineEditContext.js +7 -2
  6. package/dist/context/NotificationContext.d.ts +245 -17
  7. package/dist/context/NotificationContext.js +222 -20
  8. package/dist/context/ThemeContext.js +4 -8
  9. package/dist/hooks/index.d.ts +2 -1
  10. package/dist/hooks/index.js +3 -1
  11. package/dist/hooks/useAnimation.d.ts +40 -4
  12. package/dist/hooks/useAnimation.js +41 -10
  13. package/dist/hooks/useCapabilityGate.d.ts +16 -0
  14. package/dist/hooks/useCapabilityGate.js +60 -0
  15. package/dist/hooks/useClientNotifications.d.ts +0 -6
  16. package/dist/hooks/useClientNotifications.js +7 -53
  17. package/dist/hooks/useFilterScope.d.ts +30 -0
  18. package/dist/hooks/useFilterScope.js +55 -0
  19. package/dist/hooks/useNavigationOverlay.d.ts +11 -0
  20. package/dist/hooks/useNavigationOverlay.js +35 -1
  21. package/dist/hooks/usePageVariables.js +23 -11
  22. package/dist/hooks/useRecordSearch.js +98 -4
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +1 -1
  25. package/dist/spec-bridge/SpecBridge.d.ts +0 -4
  26. package/dist/spec-bridge/SpecBridge.js +0 -12
  27. package/dist/spec-bridge/bridges/list-view.js +5 -11
  28. package/dist/spec-bridge/index.d.ts +0 -2
  29. package/dist/spec-bridge/index.js +0 -2
  30. package/dist/utils/error-message.d.ts +31 -0
  31. package/dist/utils/error-message.js +49 -0
  32. package/package.json +7 -7
  33. package/dist/hooks/useColumnSummary.d.ts +0 -38
  34. package/dist/hooks/useColumnSummary.js +0 -54
  35. package/dist/spec-bridge/bridges/dashboard.d.ts +0 -81
  36. package/dist/spec-bridge/bridges/dashboard.js +0 -121
  37. package/dist/spec-bridge/bridges/page.d.ts +0 -58
  38. 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
+ }
@@ -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?.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(() => {
@@ -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
  */
@@ -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
  }
@@ -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
- if (spec.sharing) {
138
- // Normalize spec sharing format: map type → visibility, set enabled = true
139
- const sharing = { ...spec.sharing };
140
- if (sharing.type && !sharing.visibility) {
141
- sharing.visibility = sharing.type === 'collaborative' ? 'team' : 'private';
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';
@@ -8,5 +8,3 @@
8
8
  export { SpecBridge } from './SpecBridge';
9
9
  export { bridgeListView } from './bridges/list-view';
10
10
  export { bridgeFormView } from './bridges/form-view';
11
- export { bridgePage } from './bridges/page';
12
- 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": "16.1.0",
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": "^16.0.0-rc.0",
28
- "react-hook-form": "^7.82.0",
29
- "@object-ui/core": "16.1.0",
30
- "@object-ui/data-objectstack": "16.1.0",
31
- "@object-ui/i18n": "16.1.0",
32
- "@object-ui/types": "16.1.0"
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",