@object-ui/app-shell 11.2.0 → 11.4.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 +562 -0
- package/README.md +23 -0
- package/dist/console/ConsoleShell.js +17 -2
- 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/hooks/useConsoleActionRuntime.js +32 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/preview/DraftChangesPanel.d.ts +3 -1
- package/dist/preview/DraftChangesPanel.js +6 -5
- package/dist/utils/deriveRelatedLists.d.ts +20 -5
- package/dist/utils/deriveRelatedLists.js +31 -13
- package/dist/utils/index.d.ts +2 -24
- package/dist/utils/index.js +14 -101
- 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/DashboardView.js +2 -3
- package/dist/views/InterfaceListPage.js +10 -5
- package/dist/views/ObjectView.js +65 -12
- package/dist/views/PageView.js +2 -3
- package/dist/views/RecordDetailView.js +131 -104
- package/dist/views/RecordFormPage.js +7 -1
- package/dist/views/RelatedRecordActionsBridge.d.ts +24 -0
- package/dist/views/RelatedRecordActionsBridge.js +114 -0
- package/dist/views/ReportView.js +2 -3
- 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/clientValidation.js +8 -2
- package/dist/views/metadata-admin/color-variant-field.d.ts +1 -12
- package/dist/views/metadata-admin/color-variant-field.js +11 -0
- package/dist/views/metadata-admin/i18n.d.ts +12 -21
- package/dist/views/metadata-admin/i18n.js +343 -2
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +25 -11
- 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 +11 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
- package/dist/views/metadata-admin/previews/OutlineStrip.d.ts +1 -13
- package/dist/views/metadata-admin/previews/OutlineStrip.js +12 -0
- package/dist/views/metadata-admin/previews/PagePreview.js +9 -0
- package/dist/views/metadata-admin/previews/SourcePageEditor.d.ts +28 -0
- package/dist/views/metadata-admin/previews/SourcePageEditor.js +83 -0
- package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
- package/dist/views/studio-design/BuilderLanding.js +133 -0
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +31 -0
- package/dist/views/studio-design/ObjectFormDesigner.js +226 -0
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectSettingsPanel.js +45 -0
- package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectValidationsPanel.js +78 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +20 -0
- package/dist/views/studio-design/StudioDesignSurface.js +1306 -0
- package/dist/views/studio-design/metadataError.d.ts +23 -0
- package/dist/views/studio-design/metadataError.js +44 -0
- package/dist/views/studio-design/packages-io.d.ts +27 -0
- package/dist/views/studio-design/packages-io.js +61 -0
- package/package.json +46 -43
|
@@ -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);
|
|
@@ -121,19 +121,28 @@ export function OrganizationsPage() {
|
|
|
121
121
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
122
|
}, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]);
|
|
123
123
|
// Open the create dialog when arriving via the header "Create workspace"
|
|
124
|
-
// entry (`?create=1`)
|
|
124
|
+
// entry (`?create=1`), OR for a brand-new user who has ZERO organizations:
|
|
125
|
+
// they have nothing to pick, so land them straight on the "name your
|
|
126
|
+
// workspace" form instead of an empty picker (one hop fewer on first run).
|
|
127
|
+
// The empty-state (with its own "New organization" button) remains the
|
|
128
|
+
// backdrop if they dismiss the dialog. Guarded so closing never re-opens it.
|
|
125
129
|
const createOpenedRef = useRef(false);
|
|
126
130
|
useEffect(() => {
|
|
127
|
-
if (
|
|
131
|
+
if (isOrganizationsLoading)
|
|
132
|
+
return;
|
|
133
|
+
const firstRunNoOrg = orgList.length === 0 && canCreateOrg;
|
|
134
|
+
if ((wantsCreate || firstRunNoOrg) && !createOpenedRef.current) {
|
|
128
135
|
createOpenedRef.current = true;
|
|
129
136
|
setIsCreateOpen(true);
|
|
130
137
|
}
|
|
131
|
-
}, [wantsCreate]);
|
|
132
|
-
// Show a spinner while we're either still loading,
|
|
133
|
-
// because there's only one org
|
|
134
|
-
//
|
|
138
|
+
}, [wantsCreate, orgList.length, canCreateOrg, isOrganizationsLoading]);
|
|
139
|
+
// Show a spinner while we're either still loading, about to auto-redirect
|
|
140
|
+
// because there's only one org, or about to auto-open the create dialog for a
|
|
141
|
+
// brand-new zero-org user. This prevents the picker / empty-state from
|
|
142
|
+
// briefly flashing on screen before the redirect or dialog.
|
|
135
143
|
const willAutoSelect = !wantsCreate && !isOrganizationsLoading && orgList.length === 1;
|
|
136
|
-
|
|
144
|
+
const willAutoCreate = !isOrganizationsLoading && orgList.length === 0 && canCreateOrg && !createOpenedRef.current;
|
|
145
|
+
if (isOrganizationsLoading || willAutoSelect || willAutoCreate) {
|
|
137
146
|
return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) }));
|
|
138
147
|
}
|
|
139
148
|
return (_jsxs("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 py-8 sm:py-12", children: [_jsxs("div", { className: "mb-8", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('organizations.heading', { defaultValue: 'Your Organizations' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('organizations.subtitle', {
|
|
@@ -37,6 +37,21 @@ import { EnvironmentEntitlementDialog } from '../environment/EnvironmentEntitlem
|
|
|
37
37
|
import { entitlementDialogFromError } from '../environment/entitlements';
|
|
38
38
|
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
|
|
39
39
|
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
|
|
40
|
+
/**
|
|
41
|
+
* An action that also mounts on list rows (`list_item`) or record pages is
|
|
42
|
+
* designed to run against a single record. When such an action is launched
|
|
43
|
+
* from the list toolbar with nothing selected, there is no record context to
|
|
44
|
+
* resolve — block up front instead of triggering a run that fails at its
|
|
45
|
+
* first record-bound step (#2210: "Update requires an ID"). Actions declaring
|
|
46
|
+
* only object-level locations (e.g. `['list_toolbar']`) are left alone: they
|
|
47
|
+
* legitimately run without a record.
|
|
48
|
+
*/
|
|
49
|
+
function isRecordScoped(action) {
|
|
50
|
+
const locations = action.locations;
|
|
51
|
+
if (!Array.isArray(locations))
|
|
52
|
+
return false;
|
|
53
|
+
return locations.some((l) => l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section');
|
|
54
|
+
}
|
|
40
55
|
export function useConsoleActionRuntime(opts) {
|
|
41
56
|
const { dataSource, objects, objectName, onRefresh } = opts;
|
|
42
57
|
const navigate = useNavigate();
|
|
@@ -297,6 +312,10 @@ export function useConsoleActionRuntime(opts) {
|
|
|
297
312
|
// context as `selectedRecords`. Flows take a single `recordId` input
|
|
298
313
|
// variable, so a multi-row selection is ambiguous: block with a message
|
|
299
314
|
// instead of triggering a run that fails at its first record-bound node.
|
|
315
|
+
// Zero selection is blocked too when the action is record-scoped (it
|
|
316
|
+
// also mounts on list rows) — otherwise the wizard opens, collects
|
|
317
|
+
// input, and dies at its first record-bound node ("Update requires an
|
|
318
|
+
// ID"). Pure object-level toolbar flows keep triggering with no record.
|
|
300
319
|
if (recordId == null) {
|
|
301
320
|
const selected = Array.isArray(context?.selectedRecords) ? context.selectedRecords : [];
|
|
302
321
|
if (selected.length === 1) {
|
|
@@ -305,6 +324,9 @@ export function useConsoleActionRuntime(opts) {
|
|
|
305
324
|
else if (selected.length > 1) {
|
|
306
325
|
return { success: false, error: 'This flow runs on a single record — select exactly one row.' };
|
|
307
326
|
}
|
|
327
|
+
else if (isRecordScoped(action)) {
|
|
328
|
+
return { success: false, error: 'This flow runs on a single record — select a row first.' };
|
|
329
|
+
}
|
|
308
330
|
}
|
|
309
331
|
if (recordId != null && params.recordId == null)
|
|
310
332
|
params.recordId = recordId;
|
|
@@ -358,7 +380,8 @@ export function useConsoleActionRuntime(opts) {
|
|
|
358
380
|
// Same list_toolbar fallback as flowHandler: no `_rowRecord` means the
|
|
359
381
|
// action came from the toolbar — resolve the recordId from the grid's
|
|
360
382
|
// checkbox selection (published as `selectedRecords`). Multi-select is
|
|
361
|
-
// ambiguous for a single-record action, so block with a message
|
|
383
|
+
// ambiguous for a single-record action, so block with a message; so is
|
|
384
|
+
// zero selection when the action is record-scoped (see isRecordScoped).
|
|
362
385
|
if (resolvedRecordId == null) {
|
|
363
386
|
const selected = Array.isArray(context?.selectedRecords) ? context.selectedRecords : [];
|
|
364
387
|
if (selected.length === 1) {
|
|
@@ -368,6 +391,9 @@ export function useConsoleActionRuntime(opts) {
|
|
|
368
391
|
// The runner's post-execution hook surfaces `error` as a toast.
|
|
369
392
|
return { success: false, error: 'This action runs on a single record — select exactly one row.' };
|
|
370
393
|
}
|
|
394
|
+
else if (isRecordScoped(action)) {
|
|
395
|
+
return { success: false, error: 'This action runs on a single record — select a row first.' };
|
|
396
|
+
}
|
|
371
397
|
}
|
|
372
398
|
// Re-entrancy guard.
|
|
373
399
|
const inflightKey = `${targetName}:${resolvedRecordId ?? ''}`;
|
|
@@ -464,7 +490,9 @@ export function useConsoleActionRuntime(opts) {
|
|
|
464
490
|
}
|
|
465
491
|
catch { /* ignore */ }
|
|
466
492
|
}
|
|
467
|
-
toast
|
|
493
|
+
// Don't toast here — the ActionRunner's post-execution hook surfaces
|
|
494
|
+
// `error` as a toast (see apiHandler/flowHandler, which likewise only
|
|
495
|
+
// return). Toasting here too double-fires the error (two identical toasts).
|
|
468
496
|
return { success: false, error: errMsg };
|
|
469
497
|
}
|
|
470
498
|
const shouldRefresh = action.refreshAfter !== false;
|
|
@@ -525,7 +553,8 @@ export function useConsoleActionRuntime(opts) {
|
|
|
525
553
|
catch { /* ignore */ }
|
|
526
554
|
}
|
|
527
555
|
const msg = error.message;
|
|
528
|
-
|
|
556
|
+
// The ActionRunner's post-execution hook toasts `error`; returning it here
|
|
557
|
+
// (without a local toast.error) avoids the double toast.
|
|
529
558
|
return { success: false, error: msg };
|
|
530
559
|
}
|
|
531
560
|
finally {
|
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ import './console/marketplace/InstalledListWidget';
|
|
|
61
61
|
import './console/home/CloudOnboardingNext';
|
|
62
62
|
export { MetadataDirectoryPage, MetadataResourceRouter, MetadataResourceListPage, MetadataResourceEditPage, MetadataResourceHistoryPage, MetadataDiagnosticsPage, MetadataQuickFind, MetadataPageShell, SchemaForm, LayeredDiff, registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, useMetadataClient, useMetadataTypes, useTypesIndex, useGlobalDiagnostics, matchesQuery, registerMetadataPreview, getMetadataPreview, listMetadataPreviewTypes, registerMetadataInspector, getMetadataInspector, listMetadataInspectorTypes, } from './views/metadata-admin';
|
|
63
63
|
export type { MetadataResourceConfig, MetadataDomain, RichMetadataTypeEntry, MetadataPreview, MetadataPreviewProps, MetadataSelection, MetadataInspector, MetadataInspectorProps, } from './views/metadata-admin';
|
|
64
|
+
export { StudioDesignSurface, type StudioDesignSurfaceProps, } from './views/studio-design/StudioDesignSurface';
|
|
65
|
+
export { BuilderLanding } from './views/studio-design/BuilderLanding';
|
|
64
66
|
export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
|
|
65
67
|
export type { AssistantSnapshot, AssistantEditorContext, AssistantEditorField, } from './assistant/assistantBus';
|
|
66
68
|
export { RemediationOverlay } from './console/RemediationOverlay';
|
package/dist/index.js
CHANGED
|
@@ -79,6 +79,12 @@ import './console/home/CloudOnboardingNext';
|
|
|
79
79
|
// list / edit / create components, and host apps can compose the
|
|
80
80
|
// page primitives directly when needed.
|
|
81
81
|
export { MetadataDirectoryPage, MetadataResourceRouter, MetadataResourceListPage, MetadataResourceEditPage, MetadataResourceHistoryPage, MetadataDiagnosticsPage, MetadataQuickFind, MetadataPageShell, SchemaForm, LayeredDiff, registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, useMetadataClient, useMetadataTypes, useTypesIndex, useGlobalDiagnostics, matchesQuery, registerMetadataPreview, getMetadataPreview, listMetadataPreviewTypes, registerMetadataInspector, getMetadataInspector, listMetadataInspectorTypes, } from './views/metadata-admin';
|
|
82
|
+
// Studio WYSIWYG design surface (ADR-0080) — the open-source design surface.
|
|
83
|
+
// The left AI copilot is an injected `aiSlot`; OSS renders three zones.
|
|
84
|
+
export { StudioDesignSurface, } from './views/studio-design/StudioDesignSurface';
|
|
85
|
+
// The builder's front door: pick/create a writable package → pillar builder.
|
|
86
|
+
// Standalone at `/studio` and embedded via the `studio:builder` component ref.
|
|
87
|
+
export { BuilderLanding } from './views/studio-design/BuilderLanding';
|
|
82
88
|
// AI assistant bus — connects the metadata designers to the global chat.
|
|
83
89
|
export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
|
|
84
90
|
export { RemediationOverlay } from './console/RemediationOverlay';
|
|
@@ -15,5 +15,7 @@ export interface DraftChangeEntry {
|
|
|
15
15
|
export interface DraftChangesPanelProps {
|
|
16
16
|
open: boolean;
|
|
17
17
|
onOpenChange: (open: boolean) => void;
|
|
18
|
+
/** When set, list only pending drafts belonging to this package (Studio is package-scoped). */
|
|
19
|
+
packageId?: string | null;
|
|
18
20
|
}
|
|
19
|
-
export declare function DraftChangesPanel({ open, onOpenChange }: DraftChangesPanelProps): import("react").JSX.Element;
|
|
21
|
+
export declare function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChangesPanelProps): import("react").JSX.Element;
|
|
@@ -22,8 +22,9 @@ import { FilePlus2, FilePen, Loader2 } from 'lucide-react';
|
|
|
22
22
|
import { Badge, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@object-ui/components';
|
|
23
23
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
24
24
|
/** Pending drafts straight from the ADR-0033 `_drafts` endpoint. */
|
|
25
|
-
async function listPendingDrafts() {
|
|
26
|
-
const
|
|
25
|
+
async function listPendingDrafts(packageId) {
|
|
26
|
+
const qs = packageId ? `?packageId=${encodeURIComponent(packageId)}` : '';
|
|
27
|
+
const res = await fetch(`/api/v1/meta/_drafts${qs}`, {
|
|
27
28
|
credentials: 'include',
|
|
28
29
|
headers: { Accept: 'application/json' },
|
|
29
30
|
cache: 'no-store',
|
|
@@ -61,7 +62,7 @@ async function publishedNamesOf(type) {
|
|
|
61
62
|
.map((it) => (typeof it?.name === 'string' ? it.name : null))
|
|
62
63
|
.filter((n) => n !== null));
|
|
63
64
|
}
|
|
64
|
-
export function DraftChangesPanel({ open, onOpenChange }) {
|
|
65
|
+
export function DraftChangesPanel({ open, onOpenChange, packageId }) {
|
|
65
66
|
const { t } = useObjectTranslation();
|
|
66
67
|
const [entries, setEntries] = useState(null);
|
|
67
68
|
const [error, setError] = useState(null);
|
|
@@ -69,7 +70,7 @@ export function DraftChangesPanel({ open, onOpenChange }) {
|
|
|
69
70
|
setEntries(null);
|
|
70
71
|
setError(null);
|
|
71
72
|
try {
|
|
72
|
-
const drafts = await listPendingDrafts();
|
|
73
|
+
const drafts = await listPendingDrafts(packageId);
|
|
73
74
|
setEntries(drafts);
|
|
74
75
|
// Classify new-vs-update per TYPE: one published-list read covers every
|
|
75
76
|
// draft of that type. A type whose read fails stays unclassified
|
|
@@ -93,7 +94,7 @@ export function DraftChangesPanel({ open, onOpenChange }) {
|
|
|
93
94
|
catch (e) {
|
|
94
95
|
setError(e.message);
|
|
95
96
|
}
|
|
96
|
-
}, []);
|
|
97
|
+
}, [packageId]);
|
|
97
98
|
useEffect(() => {
|
|
98
99
|
if (open)
|
|
99
100
|
void load();
|
|
@@ -9,20 +9,29 @@
|
|
|
9
9
|
*
|
|
10
10
|
* This helper scans every object for fields whose `reference`/`reference_to`
|
|
11
11
|
* points back at the parent object and produces one related-list descriptor per
|
|
12
|
-
*
|
|
12
|
+
* eligible FK. The detail page (`RecordDetailView`) feeds these into the
|
|
13
13
|
* `record:related_list` renderers (and the legacy `DetailView.related`).
|
|
14
14
|
*
|
|
15
15
|
* Rules (kept in lockstep with the relationship-level `relatedList` spec flag):
|
|
16
16
|
* - Owned children (`master_detail`) and `lookup` children are SHOWN by
|
|
17
17
|
* default. Set `relatedList: false` on the FK field to suppress a noisy
|
|
18
|
-
* association/audit link
|
|
18
|
+
* association/audit link; set `relatedList: 'primary'` to mark a CORE
|
|
19
|
+
* relationship — the detail page promotes it to its own tab (the tab-vs-
|
|
20
|
+
* Related split is decided downstream in `buildDefaultTabs`).
|
|
19
21
|
* - `relatedListTitle` / `relatedListColumns` on the FK field override the
|
|
20
|
-
* derived title / columns
|
|
22
|
+
* derived title / columns (columns default to the child object's own list
|
|
23
|
+
* columns when omitted — resolved by the renderer).
|
|
21
24
|
* - Audit FKs (`created_by` / `updated_by` / `owner_id`) are skipped — they
|
|
22
25
|
* exist on virtually every object and would balloon the detail page into
|
|
23
26
|
* dozens of duplicate cards.
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
27
|
+
* - ONE related list per eligible FK. A child may point at the parent through
|
|
28
|
+
* MORE THAN ONE relationship (e.g. `opportunity.primary_account` +
|
|
29
|
+
* `opportunity.partner_account`); each surfaces as its own list. When a
|
|
30
|
+
* child appears more than once and gave no explicit `relatedListTitle`, the
|
|
31
|
+
* FK's label is suffixed to disambiguate ("Opportunity · Partner Account").
|
|
32
|
+
* - Self-references are allowed (e.g. `account.parent_account` → `account`):
|
|
33
|
+
* the parent record lists the records whose self-FK points back at it
|
|
34
|
+
* ("Child Accounts"). Suppress with `relatedList: false` if unwanted.
|
|
26
35
|
* - Owned (`master_detail`) children are ordered before plain `lookup`
|
|
27
36
|
* children, preserving discovery order within each group.
|
|
28
37
|
*/
|
|
@@ -39,6 +48,12 @@ export interface DerivedRelatedList {
|
|
|
39
48
|
columns?: any[];
|
|
40
49
|
/** True when the child→parent link is a `master_detail` (owned) relationship. */
|
|
41
50
|
isOwned: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* True when the FK declares `relatedList: 'primary'`. A prominence hint
|
|
53
|
+
* (ADR-0085): the detail page promotes this relationship to its OWN tab,
|
|
54
|
+
* while non-primary lists collapse into a single "Related" tab.
|
|
55
|
+
*/
|
|
56
|
+
isPrimary: boolean;
|
|
42
57
|
}
|
|
43
58
|
interface ObjectLike {
|
|
44
59
|
name?: string;
|
|
@@ -9,20 +9,29 @@
|
|
|
9
9
|
*
|
|
10
10
|
* This helper scans every object for fields whose `reference`/`reference_to`
|
|
11
11
|
* points back at the parent object and produces one related-list descriptor per
|
|
12
|
-
*
|
|
12
|
+
* eligible FK. The detail page (`RecordDetailView`) feeds these into the
|
|
13
13
|
* `record:related_list` renderers (and the legacy `DetailView.related`).
|
|
14
14
|
*
|
|
15
15
|
* Rules (kept in lockstep with the relationship-level `relatedList` spec flag):
|
|
16
16
|
* - Owned children (`master_detail`) and `lookup` children are SHOWN by
|
|
17
17
|
* default. Set `relatedList: false` on the FK field to suppress a noisy
|
|
18
|
-
* association/audit link
|
|
18
|
+
* association/audit link; set `relatedList: 'primary'` to mark a CORE
|
|
19
|
+
* relationship — the detail page promotes it to its own tab (the tab-vs-
|
|
20
|
+
* Related split is decided downstream in `buildDefaultTabs`).
|
|
19
21
|
* - `relatedListTitle` / `relatedListColumns` on the FK field override the
|
|
20
|
-
* derived title / columns
|
|
22
|
+
* derived title / columns (columns default to the child object's own list
|
|
23
|
+
* columns when omitted — resolved by the renderer).
|
|
21
24
|
* - Audit FKs (`created_by` / `updated_by` / `owner_id`) are skipped — they
|
|
22
25
|
* exist on virtually every object and would balloon the detail page into
|
|
23
26
|
* dozens of duplicate cards.
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
27
|
+
* - ONE related list per eligible FK. A child may point at the parent through
|
|
28
|
+
* MORE THAN ONE relationship (e.g. `opportunity.primary_account` +
|
|
29
|
+
* `opportunity.partner_account`); each surfaces as its own list. When a
|
|
30
|
+
* child appears more than once and gave no explicit `relatedListTitle`, the
|
|
31
|
+
* FK's label is suffixed to disambiguate ("Opportunity · Partner Account").
|
|
32
|
+
* - Self-references are allowed (e.g. `account.parent_account` → `account`):
|
|
33
|
+
* the parent record lists the records whose self-FK points back at it
|
|
34
|
+
* ("Child Accounts"). Suppress with `relatedList: false` if unwanted.
|
|
26
35
|
* - Owned (`master_detail`) children are ordered before plain `lookup`
|
|
27
36
|
* children, preserving discovery order within each group.
|
|
28
37
|
*/
|
|
@@ -50,9 +59,8 @@ export function deriveRelatedLists(objectDef, objects) {
|
|
|
50
59
|
const parentName = objectDef.name;
|
|
51
60
|
const owned = [];
|
|
52
61
|
const referenced = [];
|
|
53
|
-
const seenChild = new Set();
|
|
54
62
|
for (const child of objects) {
|
|
55
|
-
if (!child?.name
|
|
63
|
+
if (!child?.name)
|
|
56
64
|
continue;
|
|
57
65
|
for (const [fieldName, fieldDef] of fieldEntries(child.fields)) {
|
|
58
66
|
if (!fieldDef)
|
|
@@ -67,15 +75,13 @@ export function deriveRelatedLists(objectDef, objects) {
|
|
|
67
75
|
// Explicit opt-out lives on the relationship.
|
|
68
76
|
if (fieldDef.relatedList === false)
|
|
69
77
|
continue;
|
|
70
|
-
// One related list per child object — the first eligible FK wins.
|
|
71
|
-
if (seenChild.has(child.name))
|
|
72
|
-
continue;
|
|
73
|
-
seenChild.add(child.name);
|
|
74
78
|
const entry = {
|
|
75
79
|
childObject: child.name,
|
|
76
80
|
childLabel: child.label || child.name,
|
|
77
81
|
referenceField: fieldName,
|
|
78
82
|
isOwned: type === 'master_detail',
|
|
83
|
+
isPrimary: fieldDef.relatedList === 'primary',
|
|
84
|
+
_fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName,
|
|
79
85
|
...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle
|
|
80
86
|
? { title: fieldDef.relatedListTitle }
|
|
81
87
|
: {}),
|
|
@@ -83,9 +89,21 @@ export function deriveRelatedLists(objectDef, objects) {
|
|
|
83
89
|
? { columns: fieldDef.relatedListColumns }
|
|
84
90
|
: {}),
|
|
85
91
|
};
|
|
92
|
+
// NO `break`: a child object may reference this parent through several FKs.
|
|
86
93
|
(entry.isOwned ? owned : referenced).push(entry);
|
|
87
|
-
break; // move on to the next child object
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
|
-
|
|
96
|
+
const all = [...owned, ...referenced];
|
|
97
|
+
// Multi-FK disambiguation: when a child object points here through more than
|
|
98
|
+
// one relationship and gave no explicit title, suffix the FK label so the two
|
|
99
|
+
// lists are distinguishable (e.g. "Opportunity · Partner Account").
|
|
100
|
+
const counts = {};
|
|
101
|
+
for (const r of all)
|
|
102
|
+
counts[r.childObject] = (counts[r.childObject] || 0) + 1;
|
|
103
|
+
return all.map(({ _fkLabel, ...rest }) => {
|
|
104
|
+
if (!rest.title && counts[rest.childObject] > 1) {
|
|
105
|
+
return { ...rest, title: `${rest.childLabel} · ${_fkLabel}` };
|
|
106
|
+
}
|
|
107
|
+
return rest;
|
|
108
|
+
});
|
|
91
109
|
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility functions for ObjectStack Console
|
|
3
3
|
*/
|
|
4
|
+
export { getRecordDisplayName, deriveTitleField, isTitleEligibleField, formatTitleTemplate as formatRecordTitle, } from '@object-ui/core';
|
|
5
|
+
export type { RecordDisplayNameOptions } from '@object-ui/core';
|
|
4
6
|
export { resolveRecordFormTarget, resolveFormViewLayout, } from './recordFormNavigation';
|
|
5
7
|
export type { ObjectDefinitionForNavigation, RecordFormTarget, ObjectDefinitionForFormView, FormViewDefinition, FormViewModalLayout, } from './recordFormNavigation';
|
|
6
8
|
export { deriveRelatedLists } from './deriveRelatedLists';
|
|
@@ -23,27 +25,3 @@ export declare function resolveI18nLabel(label: string | {
|
|
|
23
25
|
* Preferred over CSS `capitalize` for i18n compatibility.
|
|
24
26
|
*/
|
|
25
27
|
export declare function capitalizeFirst(str: string): string;
|
|
26
|
-
/**
|
|
27
|
-
* Format a record title using the titleFormat pattern.
|
|
28
|
-
*
|
|
29
|
-
* Accepts either a legacy string template or an Expression envelope
|
|
30
|
-
* (`{ dialect: 'template', source: string }`) emitted by `@objectstack/spec`'s
|
|
31
|
-
* normalized templates. The placeholder syntax (`{field}`) is identical in both
|
|
32
|
-
* shapes; only the wrapping object is new.
|
|
33
|
-
*
|
|
34
|
-
* Empty placeholders (missing or null/empty fields) are stripped along with
|
|
35
|
-
* any orphan separator they leave behind, so a template like
|
|
36
|
-
* "{full_name} - {company}"
|
|
37
|
-
* evaluated against `{ company: "Acme" }` resolves to `"Acme"` rather than
|
|
38
|
-
* `" - Acme"`. Returns an empty string when no placeholder resolved.
|
|
39
|
-
*/
|
|
40
|
-
export declare function formatRecordTitle(titleFormat: string | {
|
|
41
|
-
source?: string;
|
|
42
|
-
} | undefined, record: any): string;
|
|
43
|
-
/**
|
|
44
|
-
* Get display name for a record using titleFormat or fallback
|
|
45
|
-
* @param objectDef Object definition with optional titleFormat
|
|
46
|
-
* @param record The record data
|
|
47
|
-
* @returns Display name for the record
|
|
48
|
-
*/
|
|
49
|
-
export declare function getRecordDisplayName(objectDef: any, record: any): string;
|