@object-ui/app-shell 11.4.0 → 11.5.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 (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
package/CHANGELOG.md CHANGED
@@ -1,5 +1,183 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 11.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 544d8eb: Add the app → Studio reverse bridge (ADR-0080): workspace admins see a "Design in Studio" entry in the app top bar that deep-links to the running app's owning package on the Studio design surface (`/studio/:packageId/data`). Hidden for non-admins and for apps with no owning package; package writability stays server-side (read-only packages open as browse-only).
8
+ - 6fffd3d: Client-side data-invalidation bus — refresh data, don't rebuild UI (objectui#2269 P1).
9
+
10
+ - `@object-ui/react` gains the bus: `notifyDataChanged({objectName, recordId?})`, `useDataInvalidation(objectName, recordId?)` (reader nonce), `subscribeDataChanges`, and `useMutationInvalidationBridge(dataSource)` which fans every dataSource write (`MutationEvent`) onto the bus. The bus also dispatches the legacy `objectui:related-changed` window event, so pre-bus listeners keep working.
11
+ - The `key={refreshKey}` remount of `RecordDetailView` (AppContent) and the `key={actionRefreshKey}` remount of `DetailView` (RecordDetailView) are GONE: record data now refetches in place via the bus — scroll, collapsed sections, tabs and in-progress inline edits survive every save/action/undo. All nine action-success bumps became precisely-scoped `notifyDataChanged` calls; undo/redo use the operation's own `objectName`/`recordId`.
12
+ - `RelatedCountStore` is wired to the bus (tab count badges refetch after any change to their object) and its `useSyncExternalStore` snapshot is now a monotonic version — previously it returned the same `Map` reference, so `emit()` never re-rendered subscribers and invalidations left badges stale; `useRelatedCountVersion()` is exported and drives the probe effect's re-fetch.
13
+ - app-shell also gains the reserved URL-param registry (`urlParams.ts` — `form`/`formObject`/`formLink`/`tab`/`recordId`/`palette`/`shortcuts` constants replace scattered string literals) and AGENTS.md Commandment #8 (UI-state classification: state that must survive a data refresh may never live only in an uncontrolled component).
14
+
15
+ - 9255686: Record detail tabs are URL-addressable (`?tab=`) and survive subtree remounts (objectui#2257, ADR-0054 C3).
16
+
17
+ - `buildDefaultTabs` emits STABLE semantic tab values (`details` / `related:<child>` / `related` / `activity` / `history`) instead of leaving the renderer to synthesize index-derived ones.
18
+ - `PageTabsRenderer` honors `item.value`, a host-provided `schema.defaultTab` (validated against actual tabs) and `schema.onTabChange`; index fallback kept for authored schemas without values.
19
+ - app-shell `RecordDetailView` restores the active tab from `?tab=` and writes it back with `replace` (tab switches never stack history), via the pure `withPageTabsUrlSync` page-tree injector (never mutates authored/memoized page schemas). Legacy `DetailView.autoTabs` wired to the same contract (`defaultTab`/`onTabChange`).
20
+ - Fixes the tab strip resetting to Details after save-refresh remounts (`refreshKey`-style) and dev-StrictMode URL churn; enables `?tab=` deep links; invalid values fall back to Details.
21
+
22
+ - 6c1ad9e: Record task flows open as derived overlays with lossless return (framework#2604, extends framework#2578).
23
+
24
+ - **Create/Edit never route** — the global record form is URL-driven (`?form=new` / `?form=<id>`): browser Back closes the overlay with the origin (list scroll/filters, detail state) intact; field-heavy objects derive a full-screen modal (`modalSize:'full'`) via the new `deriveRecordFlowSurface` mirror in plugin-view, light ones keep the auto-sized modal. `editMode:'page'` opt-in unchanged.
25
+ - **Save invariant** — _edit never moves you_ (origin refetches in place); _create lands on the new record's detail_ on its derived surface (drawer over the still-intact list for light objects, detail route for heavy), with `replace:true` so Back skips the transient form entry.
26
+ - **Subtable child create/edit = overlay over the parent detail, never a route** — related-list New/Edit push `?form=…&formObject=<child>&formLink=<fk>:<parentId>`; the one global overlay pre-links the parent (refresh-safe), sizes to the CHILD object, and on save stays on the parent while only the child's related lists refetch. ModalForm now forwards `initialValues` into its master-detail (subforms) branch so pre-links survive for children with inline line items.
27
+
28
+ - fbec4e1: feat(studio): pick a connector action from the chosen connector (no more hand-typed action ids)
29
+
30
+ In a flow's **Connector Action** node, the `actionId` field was a free-text box
31
+ (`sendMessage · send` placeholder) — a typo silently produced a node that fails
32
+ at run time. It was left as text because a connector's actions have "no flat
33
+ catalog"; but each connector already advertises its actions in the runtime
34
+ descriptors (`GET /api/v1/automation/connectors` → `{ name, actions:[{key,label}] }`).
35
+
36
+ `actionId` is now a **picker of the chosen connector's actions**, resolved from
37
+ the sibling `connectorId` (mirroring how `object-field` lists the fields of its
38
+ resolved object). New reference kind `connector-action` + `connectorSource` on
39
+ `FlowReferenceSpec`; `useConnectorActionOptions` fetches the descriptors and
40
+ `resolveConnectorName` reads the connector from the node's `connectorConfig`. Like
41
+ every reference in the designer it stays an **editable combobox** — with no
42
+ connector chosen (or none installed) it degrades to free text with a hint
43
+ ("Choose a Connector above to list its actions" / "Actions of <connector>.").
44
+
45
+ Closes the last critical hand-typed-identifier gap in flow-node config (the
46
+ object / field / flow / role / connector / template references were already
47
+ pickers). Unit-tested (`resolveConnectorName`, `connectorActionsToOptions`).
48
+
49
+ - 7a6837c: Studio package-create dogfood follow-ups (objectstack-ai/framework#2615):
50
+
51
+ - Read-only packages now gate authoring affordances client-side (Add field, New object/flow/permission set, nav Edit, Save draft, Publish, Create app) with a "switch to a writable package" hint, instead of letting doomed edits pile up until the server 422s (objectui#2259). Records stay fully usable; the field inspector opens read-only.
52
+ - New fields auto-derive their API name from the label while still auto-named — now also for the Data pillar's generic `field_N` names, so relabeling "New field" to "Status" yields a `status` column instead of `field_2` forever (objectui#2260).
53
+ - Publish is review-then-confirm: the header button opens the pending-changes panel, whose footer "Publish N change(s)" fires the atomic package publish; panel entries expand to a per-item field/property diff against the live version (objectui#2261).
54
+ - Create app can scaffold navigation from the package's objects (checkbox, on by default): one spec-valid object menu item per object, closing the "fresh app has zero nav" dead-end (objectui#2262).
55
+
56
+ - 5ed8d2d: feat(studio): automation enable/disable switch + live status in the Automations rail
57
+
58
+ The Automations pillar showed only an icon + label per flow, and no way to turn a
59
+ flow on or off — so an author couldn't tell whether an automation was live, or
60
+ stop one without deleting it (the header even said "Off by default · review before
61
+ enabling", but nothing reflected or controlled it). UX eval #6.
62
+
63
+ - **Live status dot** on every flow in the rail — a green "On" / gray "Off",
64
+ fetched from the engine's `GET /api/v1/automation/_status` (persisted `status`
65
+ is intent; this is what's actually enabled + bound to its trigger). Refetched
66
+ after a publish; degrades silently on an older backend. A flow the engine
67
+ doesn't know yet (never published) shows no dot — the amber "unpublished draft"
68
+ chip already covers that.
69
+ - **Enable/Disable switch** in the flow header. It flips the flow's deployment
70
+ `status` (active ↔ obsolete) and saves the draft immediately; the change goes
71
+ live when the package is published (so "review before enabling" is preserved).
72
+ Pairs with framework's engine-side gate (`obsolete`/`invalid` → not bound).
73
+
74
+ New `engine.studio.auto.*` i18n keys (en + zh). Unit-tested (`FlowStatusDot`:
75
+ enabled→On, disabled→Off, no-state→nothing, bound-vs-unbound tooltip). Verified in
76
+ a live browser: the rail shows a green "On" against every showcase flow and the
77
+ header switch reads "Enabled".
78
+
79
+ - 70c4a3f: Studio package-create dogfood follow-ups (framework#2615 — P2 wizard + P3 polish):
80
+
81
+ - **Package-id wizard feedback.** The three package wizards (switcher create,
82
+ landing create, landing duplicate) share a new `PackageIdInput`: illegal
83
+ characters are still normalized away, but no longer silently — a notice
84
+ says what was removed — a reverse-domain format hint shows while the id
85
+ doesn't parse, and a CJK-only name that yields no id suggestion is told to
86
+ type one manually instead of leaving the id box mysteriously empty.
87
+ - **Records-grid duplicate "Actions" column.** A field literally named
88
+ `actions` is now dropped from the Studio grid's data columns, so it no
89
+ longer collides with the always-pinned row-actions column (it stays
90
+ editable in the form designer).
91
+ - **Record-create verb consistency.** The `ObjectView` toolbar create button
92
+ resolved a hardcoded English "Create"; it now uses the same
93
+ `console.objectView.new` ("New" / 新建) key as the runtime object pages so
94
+ Studio and the running app agree.
95
+ - **Branded cold-load splash.** The console's pre-auth loading gate rendered a
96
+ bare "Loading…"; it now shows the branded, boot-safe `LoadingScreen`.
97
+ - **Picklist option editor.** Value/label inputs and CJK option labels no
98
+ longer truncate — the six controls that shared one cramped row are split
99
+ into a two-row layout so the inputs get the full panel width.
100
+ - **Draft-save confirmation.** The Data pillar's "Save draft" now shows a
101
+ success toast and a "last saved HH:MM" indicator, matching the App and
102
+ Automations pillars.
103
+
104
+ ### Patch Changes
105
+
106
+ - ec6bb16: Studio Automations rail now shows authored-but-unpublished (draft) flows.
107
+
108
+ The Automations pillar loaded its rail with `client.list('flow', …)` only, which
109
+ returns published/active metadata — so a flow authored (saved as a draft) but not
110
+ yet published was invisible in the rail, even while the "Changes · N" counter
111
+ showed a pending draft existed. Every sibling pillar (Data / Interfaces / Access)
112
+ already merged `client.listDrafts`; Automations was the sole outlier.
113
+
114
+ The published ∪ draft merge is extracted into a shared, unit-tested
115
+ `loadPackageSurfaces` helper and adopted by the Automations pillar, which also now
116
+ re-reads on `publishNonce` so drafts that go live collapse back into the published
117
+ rail after a package publish. A draft-only flow now appears in its rail (badged
118
+ "Unpublished draft"), is selectable, and loads its draft body for editing —
119
+ matching the other pillars. Fixes the empty-rail report for writable-base packages
120
+ whose flows are all still drafts.
121
+
122
+ - 4fbf910: Stop double-firing action toasts on record-detail script actions and the delete handler.
123
+
124
+ `ActionRunner.handlePostExecution` already surfaces a result's `error` as a toast
125
+ (and a success toast unless `silent`). Two handlers ALSO toasted themselves while
126
+ returning `{success:false, error}` (or a non-`silent` success), so on a runner
127
+ seeded with `onToast` the same message fired twice:
128
+
129
+ - **`RecordDetailView` `serverActionHandler`** (script actions): the HTTP/inner-fail
130
+ branch and the catch branch each called `toast.error` before returning the error.
131
+ #2177 fixed the twin in `useConsoleActionRuntime` (interface pages) but not this
132
+ copy, so record-detail script-action failures (e.g. a `RECORD_LOCKED` from an
133
+ approval-locked record) still showed the error twice for everyone on the published
134
+ console bundle. Both branches now return the error and let the runner toast it once.
135
+
136
+ - **`useObjectActions` `delete` handler** (ObjectView list/detail deletes): kept its
137
+ richer localized toast (label + description, or the bulk succeeded/failed summary)
138
+ and now returns WITHOUT `error` on failure so the runner doesn't re-toast it, and
139
+ marks successful deletes `silent` so the runner doesn't append a second generic
140
+ "Action completed successfully" toast.
141
+
142
+ Adds `useObjectActions.test.tsx` asserting exactly one toast on delete
143
+ success / failure / partial-bulk-failure.
144
+
145
+ - 6f15e43: test(studio): extend the create-conformance gate to the inline pillar creators
146
+
147
+ `createConformance.test.ts` guards that every authorable type's default
148
+ create-form output passes spec validation — catching the recurring "the designer
149
+ emits a minimal shape the spec rejects, so create→save 422s" dead-end family. But
150
+ it read only the metadata-admin registry, so the Studio's **inline** "New X"
151
+ creators (Data → object, Automations → flow, Interfaces → app, Access →
152
+ permission) — which build their skeletons directly in `StudioDesignSurface.tsx`,
153
+ bypassing the registry — were **uncovered**. A future edit to one of those shapes
154
+ could turn its "New" button into a silent dead-end with nothing to catch it.
155
+
156
+ Extracted the four inline skeletons into pure, exported builders
157
+ (`studio-design/skeletons.ts`) consumed by BOTH the pillars and a new gate block,
158
+ so the test can't drift from what the "New" button actually emits. No behavior
159
+ change — the builders return the byte-identical skeletons. The gate now covers all
160
+ create paths (registry + inline); the four inline skeletons validate clean.
161
+
162
+ - Updated dependencies [544d8eb]
163
+ - Updated dependencies [6fffd3d]
164
+ - Updated dependencies [9255686]
165
+ - Updated dependencies [fae75e2]
166
+ - Updated dependencies [1072701]
167
+ - @object-ui/i18n@11.5.0
168
+ - @object-ui/react@11.5.0
169
+ - @object-ui/components@11.5.0
170
+ - @object-ui/types@11.5.0
171
+ - @object-ui/data-objectstack@11.5.0
172
+ - @object-ui/fields@11.5.0
173
+ - @object-ui/layout@11.5.0
174
+ - @object-ui/plugin-editor@11.5.0
175
+ - @object-ui/auth@11.5.0
176
+ - @object-ui/collaboration@11.5.0
177
+ - @object-ui/core@11.5.0
178
+ - @object-ui/permissions@11.5.0
179
+ - @object-ui/providers@11.5.0
180
+
3
181
  ## 11.4.0
4
182
 
5
183
  ### Minor Changes
package/README.md CHANGED
@@ -152,6 +152,15 @@ for each metadata type. Every type has a pure-renderer **preview** that doubles
152
152
  as its **designer** when given `editing` + `onPatch` props — no backend round
153
153
  trip is required to edit a draft.
154
154
 
155
+ ### App → Studio reverse bridge
156
+
157
+ Inside a running app, workspace admins get a "Design in Studio" entry in the
158
+ top bar (`AppHeader`) that deep-links to the app's owning package on the Studio
159
+ design surface (`/studio/:packageId/data`). It is the reverse of the builder's
160
+ "Open app" bridge (ADR-0080): the entry only renders for admins and only when
161
+ the app has an owning package (`_packageId`), and package writability stays a
162
+ server-side concern — a read-only package opens in Studio as browse-only.
163
+
155
164
  ### Studio package scope
156
165
 
157
166
  Studio treats the selected package as the authoring scope. The package selector
@@ -9,12 +9,13 @@ import { useEffect, useMemo } from 'react';
9
9
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@object-ui/components';
10
10
  import { useObjectTranslation } from '@object-ui/i18n';
11
11
  import { useUrlOverlay } from '../hooks/useUrlOverlay';
12
+ import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams';
12
13
  export function KeyboardShortcutsDialog() {
13
14
  const { t } = useObjectTranslation();
14
15
  // URL-addressable (?shortcuts=1) so the dialog is deep-linkable and openable
15
16
  // from the header Help menu, not only via the `?` keyboard accelerator (ADR-0054
16
17
  // C1/C2/C3).
17
- const { open, setOpen, toggleOverlay } = useUrlOverlay('shortcuts');
18
+ const { open, setOpen, toggleOverlay } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);
18
19
  const shortcutGroups = useMemo(() => [
19
20
  {
20
21
  title: t('console.shortcuts.groups.general'),
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * AuthGuard, AdapterProvider, MetadataProvider, theme/toaster, /home, /login,
9
9
  * /organizations) is provided by `createConsole` from @object-ui/app-shell.
10
10
  */
11
- import { Routes, Route, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom';
11
+ import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams } from 'react-router-dom';
12
12
  import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo } from 'react';
13
13
  import { useAssistant } from '../assistant/assistantBus';
14
14
  import { ModalForm } from '@object-ui/plugin-form';
15
15
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
16
16
  import { toast } from 'sonner';
17
- import { useActionRunner, useGlobalUndo } from '@object-ui/react';
17
+ import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react';
18
18
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
19
19
  import { useAuth } from '@object-ui/auth';
20
20
  import { useMetadata } from '../providers/MetadataProvider';
@@ -23,7 +23,9 @@ import { usePreviewDrafts } from '../preview/PreviewModeContext';
23
23
  import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState';
24
24
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
25
25
  import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
26
- import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
26
+ import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget } from '../utils/recordFormNavigation';
27
+ import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
28
+ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams';
27
29
  import { matchAppBySegment } from '../utils/appRoute';
28
30
  import { resolveHref } from '@object-ui/layout';
29
31
  import { ExpressionEvaluator } from '@object-ui/core';
@@ -45,6 +47,7 @@ const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default
45
47
  const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
46
48
  const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
47
49
  const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
50
+ const ObjectDataPage = lazy(() => import('../views/ObjectDataPage').then(m => ({ default: m.ObjectDataPage })));
48
51
  // Metadata admin — mounted under /apps/:app/metadata. Lives at the top
49
52
  // level so URLs read like a normal nested resource (RFC-style) instead of
50
53
  // piggy-backing on the legacy ComponentRegistry fan-out.
@@ -201,30 +204,80 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
201
204
  navigate(`/apps/${seg}/component/developer/packages`, { replace: true });
202
205
  }
203
206
  }, [activeApp?.name, appName, location.pathname, navigate]);
204
- const [isDialogOpen, setIsDialogOpen] = useState(false);
205
- const [editingRecord, setEditingRecord] = useState(null);
207
+ // #2604 the create/edit overlay is URL-driven (`?form=new` / `?form=<id>`),
208
+ // not component state: the record form is a TASK overlay over the origin
209
+ // route, and putting its open-state in the URL makes browser Back close the
210
+ // overlay (returning to the intact origin) instead of abandoning the route
211
+ // with the overlay marooned on top. Same pattern as the detail drawer's
212
+ // `?recordId=…` (useUrlOverlay / ADR-0054 C3). Open pushes a history entry;
213
+ // every close strips the param with `replace` so no stale reopen-entry stays
214
+ // ahead in history.
215
+ const [searchParams, setSearchParams] = useSearchParams();
216
+ const recordFormParam = searchParams.get(RECORD_FORM_PARAM);
217
+ // #2604 D3 — child-task extension of the record-form URL contract:
218
+ // `formObject` names the object the form edits when it is NOT the route's
219
+ // object (a subtable child opened over its parent's detail); `formLink`
220
+ // ("field:id") pre-links the parent on create. Keeping the whole task in
221
+ // the URL means Back closes the overlay and a refresh reopens it still
222
+ // correctly parent-linked — no transient component state to lose.
223
+ const formObjectParam = searchParams.get(RECORD_FORM_OBJECT_PARAM);
224
+ const formLinkParam = searchParams.get(RECORD_FORM_LINK_PARAM);
225
+ const editingRecord = useMemo(() => (recordFormParam && recordFormParam !== 'new' ? { id: recordFormParam } : null), [recordFormParam]);
226
+ const formLinkValues = useMemo(() => {
227
+ if (!formLinkParam)
228
+ return undefined;
229
+ const i = formLinkParam.indexOf(':');
230
+ if (i <= 0)
231
+ return undefined;
232
+ return { [formLinkParam.slice(0, i)]: formLinkParam.slice(i + 1) };
233
+ }, [formLinkParam]);
206
234
  const [refreshKey, setRefreshKey] = useState(0);
235
+ const isDialogOpen = !!recordFormParam;
236
+ // Close the record-form overlay by stripping `?form` in place. Reads the
237
+ // LIVE location (not the hook's render-time snapshot) so it stays a no-op
238
+ // when a success handler already navigated away in the same tick (the
239
+ // post-create redirect below).
240
+ const closeRecordForm = useCallback(() => {
241
+ const sp = new URLSearchParams(window.location.search);
242
+ if (!sp.has(RECORD_FORM_PARAM) && !sp.has(RECORD_FORM_OBJECT_PARAM) && !sp.has(RECORD_FORM_LINK_PARAM))
243
+ return;
244
+ sp.delete(RECORD_FORM_PARAM);
245
+ sp.delete(RECORD_FORM_OBJECT_PARAM);
246
+ sp.delete(RECORD_FORM_LINK_PARAM);
247
+ setSearchParams(sp, { replace: true });
248
+ }, [setSearchParams]);
207
249
  const { execute: executeAction, runner } = useActionRunner();
250
+ // objectui#2269 — bridge every dataSource write (create/update/delete →
251
+ // MutationEvent) onto the invalidation bus, ONCE for the whole console.
252
+ // Readers (record detail, related lists, count badges) refetch in place;
253
+ // nothing is remounted for a data refresh.
254
+ useMutationInvalidationBridge(dataSource);
208
255
  useGlobalUndo({
209
256
  dataSource: dataSource ?? undefined,
210
257
  onUndo: (op) => {
211
258
  toast.info(`Undo: ${op.description}`, { duration: 4000 });
212
259
  setRefreshKey(k => k + 1);
260
+ // Precisely-scoped invalidation — UndoableOperation carries the target
261
+ // (objectName + recordId), so detail readers refresh in place too.
262
+ if (op?.objectName)
263
+ notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
213
264
  },
214
265
  onRedo: (op) => {
215
266
  toast.info(`Redo: ${op.description}`, { duration: 3000 });
216
267
  setRefreshKey(k => k + 1);
268
+ if (op?.objectName)
269
+ notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
217
270
  },
218
271
  });
219
272
  useEffect(() => {
220
273
  runner.registerHandler('crud_success', async (action) => {
221
- setIsDialogOpen(false);
274
+ closeRecordForm();
222
275
  setRefreshKey(k => k + 1);
223
276
  toast.success(action.params?.message ?? 'Record saved successfully');
224
277
  return { success: true, reload: true };
225
278
  });
226
279
  runner.registerHandler('dialog_cancel', async () => {
227
- setIsDialogOpen(false);
280
+ closeRecordForm();
228
281
  return { success: true };
229
282
  });
230
283
  // Page-mode navigation handlers — declarative counterparts to the
@@ -274,7 +327,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
274
327
  // `flow` handler on this top-level useActionRunner — it lives on a
275
328
  // different ActionRunner instance and would never be invoked from the
276
329
  // record/list action buttons.
277
- }, [runner, navigate, appName]);
330
+ }, [runner, navigate, appName, closeRecordForm]);
278
331
  useEffect(() => {
279
332
  if (!dataSource)
280
333
  return;
@@ -296,8 +349,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
296
349
  objectNameFromPath = '';
297
350
  }
298
351
  const currentObjectDef = allObjects.find((o) => o.name === objectNameFromPath);
352
+ // The object the record-form overlay edits: the route's object by default,
353
+ // or the `formObject` child override (#2604 D3 — subtable child task opened
354
+ // over its parent's detail).
355
+ const formObjectDef = formObjectParam
356
+ ? allObjects.find((o) => o.name === formObjectParam)
357
+ : currentObjectDef;
358
+ const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;
299
359
  const handleCrudSuccess = useCallback(() => {
300
- const label = currentObjectDef ? objectLabel(currentObjectDef) : t('common.record', { defaultValue: 'Record' });
360
+ const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
301
361
  executeAction({
302
362
  type: 'crud_success',
303
363
  params: {
@@ -306,10 +366,45 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
306
366
  : t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }),
307
367
  },
308
368
  });
309
- }, [executeAction, editingRecord, currentObjectDef, objectLabel, t]);
369
+ }, [executeAction, editingRecord, formObjectDef, objectLabel, t]);
310
370
  const handleDialogCancel = useCallback(() => {
311
371
  executeAction({ type: 'dialog_cancel' });
312
372
  }, [executeAction]);
373
+ // #2604 save invariant — *edit never moves you; create takes you to the
374
+ // record you made.* Edit save: the origin route is untouched (crud_success
375
+ // bumps refreshKey → origin refetches in place). Create save: land on the
376
+ // new record's detail, on ITS derived surface — a light object's detail is
377
+ // the drawer over the still-intact list; a heavy one is the detail route.
378
+ // `replace: true` swaps out the transient `?form=…` entry so Back returns
379
+ // to the pre-create origin.
380
+ const handleRecordFormSuccess = useCallback(async (saved) => {
381
+ // Child task (#2604 D3): the parent detail must stay EXACTLY as it was —
382
+ // active tab, scroll, everything. So do NOT go through crud_success;
383
+ // the child's open related lists refetch on their own via the
384
+ // invalidation bus (#2269): the dataSource write already emitted a
385
+ // MutationEvent that the bridge fans out — no manual notify needed.
386
+ if (isChildFormTask) {
387
+ const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
388
+ toast.success(editingRecord
389
+ ? t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` })
390
+ : t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }));
391
+ closeRecordForm();
392
+ return;
393
+ }
394
+ handleCrudSuccess();
395
+ if (editingRecord || !currentObjectDef)
396
+ return;
397
+ const target = resolvePostCreateTarget({
398
+ objectName: currentObjectDef.name,
399
+ baseUrl: appName ? `/apps/${appName}` : (activeApp?.name ? `/apps/${activeApp.name}` : ''),
400
+ pathname: location.pathname,
401
+ search: window.location.search,
402
+ surface: deriveRecordSurface(currentObjectDef),
403
+ recordId: saved?.id ?? saved?._id,
404
+ });
405
+ if (target.kind !== 'none')
406
+ navigate(target.url, { replace: true });
407
+ }, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appName, activeApp?.name, location.pathname, navigate, closeRecordForm, objectLabel, t]);
313
408
  // Track recent items on route change.
314
409
  useTrackRouteAsRecent({
315
410
  pathname: location.pathname,
@@ -330,8 +425,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
330
425
  navigate(target.url);
331
426
  return;
332
427
  }
333
- setEditingRecord(record);
334
- setIsDialogOpen(true);
428
+ // Open the overlay via the URL (pushes one history entry → Back closes
429
+ // the overlay, origin intact). `new` = create; a record id = edit.
430
+ const rawId = record?.id ?? record?._id;
431
+ const sp = new URLSearchParams(window.location.search);
432
+ sp.set(RECORD_FORM_PARAM, rawId != null && rawId !== '' ? String(rawId) : 'new');
433
+ // Top-level task on the route's own object — drop any stale child-task
434
+ // overrides (see the record-form URL contract above).
435
+ sp.delete(RECORD_FORM_OBJECT_PARAM);
436
+ sp.delete(RECORD_FORM_LINK_PARAM);
437
+ setSearchParams(sp);
335
438
  };
336
439
  const handleAppChange = (newAppName) => {
337
440
  navigate(`/apps/${newAppName}`);
@@ -395,12 +498,27 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
395
498
  // overview instead of a blank `<Navigate to="">`.
396
499
  const landing = resolveLandingRoute(activeApp, { currentUserId: user?.id ?? null });
397
500
  return landing ? _jsx(Navigate, { to: landing, replace: true }) : _jsx(StudioHomePage, {});
398
- })() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "component/metadata/directory", element: _jsx(LegacyMetadataRedirect, { mode: "directory" }) }), _jsx(Route, { path: "component/metadata/resource/*", element: _jsx(LegacyMetadataRedirect, { mode: "resource" }) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
501
+ })() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/data", element: _jsx(ObjectDataPage, { dataSource: dataSource, objects: allObjects }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "component/metadata/directory", element: _jsx(LegacyMetadataRedirect, { mode: "directory" }) }), _jsx(Route, { path: "component/metadata/resource/*", element: _jsx(LegacyMetadataRedirect, { mode: "resource" }) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), formObjectDef && (_jsx(ModalForm, { schema: {
399
502
  type: 'object-form',
400
503
  formType: 'modal',
401
- objectName: currentObjectDef.name,
504
+ objectName: formObjectDef.name,
402
505
  mode: editingRecord ? 'edit' : 'create',
403
506
  recordId: editingRecord?.id,
507
+ // Child create task (#2604 D3): pre-link the parent from the
508
+ // `formLink` URL param (refresh-safe — the link survives).
509
+ ...(formLinkValues && !editingRecord ? { initialValues: formLinkValues } : {}),
510
+ // #2604 D1: create/edit follow the flow-surface derivation —
511
+ // field-heavy → full-screen modal (the same big canvas the
512
+ // detail page gets, with overlay return semantics); light
513
+ // objects keep the existing auto-sized modal (ModalForm
514
+ // infers from columns when modalSize is unset). Derived
515
+ // default only — anything spread later (form view layout)
516
+ // would win. Child tasks size to the CHILD object's def.
517
+ ...(deriveRecordFlowSurface(formObjectDef, isChildFormTask
518
+ ? (editingRecord ? 'child-edit' : 'child-create')
519
+ : (editingRecord ? 'edit' : 'create')).size === 'full'
520
+ ? { modalSize: 'full' }
521
+ : {}),
404
522
  // Honor the object's DEFAULT FORM VIEW: curated sections (field
405
523
  // selection + order + grouping), `contentLayout: 'tabbed'` when the
406
524
  // view is tabbed, and inline child collections (master-detail).
@@ -408,30 +526,31 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
408
526
  // win over the flat `fields` list below; otherwise this resolves to
409
527
  // {} and `fields` (every field, raw schema order) is used as before.
410
528
  // `formType` stays 'modal' (the container). (#1890 / ADR-0050.)
411
- ...resolveFormViewLayout(currentObjectDef),
529
+ ...resolveFormViewLayout(formObjectDef),
412
530
  title: editingRecord
413
- ? t('form.editTitle', { object: objectLabel(currentObjectDef) })
414
- : t('form.createTitle', { object: objectLabel(currentObjectDef) }),
531
+ ? t('form.editTitle', { object: objectLabel(formObjectDef) })
532
+ : t('form.createTitle', { object: objectLabel(formObjectDef) }),
415
533
  description: editingRecord
416
- ? t('form.editDescription', { object: objectLabel(currentObjectDef) })
417
- : t('form.createDescription', { object: objectLabel(currentObjectDef) }),
534
+ ? t('form.editDescription', { object: objectLabel(formObjectDef) })
535
+ : t('form.createDescription', { object: objectLabel(formObjectDef) }),
418
536
  open: isDialogOpen,
419
- onOpenChange: setIsDialogOpen,
537
+ onOpenChange: (open) => { if (!open)
538
+ closeRecordForm(); },
420
539
  layout: 'vertical',
421
- fields: currentObjectDef.fields
422
- ? (Array.isArray(currentObjectDef.fields)
423
- ? currentObjectDef.fields
540
+ fields: formObjectDef.fields
541
+ ? (Array.isArray(formObjectDef.fields)
542
+ ? formObjectDef.fields
424
543
  .filter((f) => {
425
544
  if (typeof f === 'string')
426
545
  return true;
427
546
  return evaluateVisibility(f.visible, expressionEvaluator);
428
547
  })
429
548
  .map((f) => typeof f === 'string' ? f : f.name)
430
- : Object.entries(currentObjectDef.fields)
549
+ : Object.entries(formObjectDef.fields)
431
550
  .filter(([_, f]) => evaluateVisibility(f.visible, expressionEvaluator))
432
551
  .map(([key]) => key))
433
552
  : [],
434
- onSuccess: handleCrudSuccess,
553
+ onSuccess: handleRecordFormSuccess,
435
554
  onCancel: handleDialogCancel,
436
555
  showSubmit: true,
437
556
  showCancel: true,
@@ -439,7 +558,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
439
558
  ? t('form.update', { defaultValue: t('common.save', { defaultValue: 'Save' }) })
440
559
  : t('form.create', { defaultValue: t('common.create', { defaultValue: 'Create' }) }),
441
560
  cancelText: t('common.cancel'),
442
- }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
561
+ }, dataSource: dataSource }, `${formObjectDef.name}:${editingRecord?.id || 'new'}`))] })] }));
443
562
  }
444
563
  function findFirstRoute(items, ctx) {
445
564
  if (!items || items.length === 0)
@@ -26,9 +26,16 @@ import { FavoritesProvider } from '../context/FavoritesProvider';
26
26
  import { RecentItemsProvider } from '../context/RecentItemsProvider';
27
27
  import { UserStateAdaptersProvider, useAttachUserStateAdapters, } from '../context/UserStateAdapters';
28
28
  import { ThemeProvider } from '../chrome/ThemeProvider';
29
+ import { LoadingScreen } from '../chrome/LoadingScreen';
29
30
  import { RemediationOverlay } from './RemediationOverlay';
31
+ // The console's every pre-React / pre-auth gate (Suspense fallback, adapter
32
+ // not ready, org/auth loading) renders this. It used to be a bare, unbranded
33
+ // "Loading…" line — ~8s of blank chrome before the login redirect on a cold
34
+ // /_console load (framework#2615 P3). Delegate to the branded, boot-safe
35
+ // splash (logo + product name + step list) that the rest of the shell already
36
+ // uses, so the cold load looks like the product from the first frame.
30
37
  export function LoadingFallback() {
31
- return (_jsx("div", { className: "h-screen flex items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }));
38
+ return _jsx(LoadingScreen, {});
32
39
  }
33
40
  /**
34
41
  * Provide a MODAL handler at the console ROOT — the same level the global
@@ -19,9 +19,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
19
19
  */
20
20
  import { createContext, useContext, useEffect, useMemo, } from 'react';
21
21
  import { useUrlOverlay } from '../hooks/useUrlOverlay';
22
+ import { COMMAND_PALETTE_PARAM } from '../urlParams';
22
23
  const CommandPaletteContext = createContext(null);
23
24
  export function CommandPaletteProvider({ children }) {
24
- const { open, setOpen, openOverlay, closeOverlay, toggleOverlay } = useUrlOverlay('palette', {
25
+ const { open, setOpen, openOverlay, closeOverlay, toggleOverlay } = useUrlOverlay(COMMAND_PALETTE_PARAM, {
25
26
  alias: 'cmdk',
26
27
  });
27
28
  // ⌘K / Ctrl+K accelerator. Lives here (not in CommandPalette) so the command
@@ -58,14 +58,21 @@ export function useObjectActions({ objectName, objectLabel, dataSource, onEdit,
58
58
  label: objectLabel || objectName,
59
59
  defaultValue: `Deleted ${succeeded} ${objectLabel || objectName} records`,
60
60
  }));
61
- return { success: true, reload: true };
61
+ // `silent`: the handler already toasted the localized summary above;
62
+ // without this the runner's post-execution hook adds a second, generic
63
+ // "Action completed successfully" toast (double success toast).
64
+ return { success: true, reload: true, silent: true };
62
65
  }
63
66
  toast.error(t('objectActions.bulkDeletePartial', {
64
67
  succeeded,
65
68
  failed,
66
69
  defaultValue: `${succeeded} deleted, ${failed} failed`,
67
70
  }));
68
- return { success: false, error: `${failed} failed` };
71
+ // The toast above is the authoritative feedback (it carries the
72
+ // succeeded/failed summary the runner can't reconstruct). Return
73
+ // WITHOUT `error` so the ActionRunner post-execution hook — this runner
74
+ // has a toastHandler (onToast) — doesn't fire a second, duplicate toast.
75
+ return { success: false };
69
76
  }
70
77
  const recordId = action.params?.recordId ??
71
78
  action.params?.record?.id ??
@@ -77,13 +84,18 @@ export function useObjectActions({ objectName, objectLabel, dataSource, onEdit,
77
84
  await dataSource.delete(objectName, recordId);
78
85
  onRefresh?.();
79
86
  toast.success(t('objectActions.deleteSuccess', { label: objectLabel || objectName }));
80
- return { success: true, reload: true };
87
+ // `silent`: handler owns the localized success toast above suppress the
88
+ // runner's generic duplicate (see the bulk branch).
89
+ return { success: true, reload: true, silent: true };
81
90
  }
82
91
  catch (err) {
83
92
  toast.error(t('objectActions.deleteFailed', { label: objectLabel || objectName }), {
84
93
  description: err.message,
85
94
  });
86
- return { success: false, error: err.message };
95
+ // Keep the richer toast above (label + error description) and return
96
+ // WITHOUT `error` so the ActionRunner post-execution hook doesn't toast
97
+ // the raw message a second time. See the bulk branch for the rationale.
98
+ return { success: false };
87
99
  }
88
100
  });
89
101
  // Handler: navigate