Skip to content

Commit 2743f19

Browse files
authored
Merge pull request #1296 from objectstack-ai/copilot/add-fullscreen-edit-route
2 parents a20e037 + 1c49f52 commit 2743f19

19 files changed

Lines changed: 1259 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Page-mode record forms (`editMode: 'page'`).** New per-object
13+
metadata flag that opts a record's create/edit form into a dedicated
14+
full-screen route instead of the default modal dialog. Routes are
15+
scoped under the active app:
16+
- `GET /apps/:appName/:objectName/new` — create page
17+
- `GET /apps/:appName/:objectName/record/:recordId/edit` — edit page
18+
19+
These URLs are deep-linkable, refresh-safe, and integrate with the
20+
browser back button — making them suitable for long forms,
21+
multi-tab/wizard layouts, and copy-paste-share workflows. The same
22+
`<ObjectForm>` pipeline drives both modes, so every existing field /
23+
section / `formType` configuration carries over unchanged.
24+
25+
Two new declarative actions, `navigate_create` and `navigate_edit`,
26+
let JSON `<action:button>` schemas open the page routes directly
27+
without any host-app code changes. Default behavior (modal) is
28+
preserved for any object that does not set `editMode`.
29+
30+
Implemented in `@object-ui/app-shell` (new `RecordFormPage` view +
31+
routes in `AppContent`). Type added to `@object-ui/types`
32+
(`ObjectSchemaMetadata.editMode`). New guide at
33+
`content/docs/guide/record-edit-modes.md`.
34+
1035
### Fixed
1136

1237
- **`@object-ui/plugin-list` & `@object-ui/plugin-detail`: shared `ComponentRegistry` singleton broken when consumed via published dist.** Both packages' `vite.config.ts` only listed `react`, `react-dom`, `react/jsx-runtime` in `rollupOptions.external`. As a result Rolldown inlined `@object-ui/core` (and a large chunk of lucide-react) into each plugin's bundle, giving every plugin its **own private `ComponentRegistry` instance**. Downstream apps that loaded the published plugins from npm saw `registry.get('object-grid')` from one plugin return `undefined` for components registered by another plugin — only workspace `alias` overrides pointing back to `packages/*/src` masked the issue. The `external` config now uses a regex covering all `@object-ui/*` workspace packages, the `@object-ui/*` deps have been moved from `dependencies` to `peerDependencies` (versioned `workspace:^` for publish-time rewrite, mirrored in `devDependencies` for local builds), and a regression test (`packages/core/src/registry/__tests__/cross-plugin-singleton.test.ts`) imports the built dist of `@object-ui/plugin-grid` and `@object-ui/plugin-list` and asserts cross-plugin lookup works against the same `ComponentRegistry` singleton. Downstream consumers (e.g. framework `apps/dashboard`) can now drop any `OBJECTUI_ROOT` / `packages/*/src` aliases.

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
> - 🛑 **Spec v4 native opt-ins** for the platform layer (CLI v4 plugin options, multi-tenant v4 resolver) — engine-level work, no immediate end-user impact.
1818
1919
> **Recently Completed (queued in `.changeset/` for next release):**
20+
> - 🪟 Page-mode record forms — `editMode: 'page'` opens create/edit on a deep-linkable, refresh-safe full-screen route alongside the existing modal flow
2021
> - 📱 Mobile UX rounds 1 & 2 — Kanban readability, Calendar mobile default, Timeline dot clipping, list/sidebar/record-detail polish
2122
> - 🪟 `plugin-view` Manage Views dialog (replaces inline tab drag)
2223
> - 🧭 App-shell view-tab strip for single-view objects + DropdownMenu propagation fix

content/docs/guide/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"plugins",
1717
"plugin-development",
1818
"building-crud-app",
19+
"record-edit-modes",
1920
"console",
2021
"console-architecture",
2122
"objectos-integration",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
---
2+
title: Record Edit Modes
3+
description: Choose between modal and full-page record forms with the editMode metadata flag.
4+
---
5+
6+
# Record Edit Modes
7+
8+
ObjectUI's default console shell (`@object-ui/app-shell`) supports two ways
9+
to render the create/edit form for a record:
10+
11+
- **Modal** (default) — the form opens in an overlay dialog above the
12+
current view. Best for short forms, quick edits, and contextual data
13+
entry.
14+
- **Page** — the form takes over a full route. Best for long forms,
15+
multi-tab or wizard layouts, or anywhere you need a deep-linkable URL
16+
that survives a refresh and integrates with the browser back button.
17+
18+
Both modes use the same `<ObjectForm>` pipeline under the hood, so all
19+
field types, sections, validations, and visibility expressions work
20+
identically in either mode.
21+
22+
## Choosing a mode
23+
24+
Set `editMode` on the object metadata:
25+
26+
```jsonc
27+
// metadata/objects/account.json
28+
{
29+
"name": "account",
30+
"label": "Account",
31+
"editMode": "page", // "modal" (default) | "page"
32+
"fields": {
33+
"name": { "type": "text", "label": "Name", "required": true },
34+
"industry": { "type": "picklist", "label": "Industry" },
35+
"owner": { "type": "lookup", "label": "Owner", "reference_to": "user" }
36+
}
37+
}
38+
```
39+
40+
Omitting `editMode` (or setting it to `"modal"`) keeps the existing
41+
behavior — clicking **Create** or **Edit** opens the global `ModalForm`
42+
overlay.
43+
44+
## URL patterns
45+
46+
When `editMode: "page"` is set, the console renders the form on a
47+
dedicated route under the active app:
48+
49+
| Action | URL |
50+
|--------|-----|
51+
| Create | `/apps/:appName/:objectName/new` |
52+
| Edit | `/apps/:appName/:objectName/record/:recordId/edit` |
53+
54+
Examples (for an app `sales` and an object `account`):
55+
56+
- Create: `https://your-console.example/apps/sales/account/new`
57+
- Edit: `https://your-console.example/apps/sales/account/record/0015e000abcd/edit`
58+
59+
These URLs are stable. Users can bookmark them, share them in chat, or
60+
refresh the page mid-edit (the form rehydrates from the URL `:recordId`).
61+
62+
## Triggering the routes from JSON
63+
64+
In addition to the implicit "click create/edit on a list" entry point,
65+
two declarative actions let you open the page-mode routes from any
66+
`<action:button>` in metadata:
67+
68+
```jsonc
69+
{
70+
"type": "action:button",
71+
"label": "New Account",
72+
"icon": "plus",
73+
"action": {
74+
"action": "navigate_create",
75+
"params": { "objectName": "account" }
76+
}
77+
}
78+
```
79+
80+
```jsonc
81+
{
82+
"type": "action:button",
83+
"label": "Edit",
84+
"icon": "pencil",
85+
"action": {
86+
"action": "navigate_edit",
87+
"params": {
88+
"objectName": "account",
89+
"recordId": "${record.id}"
90+
}
91+
}
92+
}
93+
```
94+
95+
When invoked from inside an `ObjectView`, the action context already
96+
carries the active `objectName`, so `objectName` may be omitted from the
97+
`params`:
98+
99+
```jsonc
100+
{
101+
"type": "action:button",
102+
"label": "New",
103+
"action": { "action": "navigate_create" }
104+
}
105+
```
106+
107+
## Behavior summary
108+
109+
| Aspect | Modal | Page |
110+
|--------|-------|------|
111+
| Default |||
112+
| Deep-linkable URL |||
113+
| Survives refresh |||
114+
| Back button closes form | n/a ||
115+
| Best for | quick edits | long / multi-section forms |
116+
117+
## Migrating an existing object
118+
119+
The change is additive — existing apps continue to work unchanged. To
120+
migrate a single object to page mode:
121+
122+
1. Add `"editMode": "page"` to the object metadata.
123+
2. (Optional) Adjust the form layout — page mode pairs well with
124+
`formType: "tabbed"` or `formType: "wizard"` for long forms.
125+
3. Reload the console. Existing **Create** / **Edit** entry points
126+
automatically route to the new pages; no UI code changes required.
127+
128+
## See also
129+
130+
- [`@object-ui/app-shell` README](https://www.objectui.org/docs/layout/app-shell)
131+
- [`ObjectForm` API](../plugins/plugin-form.md)
132+
- [Schema rendering](./schema-rendering.md)

packages/app-shell/CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# @object-ui/app-shell — Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- **Page-mode record forms.** Objects can now opt into a route-driven
8+
full-screen create/edit experience by setting `editMode: 'page'` on the
9+
object metadata (default remains `'modal'`). When opted in, the
10+
console mounts two new routes under `/apps/:appName/`:
11+
- `:objectName/new` for create
12+
- `:objectName/record/:recordId/edit` for edit
13+
14+
URLs are deep-linkable, refresh-safe, and respect the browser back
15+
button. The new `RecordFormPage` view renders inside the existing
16+
`ConsoleLayout` chrome and reuses the same `<ObjectForm>` pipeline as
17+
the modal flow, so every existing form configuration (sections,
18+
visibility expressions, validations, `formType: 'tabbed' | 'wizard'`,
19+
…) works without changes.
20+
21+
Two declarative actions expose the routes for `<action:button>` JSON:
22+
- `{ "action": "navigate_create", "params": { "objectName": "..." } }`
23+
- `{ "action": "navigate_edit", "params": { "objectName": "...", "recordId": "..." } }`
24+
25+
When called from inside an `ObjectView` the `objectName` falls back to
26+
the action context, so it can be omitted from the params.
27+
28+
See `content/docs/guide/record-edit-modes.md` for a walkthrough.
29+
30+
- New view: `packages/app-shell/src/views/RecordFormPage.tsx`
31+
- New helpers: `resolveRecordFormTarget`, `resolveNavigateCreateUrl`,
32+
`resolveNavigateEditUrl` in
33+
`packages/app-shell/src/utils/recordFormNavigation.ts`
34+
- Tests: `RecordFormPage.test.tsx` (6) and
35+
`recordFormNavigation.test.ts` (22), all passing.
36+
337
## 4.0.1
438

539
### Patch Changes

packages/app-shell/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,60 @@ See `examples/byo-backend-console` for a complete working example that demonstra
175175
- Cherry-picking only needed components
176176
- Building a console in ~100 lines of code
177177

178+
## Record create/edit modes
179+
180+
The default `<DefaultAppContent>` shell mounts a global `<ModalForm>` for
181+
record create/edit interactions. Each object can opt in to a route-driven
182+
full-screen experience instead by setting `editMode` on its metadata:
183+
184+
```jsonc
185+
// objects/account.json
186+
{
187+
"name": "account",
188+
"label": "Account",
189+
"editMode": "page", // ← opt-in. Default is "modal".
190+
"fields": { /* ... */ }
191+
}
192+
```
193+
194+
When `editMode: 'page'` is set, clicking **Create** or **Edit** for an
195+
`account` record navigates to a dedicated route instead of opening the
196+
dialog:
197+
198+
| Action | URL |
199+
|--------|-----|
200+
| Create | `/apps/:appName/account/new` |
201+
| Edit | `/apps/:appName/account/record/:recordId/edit` |
202+
203+
These routes are deep-linkable (refresh-safe), respect the browser back
204+
button, and render the same `<ObjectForm>` pipeline as the modal — so
205+
`tabbed`, `wizard`, and section configurations work in both modes.
206+
207+
JSON `<action:button>` schemas can also trigger the page routes directly
208+
via the action runner, regardless of the object's `editMode`:
209+
210+
```json
211+
{
212+
"type": "action:button",
213+
"label": "New Account",
214+
"action": { "action": "navigate_create", "params": { "objectName": "account" } }
215+
}
216+
```
217+
218+
```json
219+
{
220+
"type": "action:button",
221+
"label": "Edit",
222+
"action": {
223+
"action": "navigate_edit",
224+
"params": { "objectName": "account", "recordId": "${record.id}" }
225+
}
226+
}
227+
```
228+
229+
See [`content/docs/guide/record-edit-modes.md`](../../content/docs/guide/record-edit-modes.md)
230+
for a longer walkthrough.
231+
178232
<!-- release-metadata:v3.3.0 -->
179233

180234
## Compatibility

packages/app-shell/src/console/AppContent.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useMetadata } from '../providers/MetadataProvider';
2121
import { useAdapter } from '../providers/AdapterProvider';
2222
import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
2323
import { useRecentItems } from '../hooks/useRecentItems';
24+
import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
2425
import { ExpressionEvaluator } from '@object-ui/core';
2526

2627
// Components (eagerly loaded — always needed)
@@ -39,6 +40,7 @@ const DashboardView = lazy(() => import('../views/DashboardView').then(m => ({ d
3940
const PageView = lazy(() => import('../views/PageView').then(m => ({ default: m.PageView })));
4041
const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default: m.ReportView })));
4142
const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
43+
const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
4244

4345
// Designer pages — sourced from @object-ui/plugin-designer so third-party hosts
4446
// can opt out by not registering these routes.
@@ -133,13 +135,54 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
133135
return { success: true };
134136
});
135137

138+
// Page-mode navigation handlers — declarative counterparts to the
139+
// imperative `handleEdit` callback. These let JSON schemas open the
140+
// full-screen create/edit pages directly via `<action:button>` without
141+
// any custom code:
142+
// { "action": "navigate_create", "params": { "objectName": "..." } }
143+
// { "action": "navigate_edit",
144+
// "params": { "objectName": "...", "recordId": "..." } }
145+
// The `objectName` param falls back to the action context's
146+
// `objectName` (set per view) so action buttons mounted inside an
147+
// ObjectView can omit it.
148+
// NOTE on duplication below: each handler reads `runner.getContext()`
149+
// INSIDE its closure (at action-invocation time) rather than once at
150+
// registration. Hoisting the call outside the registrations would
151+
// freeze the context to whatever it was when the effect last ran,
152+
// breaking dynamic per-view `runner.updateContext({ objectName, ... })`
153+
// calls (used by ObjectView / RecordDetailView). Keep the call where
154+
// it is.
155+
runner.registerHandler('navigate_create', async (action: any) => {
156+
const ctx = runner.getContext?.() ?? {};
157+
const result = resolveNavigateCreateUrl({
158+
action,
159+
context: ctx,
160+
defaultBaseUrl: `/apps/${appName ?? ''}`,
161+
});
162+
if (!result.success) return result;
163+
navigate(result.url);
164+
return { success: true };
165+
});
166+
167+
runner.registerHandler('navigate_edit', async (action: any) => {
168+
const ctx = runner.getContext?.() ?? {};
169+
const result = resolveNavigateEditUrl({
170+
action,
171+
context: ctx,
172+
defaultBaseUrl: `/apps/${appName ?? ''}`,
173+
});
174+
if (!result.success) return result;
175+
navigate(result.url);
176+
return { success: true };
177+
});
178+
136179
// NOTE: `flow` actions are handled at the per-view ActionProvider level
137180
// (RecordDetailView / ObjectView) so they share the same ActionRunner that
138181
// <action:button> renderers consume via useAction(). Do NOT register a
139182
// `flow` handler on this top-level useActionRunner — it lives on a
140183
// different ActionRunner instance and would never be invoked from the
141184
// record/list action buttons.
142-
}, [runner]);
185+
}, [runner, navigate, appName]);
143186

144187
useEffect(() => {
145188
if (!dataSource) return;
@@ -218,6 +261,19 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
218261
}, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps
219262

220263
const handleEdit = (record: any) => {
264+
// Page-mode opt-in: when the object metadata declares
265+
// `editMode: 'page'`, route to the full-screen create/edit page instead
266+
// of opening the global ModalForm. Default behavior (modal) is
267+
// preserved for any object without the flag.
268+
const target = resolveRecordFormTarget({
269+
objectDef: currentObjectDef as any,
270+
baseUrl: activeApp?.name ? `/apps/${activeApp.name}` : '',
271+
record,
272+
});
273+
if (target.kind === 'page') {
274+
navigate(target.url);
275+
return;
276+
}
221277
setEditingRecord(record);
222278
setIsDialogOpen(true);
223279
};
@@ -299,12 +355,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
299355
<Route path=":objectName" element={
300356
<ObjectView dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
301357
} />
358+
<Route path=":objectName/new" element={
359+
<RecordFormPage mode="create" />
360+
} />
302361
<Route path=":objectName/view/:viewId" element={
303362
<ObjectView dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
304363
} />
305364
<Route path=":objectName/record/:recordId" element={
306365
<RecordDetailView key={refreshKey} dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
307366
} />
367+
<Route path=":objectName/record/:recordId/edit" element={
368+
<RecordFormPage mode="edit" />
369+
} />
308370
<Route path="dashboard/:dashboardName" element={<DashboardView dataSource={dataSource} />} />
309371
<Route path="report/:reportName" element={<ReportView dataSource={dataSource} />} />
310372
<Route path="page/:pageName" element={<PageView />} />

0 commit comments

Comments
 (0)