@object-ui/app-shell 4.0.1 → 4.0.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 4be43e2: **Page-mode record forms (`editMode: 'page'`).** New per-object metadata flag that opts a record's create/edit form into a dedicated full-screen route (`/apps/:appName/:objectName/new`, `/apps/:appName/:objectName/record/:recordId/edit`). Two new declarative actions `navigate_create` and `navigate_edit` open these routes from JSON action buttons. Default modal behavior is preserved for objects that do not set `editMode`.
8
+
9
+ **`@object-ui/plugin-list` & `@object-ui/plugin-detail`: `ComponentRegistry` singleton fix.** Both plugins' Vite configs now mark all `@object-ui/*` packages as external so each plugin no longer bundles its own private copy of `@object-ui/core`. Cross-plugin component lookups now resolve correctly from the same singleton registry. `plugin-list` dist shrank from multi-MB to 67 kB (gzip 16 kB); `plugin-detail` to 124 kB (gzip 28 kB).
10
+
11
+ **`@object-ui/app-shell` `CreateViewDialog` churn fix.** `existingSet` is now memoised on the joined string key of `existingLabels` rather than the raw array reference, preventing the name-suggest `useEffect` from re-firing on every parent render.
12
+
13
+ **CI fixes.** `ReportViewer` conditional-formatting test now accepts both `rgb(...)` and hex color representations. `ObjectView` i18n mocks rewritten to mirror the real hook shapes (`useObjectTranslation`, `useObjectLabel`).
14
+
15
+ - Updated dependencies [4be43e2]
16
+ - @object-ui/types@4.0.3
17
+ - @object-ui/core@4.0.3
18
+ - @object-ui/i18n@4.0.3
19
+ - @object-ui/react@4.0.3
20
+ - @object-ui/components@4.0.3
21
+ - @object-ui/fields@4.0.3
22
+ - @object-ui/layout@4.0.3
23
+ - @object-ui/data-objectstack@4.0.3
24
+ - @object-ui/auth@4.0.3
25
+ - @object-ui/permissions@4.0.3
26
+ - @object-ui/plugin-calendar@4.0.3
27
+ - @object-ui/plugin-charts@4.0.3
28
+ - @object-ui/plugin-chatbot@4.0.3
29
+ - @object-ui/plugin-dashboard@4.0.3
30
+ - @object-ui/plugin-designer@4.0.3
31
+ - @object-ui/plugin-detail@4.0.3
32
+ - @object-ui/plugin-form@4.0.3
33
+ - @object-ui/plugin-grid@4.0.3
34
+ - @object-ui/plugin-kanban@4.0.3
35
+ - @object-ui/plugin-list@4.0.3
36
+ - @object-ui/plugin-report@4.0.3
37
+ - @object-ui/plugin-view@4.0.3
38
+ - @object-ui/collaboration@4.0.3
39
+
40
+ ## Unreleased
41
+
42
+ ### Added
43
+
44
+ - **Page-mode record forms.** Objects can now opt into a route-driven
45
+ full-screen create/edit experience by setting `editMode: 'page'` on the
46
+ object metadata (default remains `'modal'`). When opted in, the
47
+ console mounts two new routes under `/apps/:appName/`:
48
+ - `:objectName/new` for create
49
+ - `:objectName/record/:recordId/edit` for edit
50
+
51
+ URLs are deep-linkable, refresh-safe, and respect the browser back
52
+ button. The new `RecordFormPage` view renders inside the existing
53
+ `ConsoleLayout` chrome and reuses the same `<ObjectForm>` pipeline as
54
+ the modal flow, so every existing form configuration (sections,
55
+ visibility expressions, validations, `formType: 'tabbed' | 'wizard'`,
56
+ …) works without changes.
57
+
58
+ Two declarative actions expose the routes for `<action:button>` JSON:
59
+ - `{ "action": "navigate_create", "params": { "objectName": "..." } }`
60
+ - `{ "action": "navigate_edit", "params": { "objectName": "...", "recordId": "..." } }`
61
+
62
+ When called from inside an `ObjectView` the `objectName` falls back to
63
+ the action context, so it can be omitted from the params.
64
+
65
+ See `content/docs/guide/record-edit-modes.md` for a walkthrough.
66
+ - New view: `packages/app-shell/src/views/RecordFormPage.tsx`
67
+ - New helpers: `resolveRecordFormTarget`, `resolveNavigateCreateUrl`,
68
+ `resolveNavigateEditUrl` in
69
+ `packages/app-shell/src/utils/recordFormNavigation.ts`
70
+ - Tests: `RecordFormPage.test.tsx` (6) and
71
+ `recordFormNavigation.test.ts` (22), all passing.
72
+
3
73
  ## 4.0.1
4
74
 
5
75
  ### Patch Changes
package/README.md CHANGED
@@ -175,6 +175,60 @@ See `examples/byo-backend-console` for a complete working example that demonstra
175
175
  - Cherry-picking only needed components
176
176
  - Building a console in ~100 lines of code
177
177
 
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
+
178
232
  <!-- release-metadata:v3.3.0 -->
179
233
 
180
234
  ## Compatibility
@@ -13,13 +13,14 @@ import { useState, useEffect, useCallback, lazy, Suspense, useMemo } from 'react
13
13
  import { ModalForm } from '@object-ui/plugin-form';
14
14
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
15
15
  import { toast } from 'sonner';
16
- import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react';
16
+ import { useActionRunner, useGlobalUndo } from '@object-ui/react';
17
17
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
18
18
  import { useAuth } from '@object-ui/auth';
19
19
  import { useMetadata } from '../providers/MetadataProvider';
20
20
  import { useAdapter } from '../providers/AdapterProvider';
21
21
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
22
22
  import { useRecentItems } from '../hooks/useRecentItems';
23
+ import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
23
24
  import { ExpressionEvaluator } from '@object-ui/core';
24
25
  // Components (eagerly loaded — always needed)
25
26
  import { ConsoleLayout } from '../layout/ConsoleLayout';
@@ -36,6 +37,7 @@ const DashboardView = lazy(() => import('../views/DashboardView').then(m => ({ d
36
37
  const PageView = lazy(() => import('../views/PageView').then(m => ({ default: m.PageView })));
37
38
  const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default: m.ReportView })));
38
39
  const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
40
+ const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
39
41
  // Designer pages — sourced from @object-ui/plugin-designer so third-party hosts
40
42
  // can opt out by not registering these routes.
41
43
  const CreateAppPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.CreateAppPage })));
@@ -104,13 +106,54 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
104
106
  setIsDialogOpen(false);
105
107
  return { success: true };
106
108
  });
109
+ // Page-mode navigation handlers — declarative counterparts to the
110
+ // imperative `handleEdit` callback. These let JSON schemas open the
111
+ // full-screen create/edit pages directly via `<action:button>` without
112
+ // any custom code:
113
+ // { "action": "navigate_create", "params": { "objectName": "..." } }
114
+ // { "action": "navigate_edit",
115
+ // "params": { "objectName": "...", "recordId": "..." } }
116
+ // The `objectName` param falls back to the action context's
117
+ // `objectName` (set per view) so action buttons mounted inside an
118
+ // ObjectView can omit it.
119
+ // NOTE on duplication below: each handler reads `runner.getContext()`
120
+ // INSIDE its closure (at action-invocation time) rather than once at
121
+ // registration. Hoisting the call outside the registrations would
122
+ // freeze the context to whatever it was when the effect last ran,
123
+ // breaking dynamic per-view `runner.updateContext({ objectName, ... })`
124
+ // calls (used by ObjectView / RecordDetailView). Keep the call where
125
+ // it is.
126
+ runner.registerHandler('navigate_create', async (action) => {
127
+ const ctx = runner.getContext?.() ?? {};
128
+ const result = resolveNavigateCreateUrl({
129
+ action,
130
+ context: ctx,
131
+ defaultBaseUrl: `/apps/${appName ?? ''}`,
132
+ });
133
+ if (!result.success)
134
+ return result;
135
+ navigate(result.url);
136
+ return { success: true };
137
+ });
138
+ runner.registerHandler('navigate_edit', async (action) => {
139
+ const ctx = runner.getContext?.() ?? {};
140
+ const result = resolveNavigateEditUrl({
141
+ action,
142
+ context: ctx,
143
+ defaultBaseUrl: `/apps/${appName ?? ''}`,
144
+ });
145
+ if (!result.success)
146
+ return result;
147
+ navigate(result.url);
148
+ return { success: true };
149
+ });
107
150
  // NOTE: `flow` actions are handled at the per-view ActionProvider level
108
151
  // (RecordDetailView / ObjectView) so they share the same ActionRunner that
109
152
  // <action:button> renderers consume via useAction(). Do NOT register a
110
153
  // `flow` handler on this top-level useActionRunner — it lives on a
111
154
  // different ActionRunner instance and would never be invoked from the
112
155
  // record/list action buttons.
113
- }, [runner]);
156
+ }, [runner, navigate, appName]);
114
157
  useEffect(() => {
115
158
  if (!dataSource)
116
159
  return;
@@ -185,6 +228,19 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
185
228
  }
186
229
  }, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps
187
230
  const handleEdit = (record) => {
231
+ // Page-mode opt-in: when the object metadata declares
232
+ // `editMode: 'page'`, route to the full-screen create/edit page instead
233
+ // of opening the global ModalForm. Default behavior (modal) is
234
+ // preserved for any object without the flag.
235
+ const target = resolveRecordFormTarget({
236
+ objectDef: currentObjectDef,
237
+ baseUrl: activeApp?.name ? `/apps/${activeApp.name}` : '',
238
+ record,
239
+ });
240
+ if (target.kind === 'page') {
241
+ navigate(target.url);
242
+ return;
243
+ }
188
244
  setEditingRecord(record);
189
245
  setIsDialogOpen(true);
190
246
  };
@@ -208,41 +264,41 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
208
264
  const expressionUser = user
209
265
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
210
266
  : { name: 'Anonymous', email: '', role: 'guest' };
211
- return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsxs(SchemaRendererProvider, { dataSource: dataSource || {}, children: [_jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: findFirstRoute(activeApp.navigation || []), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _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: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _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, {}) }), extraRoutes] }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
212
- type: 'object-form',
213
- formType: 'modal',
214
- objectName: currentObjectDef.name,
215
- mode: editingRecord ? 'edit' : 'create',
216
- recordId: editingRecord?.id,
217
- title: editingRecord
218
- ? t('form.editTitle', { object: objectLabel(currentObjectDef) })
219
- : t('form.createTitle', { object: objectLabel(currentObjectDef) }),
220
- description: editingRecord
221
- ? t('form.editDescription', { object: objectLabel(currentObjectDef) })
222
- : t('form.createDescription', { object: objectLabel(currentObjectDef) }),
223
- open: isDialogOpen,
224
- onOpenChange: setIsDialogOpen,
225
- layout: 'vertical',
226
- fields: currentObjectDef.fields
227
- ? (Array.isArray(currentObjectDef.fields)
228
- ? currentObjectDef.fields
229
- .filter((f) => {
230
- if (typeof f === 'string')
231
- return true;
232
- return evaluateVisibility(f.visible, expressionEvaluator);
233
- })
234
- .map((f) => typeof f === 'string' ? f : f.name)
235
- : Object.entries(currentObjectDef.fields)
236
- .filter(([_, f]) => evaluateVisibility(f.visible, expressionEvaluator))
237
- .map(([key]) => key))
238
- : [],
239
- onSuccess: handleCrudSuccess,
240
- onCancel: handleDialogCancel,
241
- showSubmit: true,
242
- showCancel: true,
243
- submitText: t('form.saveRecord'),
244
- cancelText: t('common.cancel'),
245
- }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] })] }));
267
+ return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: findFirstRoute(activeApp.navigation || []), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _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 }) }), _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: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _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, {}) }), extraRoutes] }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
268
+ type: 'object-form',
269
+ formType: 'modal',
270
+ objectName: currentObjectDef.name,
271
+ mode: editingRecord ? 'edit' : 'create',
272
+ recordId: editingRecord?.id,
273
+ title: editingRecord
274
+ ? t('form.editTitle', { object: objectLabel(currentObjectDef) })
275
+ : t('form.createTitle', { object: objectLabel(currentObjectDef) }),
276
+ description: editingRecord
277
+ ? t('form.editDescription', { object: objectLabel(currentObjectDef) })
278
+ : t('form.createDescription', { object: objectLabel(currentObjectDef) }),
279
+ open: isDialogOpen,
280
+ onOpenChange: setIsDialogOpen,
281
+ layout: 'vertical',
282
+ fields: currentObjectDef.fields
283
+ ? (Array.isArray(currentObjectDef.fields)
284
+ ? currentObjectDef.fields
285
+ .filter((f) => {
286
+ if (typeof f === 'string')
287
+ return true;
288
+ return evaluateVisibility(f.visible, expressionEvaluator);
289
+ })
290
+ .map((f) => typeof f === 'string' ? f : f.name)
291
+ : Object.entries(currentObjectDef.fields)
292
+ .filter(([_, f]) => evaluateVisibility(f.visible, expressionEvaluator))
293
+ .map(([key]) => key))
294
+ : [],
295
+ onSuccess: handleCrudSuccess,
296
+ onCancel: handleDialogCancel,
297
+ showSubmit: true,
298
+ showCancel: true,
299
+ submitText: t('form.saveRecord'),
300
+ cancelText: t('common.cancel'),
301
+ }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
246
302
  }
247
303
  function findFirstRoute(items) {
248
304
  if (!items || items.length === 0)
@@ -13,6 +13,7 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
13
13
  import { Suspense } from 'react';
14
14
  import { Navigate, useLocation } from 'react-router-dom';
15
15
  import { AuthGuard, useAuth } from '@object-ui/auth';
16
+ import { SchemaRendererProvider } from '@object-ui/react';
16
17
  import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
17
18
  import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
18
19
  import { NavigationProvider } from '../context/NavigationContext';
@@ -50,7 +51,9 @@ function ConnectedShellInner({ children }) {
50
51
  const adapter = useAdapter();
51
52
  if (!adapter)
52
53
  return _jsx(LoadingFallback, {});
53
- return _jsx(MetadataProvider, { adapter: adapter, children: children });
54
+ // Expose the adapter via SchemaRendererContext so descendant hooks like
55
+ // useDiscovery() (used to gate the global AI chatbot) can resolve it.
56
+ return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(MetadataProvider, { adapter: adapter, children: children }) }));
54
57
  }
55
58
  /**
56
59
  * RequireOrganization — redirects to /organizations when the multi-tenant
@@ -8,13 +8,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  *
9
9
  * @module
10
10
  */
11
- import { useEffect } from 'react';
11
+ import { useEffect, Suspense, lazy } from 'react';
12
12
  import { useNavigationContext } from '../../context/NavigationContext';
13
13
  import { AppHeader } from '../../layout/AppHeader';
14
+ import { useDiscovery } from '@object-ui/react';
15
+ // Lazy-load the chatbot so its heavy markdown deps stay out of the initial
16
+ // paint until the AI assistant is actually enabled.
17
+ const ConsoleFloatingChatbot = lazy(() => import('../../layout/ConsoleFloatingChatbot'));
14
18
  export function HomeLayout({ children }) {
15
19
  const { setContext } = useNavigationContext();
20
+ const { isAiEnabled } = useDiscovery();
21
+ // Render the chatbot whenever AI is reachable. If the developer has explicitly
22
+ // configured `VITE_AI_BASE_URL`, trust that opt-in even when discovery
23
+ // reports AI as disabled (e.g. framework started without `--preset full`).
24
+ const aiBaseUrlConfigured = Boolean(import.meta.env?.VITE_AI_BASE_URL);
25
+ const showChatbot = isAiEnabled || aiBaseUrlConfigured;
16
26
  useEffect(() => {
17
27
  setContext('home');
18
28
  }, [setContext]);
19
- return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto", children: children })] }));
29
+ return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto", children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: "Workspace", objects: [] }) }))] }));
20
30
  }
package/dist/index.d.ts CHANGED
@@ -22,7 +22,8 @@ export { ConsoleShell, ConnectedShell, RequireOrganization, AuthenticatedRoute,
22
22
  export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, ConnectionStatus, ActivityFeed, LocaleSwitcher, ModeToggle, AuthPageLayout, } from './layout';
23
23
  export type { ActivityItem } from './layout';
24
24
  export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
25
- export { ObjectView, RecordDetailView, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
25
+ export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
26
+ export type { RecordFormPageProps } from './views';
26
27
  export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
27
28
  export type { FavoriteItem } from './hooks';
28
29
  export { NavigationProvider, useNavigationContext, FavoritesProvider } from './context';
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, Conn
25
25
  // Top-level chrome (dialogs, providers, error boundaries)
26
26
  export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
27
27
  // Standard inner-SPA views
28
- export { ObjectView, RecordDetailView, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
28
+ export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
29
29
  // Hooks
30
30
  export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
31
31
  // Context providers
@@ -5,6 +5,13 @@ interface ConsoleObject {
5
5
  export interface ConsoleFloatingChatbotProps {
6
6
  appLabel: string;
7
7
  objects: ConsoleObject[];
8
+ /**
9
+ * Base URL of the AI service. Defaults to `${VITE_SERVER_URL}/api/v1/ai`
10
+ * (or the relative `/api/v1/ai` when no server URL is configured).
11
+ */
12
+ apiBase?: string;
13
+ /** Default agent name to select on first render. */
14
+ defaultAgent?: string;
8
15
  }
9
- export default function ConsoleFloatingChatbot({ appLabel, objects }: ConsoleFloatingChatbotProps): import("react/jsx-runtime").JSX.Element;
16
+ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, }: ConsoleFloatingChatbotProps): import("react/jsx-runtime").JSX.Element;
10
17
  export {};
@@ -1,27 +1,96 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { FloatingChatbot, useObjectChat } from '@object-ui/plugin-chatbot';
3
- export default function ConsoleFloatingChatbot({ appLabel, objects }) {
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ConsoleFloatingChatbot
4
+ *
5
+ * Wires the global FAB chatbot to the framework's `@objectstack/service-ai`
6
+ * backend (the `/api/v1/ai/agents/:agentName/chat` Vercel Data Stream
7
+ * endpoint) and exposes an in-header agent picker.
8
+ *
9
+ * The chatbot pulls in `react-markdown` + `micromark` (~150 KB) which is
10
+ * unused on every page until the AI assistant is enabled, so deferring it
11
+ * keeps those bytes off the initial paint.
12
+ * @module
13
+ */
14
+ import React from 'react';
15
+ import { FloatingChatbot, useObjectChat, useAgents, } from '@object-ui/plugin-chatbot';
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
17
+ const DEFAULT_AI_PATH = '/api/v1/ai';
18
+ function resolveApiBase(explicit) {
19
+ if (explicit)
20
+ return explicit.replace(/\/$/, '');
21
+ const env = import.meta.env ?? {};
22
+ const fromEnv = env.VITE_AI_BASE_URL;
23
+ if (fromEnv)
24
+ return fromEnv.replace(/\/$/, '');
25
+ const serverUrl = env.VITE_SERVER_URL ?? '';
26
+ return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
27
+ }
28
+ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, }) {
4
29
  const objectNames = objects.map((o) => o.label || o.name).join(', ');
30
+ const activeAgentLabel = React.useMemo(() => {
31
+ const found = agents.find((a) => a.name === activeAgent);
32
+ return found?.label ?? activeAgent ?? appLabel;
33
+ }, [agents, activeAgent, appLabel]);
34
+ const welcomeContent = activeAgent
35
+ ? `Hello! I'm **${activeAgentLabel}**, your ${appLabel} assistant. How can I help you today?`
36
+ : `Hello! I'm your **${appLabel}** assistant. ${agentsError ? '(Backend unreachable — running in offline demo mode.)' : ''}`;
5
37
  const { messages, isLoading, error, sendMessage, stop, reload, clear, } = useObjectChat({
38
+ api: chatApi,
39
+ conversationId: activeAgent ? `${appLabel}:${activeAgent}` : undefined,
40
+ body: {
41
+ context: {
42
+ activeApp: appLabel,
43
+ objects: objects.map((o) => ({ name: o.name, label: o.label })),
44
+ agentName: activeAgent,
45
+ },
46
+ },
6
47
  initialMessages: [
7
48
  {
8
49
  id: 'welcome',
9
50
  role: 'assistant',
10
- content: `Hello! I'm your **${appLabel}** assistant. How can I help you today?`,
51
+ content: welcomeContent,
11
52
  },
12
53
  ],
13
- autoResponse: true,
54
+ // Local-mode fallback: only used when `chatApi` is undefined (no agent
55
+ // resolved yet, or no backend available). Keeps the UI usable.
56
+ autoResponse: !chatApi,
14
57
  autoResponseText: objectNames
15
58
  ? `I can help you work with ${objectNames}. What would you like to do?`
16
- : 'Thanks for your message! I\'m here to help you navigate and manage your data.',
17
- autoResponseDelay: 800,
59
+ : "Thanks for your message! I'm here to help you navigate and manage your data.",
60
+ autoResponseDelay: 600,
18
61
  });
62
+ const headerExtra = agents.length > 0 ? (_jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[180px] text-xs", "data-testid": "floating-chatbot-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "end", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[220px]", children: agent.description })) : null] }, agent.name))) })] })) : null;
19
63
  return (_jsx(FloatingChatbot, { floatingConfig: {
20
64
  position: 'bottom-right',
21
65
  defaultOpen: false,
22
- panelWidth: 400,
23
- panelHeight: 520,
66
+ panelWidth: 420,
67
+ panelHeight: 560,
24
68
  title: `${appLabel} Assistant`,
25
69
  triggerSize: 56,
26
- }, messages: messages, placeholder: "Ask anything...", onSendMessage: (content) => sendMessage(content), onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true }));
70
+ }, headerExtra: headerExtra, messages: messages, placeholder: activeAgent
71
+ ? `Ask ${activeAgentLabel}...`
72
+ : agentsLoading
73
+ ? 'Loading agents...'
74
+ : 'Ask anything...', onSendMessage: (content) => sendMessage(content), onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true }));
75
+ }
76
+ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, }) {
77
+ const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
78
+ const env = import.meta.env ?? {};
79
+ const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
80
+ const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
81
+ const [activeAgent, setActiveAgent] = React.useState(undefined);
82
+ React.useEffect(() => {
83
+ if (!activeAgent && agents.length > 0) {
84
+ const preferred = defaultAgentProp ?? envDefaultAgent;
85
+ const match = preferred ? agents.find((a) => a.name === preferred) : undefined;
86
+ setActiveAgent((match ?? agents[0]).name);
87
+ }
88
+ }, [agents, activeAgent, defaultAgentProp, envDefaultAgent]);
89
+ const chatApi = activeAgent
90
+ ? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
91
+ : undefined;
92
+ // `key` forces a clean remount whenever the active agent (and therefore the
93
+ // chat API URL) changes — required because `useObjectChat` locks its mode
94
+ // (api vs local) on first render.
95
+ return (_jsx(ChatbotInner, { appLabel: appLabel, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi }, chatApi ?? 'local'));
27
96
  }
@@ -29,6 +29,10 @@ function ConsoleLayoutInner({ children }) {
29
29
  export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState }) {
30
30
  const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
31
31
  const { isAiEnabled } = useDiscovery();
32
+ // Trust an explicit `VITE_AI_BASE_URL` opt-in even when discovery reports
33
+ // AI as disabled (e.g. framework started without `--preset full`).
34
+ const aiBaseUrlConfigured = Boolean(import.meta.env?.VITE_AI_BASE_URL);
35
+ const showChatbot = isAiEnabled || aiBaseUrlConfigured;
32
36
  const { setContext, setCurrentAppName } = useNavigationContext();
33
37
  // Set navigation context to 'app' when this layout mounts
34
38
  useEffect(() => {
@@ -45,5 +49,5 @@ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange,
45
49
  ? `${resolveI18nLabel(activeApp.label)} — ObjectStack Console`
46
50
  : undefined,
47
51
  }
48
- : undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), isAiEnabled && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: appLabel, objects: objects }) }))] }));
52
+ : undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: appLabel, objects: objects }) }))] }));
49
53
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Utility functions for ObjectStack Console
3
3
  */
4
+ export { resolveRecordFormTarget, } from './recordFormNavigation';
5
+ export type { ObjectDefinitionForNavigation, RecordFormTarget, } from './recordFormNavigation';
4
6
  /**
5
7
  * Resolves an I18nLabel to a plain string.
6
8
  * I18nLabel can be either a string or an object { key, defaultValue?, params? }.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Utility functions for ObjectStack Console
3
3
  */
4
+ export { resolveRecordFormTarget, } from './recordFormNavigation';
4
5
  /**
5
6
  * Resolves an I18nLabel to a plain string.
6
7
  * I18nLabel can be either a string or an object { key, defaultValue?, params? }.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Resolvers for record-form navigation targets.
3
+ *
4
+ * Pure helpers extracted from `AppContent.tsx` so the routing rules behind
5
+ * the modal-vs-page decision are unit-testable in isolation. Keep this file
6
+ * free of React / router imports — it must run in a node test environment.
7
+ *
8
+ * @module utils/recordFormNavigation
9
+ */
10
+ /**
11
+ * Subset of the object metadata shape consumed by the resolver. Mirrors the
12
+ * fields read from the runtime objects list (`useMetadata().objects`).
13
+ */
14
+ export interface ObjectDefinitionForNavigation {
15
+ /** API name used in URLs. */
16
+ name: string;
17
+ /** Optional UI mode override for create/edit interactions. */
18
+ editMode?: 'modal' | 'page';
19
+ }
20
+ /**
21
+ * Result of the modal-vs-page decision. `kind: 'modal'` means the caller
22
+ * should open the global `<ModalForm>`; `kind: 'page'` means the caller
23
+ * should `navigate(url)` to the full-screen create/edit route.
24
+ */
25
+ export type RecordFormTarget = {
26
+ kind: 'modal';
27
+ } | {
28
+ kind: 'page';
29
+ url: string;
30
+ };
31
+ /**
32
+ * Build a record-form navigation target.
33
+ *
34
+ * Returns:
35
+ * - `{ kind: 'modal' }` when the object metadata does not opt in to page
36
+ * mode (default behavior — preserves backward compatibility).
37
+ * - `{ kind: 'page', url }` when `objectDef.editMode === 'page'`. The
38
+ * URL points at `/{baseUrl}/{objectName}/new` for create, or
39
+ * `/{baseUrl}/{objectName}/record/{recordId}/edit` for edit. The
40
+ * `recordId` is derived from `record.id` or `record._id`, falling
41
+ * back to create-mode if neither is present.
42
+ *
43
+ * Notes:
44
+ * - Returns `{ kind: 'modal' }` when `objectDef` is missing or has no
45
+ * `name` — the caller (AppContent) treats this as "no actionable
46
+ * state" and falls back to the existing modal flow.
47
+ * - `recordId` is URL-encoded so non-ASCII / reserved characters in
48
+ * object IDs (e.g. UUIDs with `:` separators) are safe.
49
+ */
50
+ export declare function resolveRecordFormTarget(opts: {
51
+ objectDef: ObjectDefinitionForNavigation | null | undefined;
52
+ baseUrl: string;
53
+ record: {
54
+ id?: string | number;
55
+ _id?: string | number;
56
+ } | null | undefined;
57
+ }): RecordFormTarget;
58
+ /**
59
+ * Action descriptor accepted by the navigate-create / navigate-edit
60
+ * handlers. Loose-typed because the same shape is constructed dynamically
61
+ * from JSON metadata at runtime and we want the helpers to be tolerant of
62
+ * legacy or hand-authored inputs.
63
+ */
64
+ export interface NavigationActionDef {
65
+ /** Optional explicit object name (overrides any context). */
66
+ objectName?: string;
67
+ /** Optional explicit record id (only meaningful for `navigate_edit`). */
68
+ recordId?: string | number;
69
+ /** Standard params bag — preferred location for objectName / recordId. */
70
+ params?: {
71
+ objectName?: string;
72
+ recordId?: string | number;
73
+ [key: string]: any;
74
+ };
75
+ }
76
+ /**
77
+ * Optional context typically supplied by an `ActionRunner` (e.g. when the
78
+ * action button is mounted inside an `ObjectView`, the view registers its
79
+ * `objectName` and `baseUrl` on the runner so action JSON can omit them).
80
+ *
81
+ * Tolerant of additional unknown keys because the upstream
82
+ * `ActionRunner.getContext()` returns a generic `ActionContext` with an
83
+ * open index signature; we only read the two fields we care about.
84
+ */
85
+ export interface NavigationActionContext {
86
+ objectName?: string;
87
+ baseUrl?: string;
88
+ [key: string]: unknown;
89
+ }
90
+ export type NavigationActionResult = {
91
+ success: true;
92
+ url: string;
93
+ } | {
94
+ success: false;
95
+ error: string;
96
+ };
97
+ /**
98
+ * Resolve the URL for a `navigate_create` action.
99
+ *
100
+ * Order of precedence for `objectName`:
101
+ * 1. `action.params.objectName`
102
+ * 2. `action.objectName`
103
+ * 3. `context.objectName`
104
+ *
105
+ * `baseUrl` falls back to `context.baseUrl`, then to the supplied
106
+ * `defaultBaseUrl` (typically `/apps/{appName}`).
107
+ *
108
+ * Returns `{ success: false }` with a descriptive error when `objectName`
109
+ * cannot be resolved — the caller (`ActionRunner`) surfaces this to the UI.
110
+ */
111
+ export declare function resolveNavigateCreateUrl(opts: {
112
+ action: NavigationActionDef;
113
+ context?: NavigationActionContext;
114
+ defaultBaseUrl: string;
115
+ }): NavigationActionResult;
116
+ /**
117
+ * Resolve the URL for a `navigate_edit` action.
118
+ *
119
+ * Same `objectName` precedence as {@link resolveNavigateCreateUrl}.
120
+ * `recordId` is read from `action.params.recordId` or `action.recordId`
121
+ * (no context fallback — record ids are intrinsically per-action).
122
+ *
123
+ * Returns `{ success: false }` with a descriptive error when either
124
+ * `objectName` or `recordId` is missing.
125
+ */
126
+ export declare function resolveNavigateEditUrl(opts: {
127
+ action: NavigationActionDef;
128
+ context?: NavigationActionContext;
129
+ defaultBaseUrl: string;
130
+ }): NavigationActionResult;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Resolvers for record-form navigation targets.
3
+ *
4
+ * Pure helpers extracted from `AppContent.tsx` so the routing rules behind
5
+ * the modal-vs-page decision are unit-testable in isolation. Keep this file
6
+ * free of React / router imports — it must run in a node test environment.
7
+ *
8
+ * @module utils/recordFormNavigation
9
+ */
10
+ /**
11
+ * Build a record-form navigation target.
12
+ *
13
+ * Returns:
14
+ * - `{ kind: 'modal' }` when the object metadata does not opt in to page
15
+ * mode (default behavior — preserves backward compatibility).
16
+ * - `{ kind: 'page', url }` when `objectDef.editMode === 'page'`. The
17
+ * URL points at `/{baseUrl}/{objectName}/new` for create, or
18
+ * `/{baseUrl}/{objectName}/record/{recordId}/edit` for edit. The
19
+ * `recordId` is derived from `record.id` or `record._id`, falling
20
+ * back to create-mode if neither is present.
21
+ *
22
+ * Notes:
23
+ * - Returns `{ kind: 'modal' }` when `objectDef` is missing or has no
24
+ * `name` — the caller (AppContent) treats this as "no actionable
25
+ * state" and falls back to the existing modal flow.
26
+ * - `recordId` is URL-encoded so non-ASCII / reserved characters in
27
+ * object IDs (e.g. UUIDs with `:` separators) are safe.
28
+ */
29
+ export function resolveRecordFormTarget(opts) {
30
+ const { objectDef, baseUrl, record } = opts;
31
+ if (!objectDef?.name || objectDef.editMode !== 'page') {
32
+ return { kind: 'modal' };
33
+ }
34
+ const rawId = record?.id ?? record?._id;
35
+ if (record && rawId != null && rawId !== '') {
36
+ const encoded = encodeURIComponent(String(rawId));
37
+ return {
38
+ kind: 'page',
39
+ url: `${baseUrl}/${objectDef.name}/record/${encoded}/edit`,
40
+ };
41
+ }
42
+ return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new` };
43
+ }
44
+ /**
45
+ * Resolve the URL for a `navigate_create` action.
46
+ *
47
+ * Order of precedence for `objectName`:
48
+ * 1. `action.params.objectName`
49
+ * 2. `action.objectName`
50
+ * 3. `context.objectName`
51
+ *
52
+ * `baseUrl` falls back to `context.baseUrl`, then to the supplied
53
+ * `defaultBaseUrl` (typically `/apps/{appName}`).
54
+ *
55
+ * Returns `{ success: false }` with a descriptive error when `objectName`
56
+ * cannot be resolved — the caller (`ActionRunner`) surfaces this to the UI.
57
+ */
58
+ export function resolveNavigateCreateUrl(opts) {
59
+ const { action, context = {}, defaultBaseUrl } = opts;
60
+ const objectName = action.params?.objectName ?? action.objectName ?? context.objectName;
61
+ const baseUrl = context.baseUrl ?? defaultBaseUrl;
62
+ if (!objectName) {
63
+ return {
64
+ success: false,
65
+ error: 'navigate_create: objectName is required',
66
+ };
67
+ }
68
+ return { success: true, url: `${baseUrl}/${objectName}/new` };
69
+ }
70
+ /**
71
+ * Resolve the URL for a `navigate_edit` action.
72
+ *
73
+ * Same `objectName` precedence as {@link resolveNavigateCreateUrl}.
74
+ * `recordId` is read from `action.params.recordId` or `action.recordId`
75
+ * (no context fallback — record ids are intrinsically per-action).
76
+ *
77
+ * Returns `{ success: false }` with a descriptive error when either
78
+ * `objectName` or `recordId` is missing.
79
+ */
80
+ export function resolveNavigateEditUrl(opts) {
81
+ const { action, context = {}, defaultBaseUrl } = opts;
82
+ const objectName = action.params?.objectName ?? action.objectName ?? context.objectName;
83
+ const recordId = action.params?.recordId ?? action.recordId;
84
+ const baseUrl = context.baseUrl ?? defaultBaseUrl;
85
+ if (!objectName || recordId == null || recordId === '') {
86
+ return {
87
+ success: false,
88
+ error: 'navigate_edit: objectName and recordId are required',
89
+ };
90
+ }
91
+ const encoded = encodeURIComponent(String(recordId));
92
+ return {
93
+ success: true,
94
+ url: `${baseUrl}/${objectName}/record/${encoded}/edit`,
95
+ };
96
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * RecordFormPage Component
3
+ *
4
+ * Renders a full-screen create or edit page for a record. This is the
5
+ * page-mode counterpart to the global `ModalForm` mounted by `AppContent`
6
+ * for objects whose metadata declares `editMode: 'page'`.
7
+ *
8
+ * Routes (mounted by `AppContent`):
9
+ * - `/apps/:appName/:objectName/new` — create mode
10
+ * - `/apps/:appName/:objectName/record/:recordId/edit` — edit mode
11
+ *
12
+ * Behavior:
13
+ * - Resolves the object definition via `useMetadata()`. While metadata is
14
+ * still loading the page renders a `SkeletonDetail` to avoid a flash of
15
+ * "Object Not Found".
16
+ * - Delegates form rendering and data fetching to `<ObjectForm>` from
17
+ * `@object-ui/plugin-form` with `formType: 'simple'`. ObjectForm itself
18
+ * fetches the existing record (in edit mode) via `dataSource.findOne`,
19
+ * so this page does not need to manage its own loading state for
20
+ * record data.
21
+ * - On success / cancel, navigates back if the user has history, otherwise
22
+ * falls back to a sensible parent route (record detail in edit mode,
23
+ * object list in create mode). Cancel always navigates back without a
24
+ * toast.
25
+ * - Wraps the form in a sticky page header (back button + title) for a
26
+ * consistent full-screen chrome.
27
+ *
28
+ * @module views/RecordFormPage
29
+ */
30
+ export interface RecordFormPageProps {
31
+ /** Form mode — `'create'` for the `/new` route, `'edit'` for the `/edit` route. */
32
+ mode: 'create' | 'edit';
33
+ }
34
+ /**
35
+ * Full-screen record create/edit page.
36
+ *
37
+ * Reads `:objectName` (and `:recordId` when editing) from the URL and
38
+ * resolves the object definition. Renders an `<ObjectForm>` configured with
39
+ * `formType: 'simple'` (i.e. a flat in-page form), wrapped in a page header
40
+ * that mirrors the look of `RecordDetailView`.
41
+ */
42
+ export declare function RecordFormPage({ mode }: RecordFormPageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,171 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * RecordFormPage Component
4
+ *
5
+ * Renders a full-screen create or edit page for a record. This is the
6
+ * page-mode counterpart to the global `ModalForm` mounted by `AppContent`
7
+ * for objects whose metadata declares `editMode: 'page'`.
8
+ *
9
+ * Routes (mounted by `AppContent`):
10
+ * - `/apps/:appName/:objectName/new` — create mode
11
+ * - `/apps/:appName/:objectName/record/:recordId/edit` — edit mode
12
+ *
13
+ * Behavior:
14
+ * - Resolves the object definition via `useMetadata()`. While metadata is
15
+ * still loading the page renders a `SkeletonDetail` to avoid a flash of
16
+ * "Object Not Found".
17
+ * - Delegates form rendering and data fetching to `<ObjectForm>` from
18
+ * `@object-ui/plugin-form` with `formType: 'simple'`. ObjectForm itself
19
+ * fetches the existing record (in edit mode) via `dataSource.findOne`,
20
+ * so this page does not need to manage its own loading state for
21
+ * record data.
22
+ * - On success / cancel, navigates back if the user has history, otherwise
23
+ * falls back to a sensible parent route (record detail in edit mode,
24
+ * object list in create mode). Cancel always navigates back without a
25
+ * toast.
26
+ * - Wraps the form in a sticky page header (back button + title) for a
27
+ * consistent full-screen chrome.
28
+ *
29
+ * @module views/RecordFormPage
30
+ */
31
+ import { useCallback, useMemo } from 'react';
32
+ import { useParams, useNavigate, Link } from 'react-router-dom';
33
+ import { ObjectForm } from '@object-ui/plugin-form';
34
+ import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
35
+ import { ArrowLeft, Database } from 'lucide-react';
36
+ import { toast } from 'sonner';
37
+ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
38
+ import { useMetadata } from '../providers/MetadataProvider';
39
+ import { useAdapter } from '../providers/AdapterProvider';
40
+ import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
41
+ import { SkeletonDetail } from '../skeletons';
42
+ import { useAuth } from '@object-ui/auth';
43
+ import { ExpressionEvaluator } from '@object-ui/core';
44
+ /**
45
+ * Full-screen record create/edit page.
46
+ *
47
+ * Reads `:objectName` (and `:recordId` when editing) from the URL and
48
+ * resolves the object definition. Renders an `<ObjectForm>` configured with
49
+ * `formType: 'simple'` (i.e. a flat in-page form), wrapped in a page header
50
+ * that mirrors the look of `RecordDetailView`.
51
+ */
52
+ export function RecordFormPage({ mode }) {
53
+ const { appName, objectName, recordId } = useParams();
54
+ const navigate = useNavigate();
55
+ const dataSource = useAdapter();
56
+ const { objects, loading: metadataLoading } = useMetadata();
57
+ const { t } = useObjectTranslation();
58
+ const { objectLabel } = useObjectLabel();
59
+ const { user } = useAuth();
60
+ const objectDef = useMemo(() => objects.find((o) => o.name === objectName), [objects, objectName]);
61
+ const baseUrl = `/apps/${appName}`;
62
+ const objectListUrl = `${baseUrl}/${objectName}`;
63
+ const recordDetailUrl = mode === 'edit' && recordId
64
+ ? `${baseUrl}/${objectName}/record/${encodeURIComponent(recordId)}`
65
+ : objectListUrl;
66
+ /**
67
+ * Navigate back to the most relevant location.
68
+ * Prefer `history.back()` so users return to the exact list/view they came
69
+ * from (preserving filters, scroll position, etc.). Fall back to the
70
+ * record detail (edit mode) or list (create mode) when there is no
71
+ * history entry — happens on direct/refreshed loads of the URL.
72
+ */
73
+ const goBack = useCallback(() => {
74
+ if (typeof window !== 'undefined' && window.history.length > 1) {
75
+ navigate(-1);
76
+ }
77
+ else {
78
+ navigate(recordDetailUrl, { replace: true });
79
+ }
80
+ }, [navigate, recordDetailUrl]);
81
+ const label = objectDef ? objectLabel(objectDef) : objectName ?? '';
82
+ const pageTitle = mode === 'create'
83
+ ? t('form.createTitle', { object: label, defaultValue: `New ${label}` })
84
+ : t('form.editTitle', { object: label, defaultValue: `Edit ${label}` });
85
+ const handleSuccess = useCallback(() => {
86
+ toast.success(mode === 'create'
87
+ ? t('form.createSuccess', {
88
+ object: label,
89
+ defaultValue: `${label} created successfully`,
90
+ })
91
+ : t('form.updateSuccess', {
92
+ object: label,
93
+ defaultValue: `${label} updated successfully`,
94
+ }));
95
+ goBack();
96
+ }, [mode, t, label, goBack]);
97
+ const handleCancel = useCallback(() => {
98
+ goBack();
99
+ }, [goBack]);
100
+ // Authenticated-user descriptor — shared by the ExpressionEvaluator (used
101
+ // for field-visibility expression evaluation) and the ExpressionProvider
102
+ // wrapping the form (which exposes the same descriptor to descendant
103
+ // expression consumers). Memoised on the underlying user identity so a
104
+ // re-render that doesn't change the user does not invalidate downstream
105
+ // memoisations.
106
+ const expressionUser = useMemo(() => user
107
+ ? { name: user.name, email: user.email, role: user.role ?? 'user' }
108
+ : { name: 'Anonymous', email: '', role: 'guest' }, [user]);
109
+ // Build expression evaluator for field-visibility expressions, mirroring
110
+ // the global ModalForm setup in AppContent.
111
+ const expressionEvaluator = useMemo(() => new ExpressionEvaluator({
112
+ // expressionUser already handles the anonymous fallback, so we can
113
+ // pass it through unconditionally.
114
+ user: expressionUser,
115
+ app: { name: appName },
116
+ data: {},
117
+ }), [expressionUser, appName]);
118
+ // Resolve the field list using the same visibility-aware logic as the
119
+ // ModalForm in AppContent so page-mode and modal-mode show the same
120
+ // fields for a given user.
121
+ const fields = useMemo(() => {
122
+ if (!objectDef?.fields)
123
+ return [];
124
+ if (Array.isArray(objectDef.fields)) {
125
+ return objectDef.fields
126
+ .filter((f) => {
127
+ if (typeof f === 'string')
128
+ return true;
129
+ return evaluateVisibility(f.visible, expressionEvaluator);
130
+ })
131
+ .map((f) => (typeof f === 'string' ? f : f.name));
132
+ }
133
+ return Object.entries(objectDef.fields)
134
+ .filter(([, f]) => evaluateVisibility(f.visible, expressionEvaluator))
135
+ .map(([key]) => key);
136
+ }, [objectDef, expressionEvaluator]);
137
+ // Show skeleton while metadata is still loading rather than the
138
+ // "Object Not Found" empty state — otherwise direct/refreshed loads of
139
+ // the URL flash an error before the metadata resolves.
140
+ if (metadataLoading) {
141
+ return _jsx(SkeletonDetail, {});
142
+ }
143
+ if (!objectDef) {
144
+ return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: "Object Not Found" }), _jsxs(EmptyDescription, { children: ["Object \"", objectName, "\" definition missing. Check your configuration or navigate back to select a valid object."] }), _jsx("div", { className: "mt-4", children: _jsxs(Button, { variant: "outline", onClick: () => navigate(baseUrl), children: [_jsx(ArrowLeft, { className: "mr-2 h-4 w-4" }), "Back"] }) })] }) }));
145
+ }
146
+ return (_jsx(ExpressionProvider, { user: expressionUser, app: { name: appName }, data: {}, children: _jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", "data-testid": "record-form-page", "data-mode": mode, children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-3 border-b bg-background px-4 py-3 sm:px-6", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: goBack, "data-testid": "record-form-page-back", "aria-label": t('common.back', { defaultValue: 'Back' }), children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Link, { to: objectListUrl, className: "hover:text-foreground transition-colors", children: label }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { className: "text-foreground font-medium", "data-testid": "record-form-page-title", children: pageTitle })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto max-w-4xl", children: _jsx(ObjectForm, { schema: {
147
+ type: 'object-form',
148
+ formType: 'simple',
149
+ objectName: objectDef.name,
150
+ mode,
151
+ recordId: mode === 'edit' ? recordId : undefined,
152
+ title: pageTitle,
153
+ description: mode === 'create'
154
+ ? t('form.createDescription', {
155
+ object: label,
156
+ defaultValue: `Create a new ${label}.`,
157
+ })
158
+ : t('form.editDescription', {
159
+ object: label,
160
+ defaultValue: `Edit this ${label}.`,
161
+ }),
162
+ layout: 'vertical',
163
+ fields,
164
+ onSuccess: handleSuccess,
165
+ onCancel: handleCancel,
166
+ showSubmit: true,
167
+ showCancel: true,
168
+ submitText: t('form.saveRecord', { defaultValue: 'Save' }),
169
+ cancelText: t('common.cancel', { defaultValue: 'Cancel' }),
170
+ }, dataSource: dataSource ?? undefined }, `${mode}:${objectName}:${recordId ?? 'new'}`) }) })] }) }));
171
+ }
@@ -1,5 +1,7 @@
1
1
  export { ObjectView } from './ObjectView';
2
2
  export { RecordDetailView } from './RecordDetailView';
3
+ export { RecordFormPage } from './RecordFormPage';
4
+ export type { RecordFormPageProps } from './RecordFormPage';
3
5
  export { DashboardView } from './DashboardView';
4
6
  export { PageView } from './PageView';
5
7
  export { ReportView } from './ReportView';
@@ -1,5 +1,6 @@
1
1
  export { ObjectView } from './ObjectView';
2
2
  export { RecordDetailView } from './RecordDetailView';
3
+ export { RecordFormPage } from './RecordFormPage';
3
4
  export { DashboardView } from './DashboardView';
4
5
  export { PageView } from './PageView';
5
6
  export { ReportView } from './ReportView';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.14.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.0.1",
31
- "@object-ui/collaboration": "4.0.1",
32
- "@object-ui/components": "4.0.1",
33
- "@object-ui/core": "4.0.1",
34
- "@object-ui/data-objectstack": "4.0.1",
35
- "@object-ui/fields": "4.0.1",
36
- "@object-ui/i18n": "4.0.1",
37
- "@object-ui/layout": "4.0.1",
38
- "@object-ui/permissions": "4.0.1",
39
- "@object-ui/react": "4.0.1",
40
- "@object-ui/types": "4.0.1"
30
+ "@object-ui/auth": "4.0.3",
31
+ "@object-ui/collaboration": "4.0.3",
32
+ "@object-ui/components": "4.0.3",
33
+ "@object-ui/core": "4.0.3",
34
+ "@object-ui/data-objectstack": "4.0.3",
35
+ "@object-ui/fields": "4.0.3",
36
+ "@object-ui/i18n": "4.0.3",
37
+ "@object-ui/layout": "4.0.3",
38
+ "@object-ui/permissions": "4.0.3",
39
+ "@object-ui/react": "4.0.3",
40
+ "@object-ui/types": "4.0.3"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "4.0.1",
47
- "@object-ui/plugin-charts": "4.0.1",
48
- "@object-ui/plugin-chatbot": "4.0.1",
49
- "@object-ui/plugin-dashboard": "4.0.1",
50
- "@object-ui/plugin-designer": "4.0.1",
51
- "@object-ui/plugin-detail": "4.0.1",
52
- "@object-ui/plugin-form": "4.0.1",
53
- "@object-ui/plugin-grid": "4.0.1",
54
- "@object-ui/plugin-kanban": "4.0.1",
55
- "@object-ui/plugin-list": "4.0.1",
56
- "@object-ui/plugin-report": "4.0.1",
57
- "@object-ui/plugin-view": "4.0.1"
46
+ "@object-ui/plugin-calendar": "4.0.3",
47
+ "@object-ui/plugin-charts": "4.0.3",
48
+ "@object-ui/plugin-chatbot": "4.0.3",
49
+ "@object-ui/plugin-dashboard": "4.0.3",
50
+ "@object-ui/plugin-designer": "4.0.3",
51
+ "@object-ui/plugin-detail": "4.0.3",
52
+ "@object-ui/plugin-form": "4.0.3",
53
+ "@object-ui/plugin-grid": "4.0.3",
54
+ "@object-ui/plugin-kanban": "4.0.3",
55
+ "@object-ui/plugin-list": "4.0.3",
56
+ "@object-ui/plugin-report": "4.0.3",
57
+ "@object-ui/plugin-view": "4.0.3"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.6.0",