@object-ui/app-shell 11.3.0 → 11.5.0
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 +700 -0
- package/README.md +32 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +25 -3
- package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
- package/dist/console/home/CloudOnboardingNext.js +14 -4
- package/dist/console/home/HomePage.js +34 -7
- package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
- package/dist/console/organizations/OrganizationsPage.js +16 -7
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useConsoleActionRuntime.js +32 -3
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/layout/AppHeader.js +13 -5
- package/dist/layout/AppSidebar.js +10 -4
- package/dist/observability/sentry.d.ts +5 -0
- package/dist/observability/sentry.js +6 -1
- package/dist/preview/DraftChangesPanel.d.ts +31 -1
- package/dist/preview/DraftChangesPanel.js +146 -18
- package/dist/urlParams.d.ts +68 -0
- package/dist/urlParams.js +76 -0
- package/dist/utils/appRoute.d.ts +15 -0
- package/dist/utils/appRoute.js +22 -0
- package/dist/utils/deriveRelatedLists.d.ts +20 -5
- package/dist/utils/deriveRelatedLists.js +31 -13
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/pageTabsUrlSync.d.ts +32 -0
- package/dist/utils/pageTabsUrlSync.js +43 -0
- package/dist/utils/recordFormNavigation.d.ts +40 -0
- package/dist/utils/recordFormNavigation.js +30 -0
- package/dist/utils/resolveViewId.d.ts +23 -0
- package/dist/utils/resolveViewId.js +37 -0
- package/dist/utils/warnSuppressedListNav.d.ts +10 -0
- package/dist/utils/warnSuppressedListNav.js +40 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +7 -5
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +65 -13
- package/dist/views/RecordDetailView.js +191 -123
- package/dist/views/RecordFormPage.js +7 -1
- package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
- package/dist/views/RelatedRecordActionsBridge.js +147 -0
- package/dist/views/metadata-admin/PackagesPage.js +18 -7
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.d.ts +12 -21
- package/dist/views/metadata-admin/i18n.js +557 -6
- package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
- package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
- package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
- package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
- package/dist/views/metadata-admin/nav-selection.js +81 -0
- package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
- package/dist/views/metadata-admin/permission-slice.js +70 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
- package/dist/views/studio-design/BuilderLanding.js +126 -0
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
- package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
- package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
- package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
- package/dist/views/studio-design/PackageIdInput.js +40 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
- package/dist/views/studio-design/StudioDesignSurface.js +962 -145
- package/dist/views/studio-design/metadataError.d.ts +23 -0
- package/dist/views/studio-design/metadataError.js +44 -0
- package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
- package/dist/views/studio-design/packageSurfaces.js +34 -0
- package/dist/views/studio-design/packages-io.d.ts +38 -0
- package/dist/views/studio-design/packages-io.js +73 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- package/package.json +42 -39
package/README.md
CHANGED
|
@@ -152,6 +152,15 @@ for each metadata type. Every type has a pure-renderer **preview** that doubles
|
|
|
152
152
|
as its **designer** when given `editing` + `onPatch` props — no backend round
|
|
153
153
|
trip is required to edit a draft.
|
|
154
154
|
|
|
155
|
+
### App → Studio reverse bridge
|
|
156
|
+
|
|
157
|
+
Inside a running app, workspace admins get a "Design in Studio" entry in the
|
|
158
|
+
top bar (`AppHeader`) that deep-links to the app's owning package on the Studio
|
|
159
|
+
design surface (`/studio/:packageId/data`). It is the reverse of the builder's
|
|
160
|
+
"Open app" bridge (ADR-0080): the entry only renders for admins and only when
|
|
161
|
+
the app has an owning package (`_packageId`), and package writability stays a
|
|
162
|
+
server-side concern — a read-only package opens in Studio as browse-only.
|
|
163
|
+
|
|
155
164
|
### Studio package scope
|
|
156
165
|
|
|
157
166
|
Studio treats the selected package as the authoring scope. The package selector
|
|
@@ -164,6 +173,29 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
|
|
|
164
173
|
The Studio sidebar also flattens the root Overview group so Home and package
|
|
165
174
|
navigation sit directly under the package selector.
|
|
166
175
|
|
|
176
|
+
### Access matrix (package-scoped)
|
|
177
|
+
|
|
178
|
+
The Access pillar's permission matrix follows the active package (ADR-0086 P0).
|
|
179
|
+
A Permission Set / Profile is a single record whose `objects` / `fields` maps
|
|
180
|
+
accumulate authorization rows contributed by many packages, so the matrix:
|
|
181
|
+
|
|
182
|
+
- lists **only the objects the active package declares** — the panel never
|
|
183
|
+
exposes the whole environment's objects; and
|
|
184
|
+
- saves via **slice-merge** — it re-reads the record and writes back just this
|
|
185
|
+
package's slice, leaving rows contributed by other packages untouched.
|
|
186
|
+
|
|
187
|
+
The left rail lists only permission sets this package owns — the metadata API
|
|
188
|
+
filters `permission` by the record-level `package_id` provenance server-side
|
|
189
|
+
(framework ADR-0086 P1), via `client.list('permission', { packageId })`, so
|
|
190
|
+
environment-owned platform defaults (`admin_full_access`, `member_default`, …)
|
|
191
|
+
are excluded by the backend. (The `?package=` list rows don't echo the
|
|
192
|
+
provenance columns, so a client-side filter can't do this.) Save writes a
|
|
193
|
+
package draft and publishes with the whole package (ADR-0086 P2). Rendering
|
|
194
|
+
`PermissionMatrixEditPage` without a `packageId` keeps the environment-wide
|
|
195
|
+
behavior (full object list, whole-record save). The scope/merge helpers
|
|
196
|
+
(`scopePermissionSet`, `mergePermissionSlice`) live in
|
|
197
|
+
`metadata-admin/permission-slice.ts`.
|
|
198
|
+
|
|
167
199
|
### Visual flow canvas
|
|
168
200
|
|
|
169
201
|
The `flow` designer (`FlowPreview` → `FlowCanvas`) renders an automation as an
|
|
@@ -9,12 +9,13 @@ import { useEffect, useMemo } from 'react';
|
|
|
9
9
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@object-ui/components';
|
|
10
10
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
11
11
|
import { useUrlOverlay } from '../hooks/useUrlOverlay';
|
|
12
|
+
import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams';
|
|
12
13
|
export function KeyboardShortcutsDialog() {
|
|
13
14
|
const { t } = useObjectTranslation();
|
|
14
15
|
// URL-addressable (?shortcuts=1) so the dialog is deep-linkable and openable
|
|
15
16
|
// from the header Help menu, not only via the `?` keyboard accelerator (ADR-0054
|
|
16
17
|
// C1/C2/C3).
|
|
17
|
-
const { open, setOpen, toggleOverlay } = useUrlOverlay(
|
|
18
|
+
const { open, setOpen, toggleOverlay } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);
|
|
18
19
|
const shortcutGroups = useMemo(() => [
|
|
19
20
|
{
|
|
20
21
|
title: t('console.shortcuts.groups.general'),
|
|
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
* AuthGuard, AdapterProvider, MetadataProvider, theme/toaster, /home, /login,
|
|
9
9
|
* /organizations) is provided by `createConsole` from @object-ui/app-shell.
|
|
10
10
|
*/
|
|
11
|
-
import { Routes, Route, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom';
|
|
11
|
+
import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams } from 'react-router-dom';
|
|
12
12
|
import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo } from 'react';
|
|
13
13
|
import { useAssistant } from '../assistant/assistantBus';
|
|
14
14
|
import { ModalForm } from '@object-ui/plugin-form';
|
|
15
15
|
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
|
|
16
16
|
import { toast } from 'sonner';
|
|
17
|
-
import { useActionRunner, useGlobalUndo } from '@object-ui/react';
|
|
17
|
+
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react';
|
|
18
18
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
19
19
|
import { useAuth } from '@object-ui/auth';
|
|
20
20
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
@@ -23,7 +23,9 @@ import { usePreviewDrafts } from '../preview/PreviewModeContext';
|
|
|
23
23
|
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState';
|
|
24
24
|
import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
|
|
25
25
|
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
|
|
26
|
-
import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
|
|
26
|
+
import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget } from '../utils/recordFormNavigation';
|
|
27
|
+
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
|
|
28
|
+
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams';
|
|
27
29
|
import { matchAppBySegment } from '../utils/appRoute';
|
|
28
30
|
import { resolveHref } from '@object-ui/layout';
|
|
29
31
|
import { ExpressionEvaluator } from '@object-ui/core';
|
|
@@ -45,6 +47,7 @@ const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default
|
|
|
45
47
|
const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
|
|
46
48
|
const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
|
|
47
49
|
const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
|
|
50
|
+
const ObjectDataPage = lazy(() => import('../views/ObjectDataPage').then(m => ({ default: m.ObjectDataPage })));
|
|
48
51
|
// Metadata admin — mounted under /apps/:app/metadata. Lives at the top
|
|
49
52
|
// level so URLs read like a normal nested resource (RFC-style) instead of
|
|
50
53
|
// piggy-backing on the legacy ComponentRegistry fan-out.
|
|
@@ -201,30 +204,80 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
201
204
|
navigate(`/apps/${seg}/component/developer/packages`, { replace: true });
|
|
202
205
|
}
|
|
203
206
|
}, [activeApp?.name, appName, location.pathname, navigate]);
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// #2604 — the create/edit overlay is URL-driven (`?form=new` / `?form=<id>`),
|
|
208
|
+
// not component state: the record form is a TASK overlay over the origin
|
|
209
|
+
// route, and putting its open-state in the URL makes browser Back close the
|
|
210
|
+
// overlay (returning to the intact origin) instead of abandoning the route
|
|
211
|
+
// with the overlay marooned on top. Same pattern as the detail drawer's
|
|
212
|
+
// `?recordId=…` (useUrlOverlay / ADR-0054 C3). Open pushes a history entry;
|
|
213
|
+
// every close strips the param with `replace` so no stale reopen-entry stays
|
|
214
|
+
// ahead in history.
|
|
215
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
216
|
+
const recordFormParam = searchParams.get(RECORD_FORM_PARAM);
|
|
217
|
+
// #2604 D3 — child-task extension of the record-form URL contract:
|
|
218
|
+
// `formObject` names the object the form edits when it is NOT the route's
|
|
219
|
+
// object (a subtable child opened over its parent's detail); `formLink`
|
|
220
|
+
// ("field:id") pre-links the parent on create. Keeping the whole task in
|
|
221
|
+
// the URL means Back closes the overlay and a refresh reopens it still
|
|
222
|
+
// correctly parent-linked — no transient component state to lose.
|
|
223
|
+
const formObjectParam = searchParams.get(RECORD_FORM_OBJECT_PARAM);
|
|
224
|
+
const formLinkParam = searchParams.get(RECORD_FORM_LINK_PARAM);
|
|
225
|
+
const editingRecord = useMemo(() => (recordFormParam && recordFormParam !== 'new' ? { id: recordFormParam } : null), [recordFormParam]);
|
|
226
|
+
const formLinkValues = useMemo(() => {
|
|
227
|
+
if (!formLinkParam)
|
|
228
|
+
return undefined;
|
|
229
|
+
const i = formLinkParam.indexOf(':');
|
|
230
|
+
if (i <= 0)
|
|
231
|
+
return undefined;
|
|
232
|
+
return { [formLinkParam.slice(0, i)]: formLinkParam.slice(i + 1) };
|
|
233
|
+
}, [formLinkParam]);
|
|
206
234
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
235
|
+
const isDialogOpen = !!recordFormParam;
|
|
236
|
+
// Close the record-form overlay by stripping `?form` in place. Reads the
|
|
237
|
+
// LIVE location (not the hook's render-time snapshot) so it stays a no-op
|
|
238
|
+
// when a success handler already navigated away in the same tick (the
|
|
239
|
+
// post-create redirect below).
|
|
240
|
+
const closeRecordForm = useCallback(() => {
|
|
241
|
+
const sp = new URLSearchParams(window.location.search);
|
|
242
|
+
if (!sp.has(RECORD_FORM_PARAM) && !sp.has(RECORD_FORM_OBJECT_PARAM) && !sp.has(RECORD_FORM_LINK_PARAM))
|
|
243
|
+
return;
|
|
244
|
+
sp.delete(RECORD_FORM_PARAM);
|
|
245
|
+
sp.delete(RECORD_FORM_OBJECT_PARAM);
|
|
246
|
+
sp.delete(RECORD_FORM_LINK_PARAM);
|
|
247
|
+
setSearchParams(sp, { replace: true });
|
|
248
|
+
}, [setSearchParams]);
|
|
207
249
|
const { execute: executeAction, runner } = useActionRunner();
|
|
250
|
+
// objectui#2269 — bridge every dataSource write (create/update/delete →
|
|
251
|
+
// MutationEvent) onto the invalidation bus, ONCE for the whole console.
|
|
252
|
+
// Readers (record detail, related lists, count badges) refetch in place;
|
|
253
|
+
// nothing is remounted for a data refresh.
|
|
254
|
+
useMutationInvalidationBridge(dataSource);
|
|
208
255
|
useGlobalUndo({
|
|
209
256
|
dataSource: dataSource ?? undefined,
|
|
210
257
|
onUndo: (op) => {
|
|
211
258
|
toast.info(`Undo: ${op.description}`, { duration: 4000 });
|
|
212
259
|
setRefreshKey(k => k + 1);
|
|
260
|
+
// Precisely-scoped invalidation — UndoableOperation carries the target
|
|
261
|
+
// (objectName + recordId), so detail readers refresh in place too.
|
|
262
|
+
if (op?.objectName)
|
|
263
|
+
notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
|
|
213
264
|
},
|
|
214
265
|
onRedo: (op) => {
|
|
215
266
|
toast.info(`Redo: ${op.description}`, { duration: 3000 });
|
|
216
267
|
setRefreshKey(k => k + 1);
|
|
268
|
+
if (op?.objectName)
|
|
269
|
+
notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
|
|
217
270
|
},
|
|
218
271
|
});
|
|
219
272
|
useEffect(() => {
|
|
220
273
|
runner.registerHandler('crud_success', async (action) => {
|
|
221
|
-
|
|
274
|
+
closeRecordForm();
|
|
222
275
|
setRefreshKey(k => k + 1);
|
|
223
276
|
toast.success(action.params?.message ?? 'Record saved successfully');
|
|
224
277
|
return { success: true, reload: true };
|
|
225
278
|
});
|
|
226
279
|
runner.registerHandler('dialog_cancel', async () => {
|
|
227
|
-
|
|
280
|
+
closeRecordForm();
|
|
228
281
|
return { success: true };
|
|
229
282
|
});
|
|
230
283
|
// Page-mode navigation handlers — declarative counterparts to the
|
|
@@ -274,7 +327,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
274
327
|
// `flow` handler on this top-level useActionRunner — it lives on a
|
|
275
328
|
// different ActionRunner instance and would never be invoked from the
|
|
276
329
|
// record/list action buttons.
|
|
277
|
-
}, [runner, navigate, appName]);
|
|
330
|
+
}, [runner, navigate, appName, closeRecordForm]);
|
|
278
331
|
useEffect(() => {
|
|
279
332
|
if (!dataSource)
|
|
280
333
|
return;
|
|
@@ -296,8 +349,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
296
349
|
objectNameFromPath = '';
|
|
297
350
|
}
|
|
298
351
|
const currentObjectDef = allObjects.find((o) => o.name === objectNameFromPath);
|
|
352
|
+
// The object the record-form overlay edits: the route's object by default,
|
|
353
|
+
// or the `formObject` child override (#2604 D3 — subtable child task opened
|
|
354
|
+
// over its parent's detail).
|
|
355
|
+
const formObjectDef = formObjectParam
|
|
356
|
+
? allObjects.find((o) => o.name === formObjectParam)
|
|
357
|
+
: currentObjectDef;
|
|
358
|
+
const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;
|
|
299
359
|
const handleCrudSuccess = useCallback(() => {
|
|
300
|
-
const label =
|
|
360
|
+
const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
|
|
301
361
|
executeAction({
|
|
302
362
|
type: 'crud_success',
|
|
303
363
|
params: {
|
|
@@ -306,10 +366,45 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
306
366
|
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }),
|
|
307
367
|
},
|
|
308
368
|
});
|
|
309
|
-
}, [executeAction, editingRecord,
|
|
369
|
+
}, [executeAction, editingRecord, formObjectDef, objectLabel, t]);
|
|
310
370
|
const handleDialogCancel = useCallback(() => {
|
|
311
371
|
executeAction({ type: 'dialog_cancel' });
|
|
312
372
|
}, [executeAction]);
|
|
373
|
+
// #2604 save invariant — *edit never moves you; create takes you to the
|
|
374
|
+
// record you made.* Edit save: the origin route is untouched (crud_success
|
|
375
|
+
// bumps refreshKey → origin refetches in place). Create save: land on the
|
|
376
|
+
// new record's detail, on ITS derived surface — a light object's detail is
|
|
377
|
+
// the drawer over the still-intact list; a heavy one is the detail route.
|
|
378
|
+
// `replace: true` swaps out the transient `?form=…` entry so Back returns
|
|
379
|
+
// to the pre-create origin.
|
|
380
|
+
const handleRecordFormSuccess = useCallback(async (saved) => {
|
|
381
|
+
// Child task (#2604 D3): the parent detail must stay EXACTLY as it was —
|
|
382
|
+
// active tab, scroll, everything. So do NOT go through crud_success;
|
|
383
|
+
// the child's open related lists refetch on their own via the
|
|
384
|
+
// invalidation bus (#2269): the dataSource write already emitted a
|
|
385
|
+
// MutationEvent that the bridge fans out — no manual notify needed.
|
|
386
|
+
if (isChildFormTask) {
|
|
387
|
+
const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
|
|
388
|
+
toast.success(editingRecord
|
|
389
|
+
? t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` })
|
|
390
|
+
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }));
|
|
391
|
+
closeRecordForm();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
handleCrudSuccess();
|
|
395
|
+
if (editingRecord || !currentObjectDef)
|
|
396
|
+
return;
|
|
397
|
+
const target = resolvePostCreateTarget({
|
|
398
|
+
objectName: currentObjectDef.name,
|
|
399
|
+
baseUrl: appName ? `/apps/${appName}` : (activeApp?.name ? `/apps/${activeApp.name}` : ''),
|
|
400
|
+
pathname: location.pathname,
|
|
401
|
+
search: window.location.search,
|
|
402
|
+
surface: deriveRecordSurface(currentObjectDef),
|
|
403
|
+
recordId: saved?.id ?? saved?._id,
|
|
404
|
+
});
|
|
405
|
+
if (target.kind !== 'none')
|
|
406
|
+
navigate(target.url, { replace: true });
|
|
407
|
+
}, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appName, activeApp?.name, location.pathname, navigate, closeRecordForm, objectLabel, t]);
|
|
313
408
|
// Track recent items on route change.
|
|
314
409
|
useTrackRouteAsRecent({
|
|
315
410
|
pathname: location.pathname,
|
|
@@ -330,8 +425,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
330
425
|
navigate(target.url);
|
|
331
426
|
return;
|
|
332
427
|
}
|
|
333
|
-
|
|
334
|
-
|
|
428
|
+
// Open the overlay via the URL (pushes one history entry → Back closes
|
|
429
|
+
// the overlay, origin intact). `new` = create; a record id = edit.
|
|
430
|
+
const rawId = record?.id ?? record?._id;
|
|
431
|
+
const sp = new URLSearchParams(window.location.search);
|
|
432
|
+
sp.set(RECORD_FORM_PARAM, rawId != null && rawId !== '' ? String(rawId) : 'new');
|
|
433
|
+
// Top-level task on the route's own object — drop any stale child-task
|
|
434
|
+
// overrides (see the record-form URL contract above).
|
|
435
|
+
sp.delete(RECORD_FORM_OBJECT_PARAM);
|
|
436
|
+
sp.delete(RECORD_FORM_LINK_PARAM);
|
|
437
|
+
setSearchParams(sp);
|
|
335
438
|
};
|
|
336
439
|
const handleAppChange = (newAppName) => {
|
|
337
440
|
navigate(`/apps/${newAppName}`);
|
|
@@ -395,12 +498,27 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
395
498
|
// overview instead of a blank `<Navigate to="">`.
|
|
396
499
|
const landing = resolveLandingRoute(activeApp, { currentUserId: user?.id ?? null });
|
|
397
500
|
return landing ? _jsx(Navigate, { to: landing, replace: true }) : _jsx(StudioHomePage, {});
|
|
398
|
-
})() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _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, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }
|
|
501
|
+
})() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _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, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/data", element: _jsx(ObjectDataPage, { dataSource: dataSource, objects: allObjects }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _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: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "component/metadata/directory", element: _jsx(LegacyMetadataRedirect, { mode: "directory" }) }), _jsx(Route, { path: "component/metadata/resource/*", element: _jsx(LegacyMetadataRedirect, { mode: "resource" }) }), _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, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), formObjectDef && (_jsx(ModalForm, { schema: {
|
|
399
502
|
type: 'object-form',
|
|
400
503
|
formType: 'modal',
|
|
401
|
-
objectName:
|
|
504
|
+
objectName: formObjectDef.name,
|
|
402
505
|
mode: editingRecord ? 'edit' : 'create',
|
|
403
506
|
recordId: editingRecord?.id,
|
|
507
|
+
// Child create task (#2604 D3): pre-link the parent from the
|
|
508
|
+
// `formLink` URL param (refresh-safe — the link survives).
|
|
509
|
+
...(formLinkValues && !editingRecord ? { initialValues: formLinkValues } : {}),
|
|
510
|
+
// #2604 D1: create/edit follow the flow-surface derivation —
|
|
511
|
+
// field-heavy → full-screen modal (the same big canvas the
|
|
512
|
+
// detail page gets, with overlay return semantics); light
|
|
513
|
+
// objects keep the existing auto-sized modal (ModalForm
|
|
514
|
+
// infers from columns when modalSize is unset). Derived
|
|
515
|
+
// default only — anything spread later (form view layout)
|
|
516
|
+
// would win. Child tasks size to the CHILD object's def.
|
|
517
|
+
...(deriveRecordFlowSurface(formObjectDef, isChildFormTask
|
|
518
|
+
? (editingRecord ? 'child-edit' : 'child-create')
|
|
519
|
+
: (editingRecord ? 'edit' : 'create')).size === 'full'
|
|
520
|
+
? { modalSize: 'full' }
|
|
521
|
+
: {}),
|
|
404
522
|
// Honor the object's DEFAULT FORM VIEW: curated sections (field
|
|
405
523
|
// selection + order + grouping), `contentLayout: 'tabbed'` when the
|
|
406
524
|
// view is tabbed, and inline child collections (master-detail).
|
|
@@ -408,30 +526,31 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
408
526
|
// win over the flat `fields` list below; otherwise this resolves to
|
|
409
527
|
// {} and `fields` (every field, raw schema order) is used as before.
|
|
410
528
|
// `formType` stays 'modal' (the container). (#1890 / ADR-0050.)
|
|
411
|
-
...resolveFormViewLayout(
|
|
529
|
+
...resolveFormViewLayout(formObjectDef),
|
|
412
530
|
title: editingRecord
|
|
413
|
-
? t('form.editTitle', { object: objectLabel(
|
|
414
|
-
: t('form.createTitle', { object: objectLabel(
|
|
531
|
+
? t('form.editTitle', { object: objectLabel(formObjectDef) })
|
|
532
|
+
: t('form.createTitle', { object: objectLabel(formObjectDef) }),
|
|
415
533
|
description: editingRecord
|
|
416
|
-
? t('form.editDescription', { object: objectLabel(
|
|
417
|
-
: t('form.createDescription', { object: objectLabel(
|
|
534
|
+
? t('form.editDescription', { object: objectLabel(formObjectDef) })
|
|
535
|
+
: t('form.createDescription', { object: objectLabel(formObjectDef) }),
|
|
418
536
|
open: isDialogOpen,
|
|
419
|
-
onOpenChange:
|
|
537
|
+
onOpenChange: (open) => { if (!open)
|
|
538
|
+
closeRecordForm(); },
|
|
420
539
|
layout: 'vertical',
|
|
421
|
-
fields:
|
|
422
|
-
? (Array.isArray(
|
|
423
|
-
?
|
|
540
|
+
fields: formObjectDef.fields
|
|
541
|
+
? (Array.isArray(formObjectDef.fields)
|
|
542
|
+
? formObjectDef.fields
|
|
424
543
|
.filter((f) => {
|
|
425
544
|
if (typeof f === 'string')
|
|
426
545
|
return true;
|
|
427
546
|
return evaluateVisibility(f.visible, expressionEvaluator);
|
|
428
547
|
})
|
|
429
548
|
.map((f) => typeof f === 'string' ? f : f.name)
|
|
430
|
-
: Object.entries(
|
|
549
|
+
: Object.entries(formObjectDef.fields)
|
|
431
550
|
.filter(([_, f]) => evaluateVisibility(f.visible, expressionEvaluator))
|
|
432
551
|
.map(([key]) => key))
|
|
433
552
|
: [],
|
|
434
|
-
onSuccess:
|
|
553
|
+
onSuccess: handleRecordFormSuccess,
|
|
435
554
|
onCancel: handleDialogCancel,
|
|
436
555
|
showSubmit: true,
|
|
437
556
|
showCancel: true,
|
|
@@ -439,7 +558,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
439
558
|
? t('form.update', { defaultValue: t('common.save', { defaultValue: 'Save' }) })
|
|
440
559
|
: t('form.create', { defaultValue: t('common.create', { defaultValue: 'Create' }) }),
|
|
441
560
|
cancelText: t('common.cancel'),
|
|
442
|
-
}, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
|
|
561
|
+
}, dataSource: dataSource }, `${formObjectDef.name}:${editingRecord?.id || 'new'}`))] })] }));
|
|
443
562
|
}
|
|
444
563
|
function findFirstRoute(items, ctx) {
|
|
445
564
|
if (!items || items.length === 0)
|
|
@@ -14,7 +14,8 @@ import { Suspense, useEffect, useRef, useState } from 'react';
|
|
|
14
14
|
import { Navigate, useLocation } from 'react-router-dom';
|
|
15
15
|
import { AuthGuard, useAuth } from '@object-ui/auth';
|
|
16
16
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
17
|
-
import { SchemaRendererProvider } from '@object-ui/react';
|
|
17
|
+
import { SchemaRendererProvider, ActionProvider } from '@object-ui/react';
|
|
18
|
+
import { useActionModal } from '../hooks/useActionModal';
|
|
18
19
|
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
|
|
19
20
|
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
|
|
20
21
|
import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
|
|
@@ -25,9 +26,30 @@ import { FavoritesProvider } from '../context/FavoritesProvider';
|
|
|
25
26
|
import { RecentItemsProvider } from '../context/RecentItemsProvider';
|
|
26
27
|
import { UserStateAdaptersProvider, useAttachUserStateAdapters, } from '../context/UserStateAdapters';
|
|
27
28
|
import { ThemeProvider } from '../chrome/ThemeProvider';
|
|
29
|
+
import { LoadingScreen } from '../chrome/LoadingScreen';
|
|
28
30
|
import { RemediationOverlay } from './RemediationOverlay';
|
|
31
|
+
// The console's every pre-React / pre-auth gate (Suspense fallback, adapter
|
|
32
|
+
// not ready, org/auth loading) renders this. It used to be a bare, unbranded
|
|
33
|
+
// "Loading…" line — ~8s of blank chrome before the login redirect on a cold
|
|
34
|
+
// /_console load (framework#2615 P3). Delegate to the branded, boot-safe
|
|
35
|
+
// splash (logo + product name + step list) that the rest of the shell already
|
|
36
|
+
// uses, so the cold load looks like the product from the first frame.
|
|
29
37
|
export function LoadingFallback() {
|
|
30
|
-
return
|
|
38
|
+
return _jsx(LoadingScreen, {});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Provide a MODAL handler at the console ROOT — the same level the global
|
|
42
|
+
* SchemaRendererProvider (dataSource) sits — so it reaches EVERY field widget,
|
|
43
|
+
* including a relation field inside a create/edit form that renders in a Radix
|
|
44
|
+
* Dialog portal (which sits above the lower per-view ActionProviders). This is
|
|
45
|
+
* what lets a lookup's inline "create the referenced record" open that object's
|
|
46
|
+
* OWN create form and select the result. Only ADDS the modal capability where
|
|
47
|
+
* none existed; richer per-view ActionProviders still override it where they
|
|
48
|
+
* apply. Must render inside MetadataProvider (useActionModal reads useMetadata).
|
|
49
|
+
*/
|
|
50
|
+
function GlobalCreateModalProvider({ dataSource, children }) {
|
|
51
|
+
const { modalHandler, modalElement } = useActionModal(dataSource);
|
|
52
|
+
return (_jsxs(ActionProvider, { onModal: modalHandler, children: [children, modalElement] }));
|
|
31
53
|
}
|
|
32
54
|
/**
|
|
33
55
|
* ConsoleShell — top-level provider stack shared by every console route.
|
|
@@ -87,7 +109,7 @@ function ConnectedShellInner({ children }) {
|
|
|
87
109
|
return _jsx(LoadingFallback, {});
|
|
88
110
|
// Expose the adapter via SchemaRendererContext so descendant hooks like
|
|
89
111
|
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
|
|
90
|
-
return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), children] }, language) }));
|
|
112
|
+
return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), _jsx(GlobalCreateModalProvider, { dataSource: adapter, children: children })] }, language) }));
|
|
91
113
|
}
|
|
92
114
|
/**
|
|
93
115
|
* UserStateBridge — once we have an authenticated user + a connected data
|
|
@@ -4,6 +4,15 @@ interface CloudOnboardingNextProps {
|
|
|
4
4
|
openProductionUrl?: string;
|
|
5
5
|
/** SPA route to the environments list (create / open / manage). */
|
|
6
6
|
environmentsRoute?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Optional backend pre-warm endpoint. When set and the caller already has
|
|
9
|
+
* a production env, the widget fires a best-effort GET the moment it knows
|
|
10
|
+
* the env exists — nudging a possibly-asleep container awake WHILE the user
|
|
11
|
+
* reads the hint, so the later "Open Production" click lands in an
|
|
12
|
+
* already-waking env instead of paying the full cold-start. No-op when
|
|
13
|
+
* unset (older page metadata), so this is safe to ship ahead of the page.
|
|
14
|
+
*/
|
|
15
|
+
warmUrl?: string;
|
|
7
16
|
};
|
|
8
17
|
}
|
|
9
18
|
export declare function CloudOnboardingNext({ properties }: CloudOnboardingNextProps): import("react").JSX.Element;
|
|
@@ -43,7 +43,7 @@ function pick(label) {
|
|
|
43
43
|
* `unknown` on any failure so the caller degrades gracefully rather than
|
|
44
44
|
* blocking the user behind a wrong "create" CTA.
|
|
45
45
|
*/
|
|
46
|
-
function useProductionEnvState() {
|
|
46
|
+
function useProductionEnvState(warmUrl) {
|
|
47
47
|
const { activeOrganization } = useAuth();
|
|
48
48
|
const orgId = activeOrganization?.id;
|
|
49
49
|
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
|
|
@@ -68,7 +68,17 @@ function useProductionEnvState() {
|
|
|
68
68
|
setState({ phase: 'unknown' });
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
const hasProductionEnv = data.hasProductionEnv === true;
|
|
72
|
+
setState({ phase: 'ready', hasProductionEnv });
|
|
73
|
+
// Pre-warm the prod env the instant we know it exists — fire-and-forget,
|
|
74
|
+
// best-effort, once per resolve. The user is now reading the hint and
|
|
75
|
+
// will click "Open Production" seconds later; by then the container is
|
|
76
|
+
// already waking. The sso-open click warms again, so a failed warm here
|
|
77
|
+
// costs nothing. Only when the page passes a warmUrl (else no-op).
|
|
78
|
+
if (hasProductionEnv && warmUrl) {
|
|
79
|
+
void authFetch(`${apiBase}${warmUrl}`, { method: 'GET', credentials: 'include' })
|
|
80
|
+
.catch(() => { });
|
|
81
|
+
}
|
|
72
82
|
}
|
|
73
83
|
catch {
|
|
74
84
|
if (!cancelled)
|
|
@@ -78,7 +88,7 @@ function useProductionEnvState() {
|
|
|
78
88
|
return () => {
|
|
79
89
|
cancelled = true;
|
|
80
90
|
};
|
|
81
|
-
}, [authFetch, orgId]);
|
|
91
|
+
}, [authFetch, orgId, warmUrl]);
|
|
82
92
|
return state;
|
|
83
93
|
}
|
|
84
94
|
/** Full-page nav to the backend SSO endpoint so the browser follows its 302. */
|
|
@@ -87,7 +97,7 @@ function openProduction(url) {
|
|
|
87
97
|
}
|
|
88
98
|
export function CloudOnboardingNext({ properties }) {
|
|
89
99
|
const navigate = useNavigate();
|
|
90
|
-
const state = useProductionEnvState();
|
|
100
|
+
const state = useProductionEnvState(properties?.warmUrl);
|
|
91
101
|
const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL;
|
|
92
102
|
const envsRoute = properties?.environmentsRoute || DEFAULT_ENVIRONMENTS_ROUTE;
|
|
93
103
|
// Loading — a neutral skeleton sized like the button row, so the hero doesn't
|
|
@@ -28,7 +28,7 @@ import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail';
|
|
|
28
28
|
import { useHomeInbox } from '../../hooks/useHomeInbox';
|
|
29
29
|
import { appRouteSegment } from '../../utils';
|
|
30
30
|
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
|
|
31
|
-
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText } from 'lucide-react';
|
|
31
|
+
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
|
|
32
32
|
import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
|
|
33
33
|
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts';
|
|
34
34
|
import { resolveAiApiBase } from '../../hooks/useAiSurface';
|
|
@@ -135,18 +135,41 @@ function PendingDraftsBanner({ t }) {
|
|
|
135
135
|
*/
|
|
136
136
|
function RecoveryPasswordReminder({ t }) {
|
|
137
137
|
const navigate = useNavigate();
|
|
138
|
-
const { hasLocalPassword } = useAuth();
|
|
138
|
+
const { hasLocalPassword, getAuthConfig } = useAuth();
|
|
139
139
|
const [show, setShow] = useState(false);
|
|
140
140
|
useEffect(() => {
|
|
141
141
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-dismissed') === '1')
|
|
142
142
|
return;
|
|
143
|
+
// Don't nag on the very first landing: let a brand-new SSO user reach
|
|
144
|
+
// their magic moment (build their first app) before asking them to set a
|
|
145
|
+
// recovery password. Record the first home visit; show the reminder only
|
|
146
|
+
// from the next one on. (The component already intends 'not before the
|
|
147
|
+
// first session'; previously the code still fired on the very first screen.)
|
|
148
|
+
if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-first-seen') !== '1') {
|
|
149
|
+
try {
|
|
150
|
+
localStorage.setItem('os:recovery-pw-first-seen', '1');
|
|
151
|
+
}
|
|
152
|
+
catch { /* ignore */ }
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
143
155
|
let cancelled = false;
|
|
144
|
-
Promise.
|
|
145
|
-
.
|
|
146
|
-
|
|
156
|
+
Promise.all([
|
|
157
|
+
Promise.resolve(hasLocalPassword?.()),
|
|
158
|
+
Promise.resolve(getAuthConfig?.()).catch(() => null),
|
|
159
|
+
])
|
|
160
|
+
.then(([has, config]) => {
|
|
161
|
+
if (cancelled)
|
|
162
|
+
return;
|
|
163
|
+
// Skip on SSO-enforced envs: password login is disabled there, so a
|
|
164
|
+
// recovery password can't be used — nudging for one is misleading and
|
|
165
|
+
// gives a false sense of security. Same signal LoginForm uses.
|
|
166
|
+
const passwordUnavailable = config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false;
|
|
167
|
+
if (has === false && !passwordUnavailable)
|
|
168
|
+
setShow(true);
|
|
169
|
+
})
|
|
147
170
|
.catch(() => { });
|
|
148
171
|
return () => { cancelled = true; };
|
|
149
|
-
}, [hasLocalPassword]);
|
|
172
|
+
}, [hasLocalPassword, getAuthConfig]);
|
|
150
173
|
const dismiss = () => {
|
|
151
174
|
try {
|
|
152
175
|
localStorage.setItem('os:recovery-pw-dismissed', '1');
|
|
@@ -195,5 +218,9 @@ export function HomePage() {
|
|
|
195
218
|
defaultValue: 'There are no applications available to you yet. Please contact your workspace administrator.',
|
|
196
219
|
}) })] })) })] }));
|
|
197
220
|
}
|
|
198
|
-
return (_jsxs("div", { className: "relative min-h-full bg-background", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pt-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "mb-7 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "text-foreground", children: [greeting, displayName ? ', ' : ''] }), displayName && _jsx("span", { className: "text-primary", children: displayName }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "mt-1 text-sm sm:text-base text-muted-foreground", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }), _jsx(HomeAiActions, { askAvailable: askAvailable, buildAvailable: buildAvailable, navigate: navigate, t: t })] }),
|
|
221
|
+
return (_jsxs("div", { className: "relative min-h-full bg-background", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pt-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "mb-7 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "text-foreground", children: [greeting, displayName ? ', ' : ''] }), displayName && _jsx("span", { className: "text-primary", children: displayName }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "mt-1 text-sm sm:text-base text-muted-foreground", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }), _jsx(HomeAiActions, { askAvailable: askAvailable, buildAvailable: buildAvailable, navigate: navigate, t: t })] }), isAdmin && (_jsxs("div", { className: "mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsxs("button", { type: "button", onClick: () => navigate('/studio'), className: "flex items-start gap-3 rounded-xl border bg-card px-4 py-3.5 text-left transition-colors hover:border-primary/50 hover:bg-muted/40", children: [_jsx("span", { className: "mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary", children: _jsx(Hammer, { className: "h-4.5 w-4.5" }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-semibold", children: t('home.build.title', { defaultValue: 'Build an app' }) }), _jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: t('home.build.subtitle', {
|
|
222
|
+
defaultValue: 'Start from scratch — design objects, forms, automations and interfaces.',
|
|
223
|
+
}) })] })] }), _jsxs("button", { type: "button", onClick: () => navigate('/apps/setup/system/marketplace'), className: "flex items-start gap-3 rounded-xl border bg-card px-4 py-3.5 text-left transition-colors hover:border-primary/50 hover:bg-muted/40", children: [_jsx("span", { className: "mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary", children: _jsx(LayoutTemplate, { className: "h-4.5 w-4.5" }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-semibold", children: t('home.template.title', { defaultValue: 'Start with a template' }) }), _jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: t('home.template.subtitle', {
|
|
224
|
+
defaultValue: 'Install a template app from the marketplace and customize it.',
|
|
225
|
+
}) })] })] })] })), _jsx(HomeAppsStrip, { apps: activeApps, favorites: favorites, onOpen: (app) => navigate(`/apps/${appRouteSegment(app) ?? app.name}`), onBrowseMarketplace: () => navigate('/apps/setup/system/marketplace'), isAdmin: isAdmin }), _jsx("div", { className: "mb-6", children: _jsx(HomeActionCenter, { pendingApprovalsCount: pendingApprovalsCount, notifications: notifications, onOpenApprovals: () => navigate('/apps/setup/system/approvals'), onOpenNotification: (n) => navigate(n.actionUrl || '/apps/setup/sys_inbox_message?view=mine'), t: t }) }), _jsxs("div", { className: "grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]", children: [_jsx(HomeContinue, { items: recentApps, onOpen: (href) => navigate(href), t: t }), _jsx(HomeActivity, { items: activities, onViewAll: () => navigate('/apps/setup/sys_activity'), t: t })] })] }) })] }));
|
|
199
226
|
}
|
|
@@ -13,15 +13,40 @@ import { useAuth } from '@object-ui/auth';
|
|
|
13
13
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
14
14
|
import { Loader2 } from 'lucide-react';
|
|
15
15
|
import { provisionProductionEnvironment } from './provisionEnvironment';
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Convert a display name to a URL-friendly slug.
|
|
18
|
+
*
|
|
19
|
+
* The ASCII pass strips everything outside [a-z0-9 _-]. For a name written
|
|
20
|
+
* entirely in a non-Latin script (中文 / 日本語 / 한국어 / العربية …) that pass
|
|
21
|
+
* yields the empty string — and an empty slug left the "Create workspace"
|
|
22
|
+
* button permanently disabled (`!slug.trim()`), dead-ending the FIRST step of
|
|
23
|
+
* onboarding for every non-Latin-name user. Rather than block them, fall back
|
|
24
|
+
* to a deterministic, non-empty slug they can still edit.
|
|
25
|
+
*
|
|
26
|
+
* Deterministic (not random) on purpose: a name-derived hash means the slug
|
|
27
|
+
* doesn't jitter on every keystroke while typing a CJK name, and re-typing the
|
|
28
|
+
* same name reproduces the same slug. Uniqueness across different names comes
|
|
29
|
+
* from the hash; the server still enforces global slug uniqueness on submit.
|
|
30
|
+
*/
|
|
17
31
|
function nameToSlug(name) {
|
|
18
|
-
|
|
32
|
+
const ascii = name
|
|
19
33
|
.toLowerCase()
|
|
20
34
|
.replace(/[^a-z0-9\s_-]/g, '')
|
|
21
35
|
.replace(/[\s_]+/g, '-')
|
|
22
36
|
.replace(/-+/g, '-')
|
|
23
37
|
.replace(/^-|-$/g, '')
|
|
24
38
|
.slice(0, 48);
|
|
39
|
+
if (ascii)
|
|
40
|
+
return ascii;
|
|
41
|
+
// Empty name → empty slug (keep the button disabled; nothing to create yet).
|
|
42
|
+
const trimmed = name.trim();
|
|
43
|
+
if (!trimmed)
|
|
44
|
+
return '';
|
|
45
|
+
// Non-empty name with no ASCII-sluggable chars → deterministic fallback.
|
|
46
|
+
let hash = 0;
|
|
47
|
+
for (const ch of trimmed)
|
|
48
|
+
hash = (Math.imul(hash, 31) + ch.charCodeAt(0)) >>> 0;
|
|
49
|
+
return `workspace-${hash.toString(36).slice(0, 6)}`;
|
|
25
50
|
}
|
|
26
51
|
export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
|
|
27
52
|
const { t } = useObjectTranslation();
|
|
@@ -90,7 +115,12 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
|
|
|
90
115
|
// resolves this to `alreadyProvisioned`; a genuine failure falls through
|
|
91
116
|
// to the onboarding gate (lazy provision on first navigation).
|
|
92
117
|
try {
|
|
93
|
-
|
|
118
|
+
// PR-5 (naming collapse): name the production environment after the
|
|
119
|
+
// workspace, so the user sees one consistent name instead of a
|
|
120
|
+
// workspace "Bloom Studio" whose environment is a generic "Production".
|
|
121
|
+
// The control plane inherits this displayName verbatim; auto-provision
|
|
122
|
+
// races that skip this call fall back to the org name server-side.
|
|
123
|
+
await provisionProductionEnvironment({ organizationId: org.id, displayName: name.trim() });
|
|
94
124
|
}
|
|
95
125
|
catch (provisionErr) {
|
|
96
126
|
console.warn('[CreateWorkspace] eager env provision failed; onboarding gate will provision lazily', provisionErr);
|