Skip to content

Commit 24570bd

Browse files
Copilothotlong
andauthored
feat(app-shell): add page-mode record form route
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/9bbb4b9a-dc45-41ea-b03e-55d3172b7dde Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent a20e037 commit 24570bd

9 files changed

Lines changed: 972 additions & 1 deletion

File tree

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

Lines changed: 56 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,47 @@ 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+
runner.registerHandler('navigate_create', async (action: any) => {
149+
const ctx = (runner as any).context ?? {};
150+
const result = resolveNavigateCreateUrl({
151+
action,
152+
context: ctx,
153+
defaultBaseUrl: `/apps/${appName ?? ''}`,
154+
});
155+
if (!result.success) return result;
156+
navigate(result.url);
157+
return { success: true };
158+
});
159+
160+
runner.registerHandler('navigate_edit', async (action: any) => {
161+
const ctx = (runner as any).context ?? {};
162+
const result = resolveNavigateEditUrl({
163+
action,
164+
context: ctx,
165+
defaultBaseUrl: `/apps/${appName ?? ''}`,
166+
});
167+
if (!result.success) return result;
168+
navigate(result.url);
169+
return { success: true };
170+
});
171+
136172
// NOTE: `flow` actions are handled at the per-view ActionProvider level
137173
// (RecordDetailView / ObjectView) so they share the same ActionRunner that
138174
// <action:button> renderers consume via useAction(). Do NOT register a
139175
// `flow` handler on this top-level useActionRunner — it lives on a
140176
// different ActionRunner instance and would never be invoked from the
141177
// record/list action buttons.
142-
}, [runner]);
178+
}, [runner, navigate, appName]);
143179

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

220256
const handleEdit = (record: any) => {
257+
// Page-mode opt-in: when the object metadata declares
258+
// `editMode: 'page'`, route to the full-screen create/edit page instead
259+
// of opening the global ModalForm. Default behavior (modal) is
260+
// preserved for any object without the flag.
261+
const target = resolveRecordFormTarget({
262+
objectDef: currentObjectDef as any,
263+
baseUrl: activeApp?.name ? `/apps/${activeApp.name}` : '',
264+
record,
265+
});
266+
if (target.kind === 'page') {
267+
navigate(target.url);
268+
return;
269+
}
221270
setEditingRecord(record);
222271
setIsDialogOpen(true);
223272
};
@@ -299,12 +348,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
299348
<Route path=":objectName" element={
300349
<ObjectView dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
301350
} />
351+
<Route path=":objectName/new" element={
352+
<RecordFormPage mode="create" />
353+
} />
302354
<Route path=":objectName/view/:viewId" element={
303355
<ObjectView dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
304356
} />
305357
<Route path=":objectName/record/:recordId" element={
306358
<RecordDetailView key={refreshKey} dataSource={dataSource} objects={allObjects} onEdit={handleEdit} />
307359
} />
360+
<Route path=":objectName/record/:recordId/edit" element={
361+
<RecordFormPage mode="edit" />
362+
} />
308363
<Route path="dashboard/:dashboardName" element={<DashboardView dataSource={dataSource} />} />
309364
<Route path="report/:reportName" element={<ReportView dataSource={dataSource} />} />
310365
<Route path="page/:pageName" element={<PageView />} />

packages/app-shell/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,14 @@ export {
8888
export {
8989
ObjectView,
9090
RecordDetailView,
91+
RecordFormPage,
9192
DashboardView,
9293
PageView,
9394
ReportView,
9495
SearchResultsPage,
9596
ViewConfigPanel,
9697
} from './views';
98+
export type { RecordFormPageProps } from './views';
9799

98100
// Hooks
99101
export {
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Tests for resolveRecordFormTarget — the modal-vs-page decision used by
3+
* `AppContent.handleEdit` when the user clicks "Create" or "Edit" on a
4+
* record. Covers:
5+
* - default (no editMode) → modal
6+
* - editMode: 'modal' → modal (explicit opt-out)
7+
* - editMode: 'page' + null/undefined record → create URL
8+
* - editMode: 'page' + record with id → edit URL
9+
* - editMode: 'page' + record with _id (Mongo-style) → edit URL
10+
* - URL encoding of record ids that contain reserved characters
11+
* - missing objectDef → modal (defensive fallback)
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import {
16+
resolveRecordFormTarget,
17+
resolveNavigateCreateUrl,
18+
resolveNavigateEditUrl,
19+
} from '../recordFormNavigation';
20+
21+
describe('resolveRecordFormTarget', () => {
22+
const baseUrl = '/apps/sales';
23+
24+
it('returns modal when editMode is unset (default)', () => {
25+
expect(
26+
resolveRecordFormTarget({
27+
objectDef: { name: 'account' },
28+
baseUrl,
29+
record: null,
30+
}),
31+
).toEqual({ kind: 'modal' });
32+
});
33+
34+
it('returns modal when editMode is explicitly "modal"', () => {
35+
expect(
36+
resolveRecordFormTarget({
37+
objectDef: { name: 'account', editMode: 'modal' },
38+
baseUrl,
39+
record: { id: 'r1' },
40+
}),
41+
).toEqual({ kind: 'modal' });
42+
});
43+
44+
it('returns the create URL when editMode is "page" and no record is provided', () => {
45+
expect(
46+
resolveRecordFormTarget({
47+
objectDef: { name: 'account', editMode: 'page' },
48+
baseUrl,
49+
record: null,
50+
}),
51+
).toEqual({ kind: 'page', url: '/apps/sales/account/new' });
52+
});
53+
54+
it('returns the edit URL when editMode is "page" and record has id', () => {
55+
expect(
56+
resolveRecordFormTarget({
57+
objectDef: { name: 'account', editMode: 'page' },
58+
baseUrl,
59+
record: { id: 'r1' },
60+
}),
61+
).toEqual({ kind: 'page', url: '/apps/sales/account/record/r1/edit' });
62+
});
63+
64+
it('falls back to record._id (Mongo-style) when id is missing', () => {
65+
expect(
66+
resolveRecordFormTarget({
67+
objectDef: { name: 'account', editMode: 'page' },
68+
baseUrl,
69+
record: { _id: 'mongo-7' },
70+
}),
71+
).toEqual({ kind: 'page', url: '/apps/sales/account/record/mongo-7/edit' });
72+
});
73+
74+
it('URL-encodes record ids with reserved characters', () => {
75+
expect(
76+
resolveRecordFormTarget({
77+
objectDef: { name: 'account', editMode: 'page' },
78+
baseUrl,
79+
record: { id: 'a/b c' },
80+
}),
81+
).toEqual({
82+
kind: 'page',
83+
url: '/apps/sales/account/record/a%2Fb%20c/edit',
84+
});
85+
});
86+
87+
it('treats an empty-string id as create-mode', () => {
88+
expect(
89+
resolveRecordFormTarget({
90+
objectDef: { name: 'account', editMode: 'page' },
91+
baseUrl,
92+
record: { id: '' },
93+
}),
94+
).toEqual({ kind: 'page', url: '/apps/sales/account/new' });
95+
});
96+
97+
it('treats numeric ids correctly', () => {
98+
expect(
99+
resolveRecordFormTarget({
100+
objectDef: { name: 'account', editMode: 'page' },
101+
baseUrl,
102+
record: { id: 42 },
103+
}),
104+
).toEqual({ kind: 'page', url: '/apps/sales/account/record/42/edit' });
105+
});
106+
107+
it('returns modal when objectDef is missing (defensive fallback)', () => {
108+
expect(
109+
resolveRecordFormTarget({
110+
objectDef: null,
111+
baseUrl,
112+
record: { id: 'r1' },
113+
}),
114+
).toEqual({ kind: 'modal' });
115+
});
116+
117+
it('returns modal when objectDef has no name', () => {
118+
expect(
119+
resolveRecordFormTarget({
120+
objectDef: { name: '', editMode: 'page' },
121+
baseUrl,
122+
record: { id: 'r1' },
123+
}),
124+
).toEqual({ kind: 'modal' });
125+
});
126+
});
127+
128+
describe('resolveNavigateCreateUrl', () => {
129+
const defaultBaseUrl = '/apps/sales';
130+
131+
it('uses params.objectName as primary source', () => {
132+
expect(
133+
resolveNavigateCreateUrl({
134+
action: { params: { objectName: 'lead' } },
135+
defaultBaseUrl,
136+
}),
137+
).toEqual({ success: true, url: '/apps/sales/lead/new' });
138+
});
139+
140+
it('falls back to action.objectName when params.objectName is missing', () => {
141+
expect(
142+
resolveNavigateCreateUrl({
143+
action: { objectName: 'lead' },
144+
defaultBaseUrl,
145+
}),
146+
).toEqual({ success: true, url: '/apps/sales/lead/new' });
147+
});
148+
149+
it('falls back to context.objectName as last resort', () => {
150+
expect(
151+
resolveNavigateCreateUrl({
152+
action: {},
153+
context: { objectName: 'lead' },
154+
defaultBaseUrl,
155+
}),
156+
).toEqual({ success: true, url: '/apps/sales/lead/new' });
157+
});
158+
159+
it('prefers context.baseUrl over defaultBaseUrl', () => {
160+
expect(
161+
resolveNavigateCreateUrl({
162+
action: { params: { objectName: 'lead' } },
163+
context: { baseUrl: '/apps/marketing' },
164+
defaultBaseUrl,
165+
}),
166+
).toEqual({ success: true, url: '/apps/marketing/lead/new' });
167+
});
168+
169+
it('errors when objectName cannot be resolved', () => {
170+
expect(
171+
resolveNavigateCreateUrl({
172+
action: {},
173+
defaultBaseUrl,
174+
}),
175+
).toEqual({
176+
success: false,
177+
error: 'navigate_create: objectName is required',
178+
});
179+
});
180+
});
181+
182+
describe('resolveNavigateEditUrl', () => {
183+
const defaultBaseUrl = '/apps/sales';
184+
185+
it('builds the edit URL from params', () => {
186+
expect(
187+
resolveNavigateEditUrl({
188+
action: { params: { objectName: 'lead', recordId: 'L-7' } },
189+
defaultBaseUrl,
190+
}),
191+
).toEqual({ success: true, url: '/apps/sales/lead/record/L-7/edit' });
192+
});
193+
194+
it('falls back to action.objectName + action.recordId', () => {
195+
expect(
196+
resolveNavigateEditUrl({
197+
action: { objectName: 'lead', recordId: 'L-7' },
198+
defaultBaseUrl,
199+
}),
200+
).toEqual({ success: true, url: '/apps/sales/lead/record/L-7/edit' });
201+
});
202+
203+
it('uses context.objectName but never context.recordId', () => {
204+
// recordId must always come from the action — every record click
205+
// should provide its own id.
206+
expect(
207+
resolveNavigateEditUrl({
208+
action: { params: { recordId: 'L-7' } },
209+
context: { objectName: 'lead' },
210+
defaultBaseUrl,
211+
}),
212+
).toEqual({ success: true, url: '/apps/sales/lead/record/L-7/edit' });
213+
});
214+
215+
it('URL-encodes record ids', () => {
216+
expect(
217+
resolveNavigateEditUrl({
218+
action: { params: { objectName: 'lead', recordId: 'a/b c' } },
219+
defaultBaseUrl,
220+
}),
221+
).toEqual({
222+
success: true,
223+
url: '/apps/sales/lead/record/a%2Fb%20c/edit',
224+
});
225+
});
226+
227+
it('errors when objectName is missing', () => {
228+
expect(
229+
resolveNavigateEditUrl({
230+
action: { params: { recordId: 'L-7' } },
231+
defaultBaseUrl,
232+
}),
233+
).toEqual({
234+
success: false,
235+
error: 'navigate_edit: objectName and recordId are required',
236+
});
237+
});
238+
239+
it('errors when recordId is missing', () => {
240+
expect(
241+
resolveNavigateEditUrl({
242+
action: { params: { objectName: 'lead' } },
243+
defaultBaseUrl,
244+
}),
245+
).toEqual({
246+
success: false,
247+
error: 'navigate_edit: objectName and recordId are required',
248+
});
249+
});
250+
251+
it('errors when recordId is empty string', () => {
252+
expect(
253+
resolveNavigateEditUrl({
254+
action: { params: { objectName: 'lead', recordId: '' } },
255+
defaultBaseUrl,
256+
}),
257+
).toEqual({
258+
success: false,
259+
error: 'navigate_edit: objectName and recordId are required',
260+
});
261+
});
262+
});

packages/app-shell/src/utils/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
* Utility functions for ObjectStack Console
33
*/
44

5+
export {
6+
resolveRecordFormTarget,
7+
} from './recordFormNavigation';
8+
export type {
9+
ObjectDefinitionForNavigation,
10+
RecordFormTarget,
11+
} from './recordFormNavigation';
12+
513
/**
614
* Resolves an I18nLabel to a plain string.
715
* I18nLabel can be either a string or an object { key, defaultValue?, params? }.

0 commit comments

Comments
 (0)