@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 +127 -0
- package/README.md +54 -0
- package/dist/console/AppContent.js +93 -37
- package/dist/console/ConsoleShell.js +4 -1
- package/dist/console/home/HomeLayout.js +12 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/layout/AppHeader.js +8 -1
- package/dist/layout/ConsoleFloatingChatbot.d.ts +8 -1
- package/dist/layout/ConsoleFloatingChatbot.js +79 -10
- package/dist/layout/ConsoleLayout.js +5 -1
- package/dist/utils/index.d.ts +9 -4
- package/dist/utils/index.js +45 -12
- package/dist/utils/recordFormNavigation.d.ts +130 -0
- package/dist/utils/recordFormNavigation.js +96 -0
- package/dist/views/CreateViewDialog.js +10 -1
- package/dist/views/ObjectView.js +32 -6
- package/dist/views/RecordDetailView.js +8 -11
- package/dist/views/RecordFormPage.d.ts +42 -0
- package/dist/views/RecordFormPage.js +171 -0
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +1 -0
- package/package.json +24 -24
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 {
|
|
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, {}),
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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:
|
|
51
|
+
content: welcomeContent,
|
|
11
52
|
},
|
|
12
53
|
],
|
|
13
|
-
|
|
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
|
-
:
|
|
17
|
-
autoResponseDelay:
|
|
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:
|
|
23
|
-
panelHeight:
|
|
66
|
+
panelWidth: 420,
|
|
67
|
+
panelHeight: 560,
|
|
24
68
|
title: `${appLabel} Assistant`,
|
|
25
69
|
triggerSize: 56,
|
|
26
|
-
},
|
|
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 }),
|
|
52
|
+
: undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: appLabel, objects: objects }) }))] }));
|
|
49
53
|
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
/**
|