@object-ui/app-shell 4.0.0 → 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,132 @@
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
+
73
+ ## 4.0.1
74
+
75
+ ### Patch Changes
76
+
77
+ - @object-ui/types@4.0.1
78
+ - @object-ui/core@4.0.1
79
+ - @object-ui/i18n@4.0.1
80
+ - @object-ui/react@4.0.1
81
+ - @object-ui/components@4.0.1
82
+ - @object-ui/fields@4.0.1
83
+ - @object-ui/layout@4.0.1
84
+ - @object-ui/data-objectstack@4.0.1
85
+ - @object-ui/auth@4.0.1
86
+ - @object-ui/permissions@4.0.1
87
+ - @object-ui/plugin-calendar@4.0.1
88
+ - @object-ui/plugin-charts@4.0.1
89
+ - @object-ui/plugin-chatbot@4.0.1
90
+ - @object-ui/plugin-dashboard@4.0.1
91
+ - @object-ui/plugin-designer@4.0.1
92
+ - @object-ui/plugin-detail@4.0.1
93
+ - @object-ui/plugin-form@4.0.1
94
+ - @object-ui/plugin-grid@4.0.1
95
+ - @object-ui/plugin-kanban@4.0.1
96
+ - @object-ui/plugin-list@4.0.1
97
+ - @object-ui/plugin-report@4.0.1
98
+ - @object-ui/plugin-view@4.0.1
99
+ - @object-ui/collaboration@4.0.1
100
+
101
+ ## 4.0.0
102
+
103
+ ### Patch Changes
104
+
105
+ - Updated dependencies
106
+ - @object-ui/types@4.0.0
107
+ - @object-ui/auth@4.0.0
108
+ - @object-ui/collaboration@4.0.0
109
+ - @object-ui/components@4.0.0
110
+ - @object-ui/core@4.0.0
111
+ - @object-ui/data-objectstack@4.0.0
112
+ - @object-ui/fields@4.0.0
113
+ - @object-ui/layout@4.0.0
114
+ - @object-ui/permissions@4.0.0
115
+ - @object-ui/plugin-calendar@4.0.0
116
+ - @object-ui/plugin-charts@4.0.0
117
+ - @object-ui/plugin-chatbot@4.0.0
118
+ - @object-ui/plugin-dashboard@4.0.0
119
+ - @object-ui/plugin-designer@4.0.0
120
+ - @object-ui/plugin-detail@4.0.0
121
+ - @object-ui/plugin-form@4.0.0
122
+ - @object-ui/plugin-grid@4.0.0
123
+ - @object-ui/plugin-kanban@4.0.0
124
+ - @object-ui/plugin-list@4.0.0
125
+ - @object-ui/plugin-report@4.0.0
126
+ - @object-ui/plugin-view@4.0.0
127
+ - @object-ui/react@4.0.0
128
+ - @object-ui/i18n@4.0.0
129
+
3
130
  ## 4.0.0
4
131
 
5
132
  ### 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
@@ -181,7 +181,14 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
181
181
  extraSegments.push({ label: displayTitle || `#${shortId}` });
182
182
  }
183
183
  else if (pathParts[3] === 'view' && pathParts[4]) {
184
- extraSegments.push({ label: humanizeSlug(pathParts[4]) });
184
+ // Prefer the view's metadata label (e.g. "Lead Pipeline") over a
185
+ // humanized slug ("Kanban By Status") so the breadcrumb matches the
186
+ // tab label users clicked.
187
+ const viewName = pathParts[4];
188
+ const definedViews = currentObject.listViews || currentObject.list_views || {};
189
+ const viewDef = definedViews[viewName];
190
+ const viewLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
191
+ extraSegments.push({ label: viewLabel });
185
192
  }
186
193
  }
187
194
  }
@@ -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? }.
@@ -18,10 +20,13 @@ export declare function resolveI18nLabel(label: string | {
18
20
  */
19
21
  export declare function capitalizeFirst(str: string): string;
20
22
  /**
21
- * Format a record title using the titleFormat pattern
22
- * @param titleFormat Pattern like "{name} - {email}" or "{firstName} {lastName}"
23
- * @param record The record data object
24
- * @returns Formatted title string
23
+ * Format a record title using the titleFormat pattern.
24
+ *
25
+ * Empty placeholders (missing or null/empty fields) are stripped along with
26
+ * any orphan separator they leave behind, so a template like
27
+ * "{full_name} - {company}"
28
+ * evaluated against `{ company: "Acme" }` resolves to `"Acme"` rather than
29
+ * `" - Acme"`. Returns an empty string when no placeholder resolved.
25
30
  */
26
31
  export declare function formatRecordTitle(titleFormat: string | undefined, record: any): string;
27
32
  /**