@object-ui/app-shell 11.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +522 -0
  2. package/README.md +23 -0
  3. package/dist/console/ConsoleShell.js +17 -2
  4. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  5. package/dist/console/home/CloudOnboardingNext.js +14 -4
  6. package/dist/console/home/HomePage.js +34 -7
  7. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  8. package/dist/console/organizations/OrganizationsPage.js +16 -7
  9. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +3 -0
  12. package/dist/preview/DraftChangesPanel.d.ts +3 -1
  13. package/dist/preview/DraftChangesPanel.js +6 -5
  14. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  15. package/dist/utils/deriveRelatedLists.js +31 -13
  16. package/dist/utils/resolveViewId.d.ts +23 -0
  17. package/dist/utils/resolveViewId.js +37 -0
  18. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  19. package/dist/utils/warnSuppressedListNav.js +40 -0
  20. package/dist/views/InterfaceListPage.js +6 -4
  21. package/dist/views/ObjectView.js +61 -10
  22. package/dist/views/RecordDetailView.js +131 -104
  23. package/dist/views/RecordFormPage.js +7 -1
  24. package/dist/views/RelatedRecordActionsBridge.d.ts +24 -0
  25. package/dist/views/RelatedRecordActionsBridge.js +114 -0
  26. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  27. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  28. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  29. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  30. package/dist/views/metadata-admin/i18n.js +343 -2
  31. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +25 -11
  32. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  33. package/dist/views/metadata-admin/permission-slice.js +70 -0
  34. package/dist/views/metadata-admin/previews/AppNavCanvas.js +11 -7
  35. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  36. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  37. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  38. package/dist/views/studio-design/BuilderLanding.js +133 -0
  39. package/dist/views/studio-design/ObjectFormDesigner.d.ts +31 -0
  40. package/dist/views/studio-design/ObjectFormDesigner.js +226 -0
  41. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  42. package/dist/views/studio-design/ObjectSettingsPanel.js +45 -0
  43. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  44. package/dist/views/studio-design/ObjectValidationsPanel.js +78 -0
  45. package/dist/views/studio-design/StudioDesignSurface.js +793 -146
  46. package/dist/views/studio-design/metadataError.d.ts +23 -0
  47. package/dist/views/studio-design/metadataError.js +44 -0
  48. package/dist/views/studio-design/packages-io.d.ts +27 -0
  49. package/dist/views/studio-design/packages-io.js +61 -0
  50. package/package.json +42 -39
@@ -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
- setState({ phase: 'ready', hasProductionEnv: data.hasProductionEnv === true });
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.resolve(hasLocalPassword?.())
145
- .then((has) => { if (!cancelled && has === false)
146
- setShow(true); })
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 })] }), _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 })] })] }) })] }));
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
- /** Convert a display name to a URL-friendly slug */
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
- return name
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
- await provisionProductionEnvironment({ organizationId: org.id });
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`). Guarded so closing the dialog doesn't re-open it.
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 (wantsCreate && !createOpenedRef.current) {
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, or about to auto-redirect
133
- // because there's only one org. This prevents the picker from briefly
134
- // flashing on screen for single-org users.
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
- if (isOrganizationsLoading || willAutoSelect) {
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.error(errMsg);
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
- toast.error(msg);
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
@@ -62,6 +62,7 @@ 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
64
  export { StudioDesignSurface, type StudioDesignSurfaceProps, } from './views/studio-design/StudioDesignSurface';
65
+ export { BuilderLanding } from './views/studio-design/BuilderLanding';
65
66
  export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
66
67
  export type { AssistantSnapshot, AssistantEditorContext, AssistantEditorField, } from './assistant/assistantBus';
67
68
  export { RemediationOverlay } from './console/RemediationOverlay';
package/dist/index.js CHANGED
@@ -82,6 +82,9 @@ export { MetadataDirectoryPage, MetadataResourceRouter, MetadataResourceListPage
82
82
  // Studio WYSIWYG design surface (ADR-0080) — the open-source design surface.
83
83
  // The left AI copilot is an injected `aiSlot`; OSS renders three zones.
84
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';
85
88
  // AI assistant bus — connects the metadata designers to the global chat.
86
89
  export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
87
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 res = await fetch('/api/v1/meta/_drafts', {
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
- * child collection. The detail page (`RecordDetailView`) feeds these into the
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
- * - One related list per child object (deduped by child object name): the
25
- * first non-audit, non-suppressed FK wins.
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
- * child collection. The detail page (`RecordDetailView`) feeds these into the
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
- * - One related list per child object (deduped by child object name): the
25
- * first non-audit, non-suppressed FK wins.
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 || child.name === parentName)
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
- return [...owned, ...referenced];
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
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Resolve a URL-requested view name against the object's actual view ids.
10
+ *
11
+ * Canonical view ids are fully qualified (`<object>.<viewKind>`, see
12
+ * MetadataProvider), but nav items emit their `viewName` verbatim — usually
13
+ * the short form — and legacy embedded listViews carry bare keys (including
14
+ * the `'all'` fallback view). Accept both directions (#2217):
15
+ *
16
+ * 1. exact id match;
17
+ * 2. short name (no `.`) → retry as `<objectName>.<name>`;
18
+ * 3. qualified name → retry with the `<objectName>.` prefix stripped.
19
+ *
20
+ * Returns the matching view id, or `undefined` when nothing matches — the
21
+ * caller decides how to fall back (and should warn rather than swallow it).
22
+ */
23
+ export declare function resolveViewId(requested: string | undefined | null, viewIds: readonly string[], objectName: string): string | undefined;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Resolve a URL-requested view name against the object's actual view ids.
10
+ *
11
+ * Canonical view ids are fully qualified (`<object>.<viewKind>`, see
12
+ * MetadataProvider), but nav items emit their `viewName` verbatim — usually
13
+ * the short form — and legacy embedded listViews carry bare keys (including
14
+ * the `'all'` fallback view). Accept both directions (#2217):
15
+ *
16
+ * 1. exact id match;
17
+ * 2. short name (no `.`) → retry as `<objectName>.<name>`;
18
+ * 3. qualified name → retry with the `<objectName>.` prefix stripped.
19
+ *
20
+ * Returns the matching view id, or `undefined` when nothing matches — the
21
+ * caller decides how to fall back (and should warn rather than swallow it).
22
+ */
23
+ export function resolveViewId(requested, viewIds, objectName) {
24
+ if (!requested)
25
+ return undefined;
26
+ const has = (id) => viewIds.includes(id);
27
+ if (has(requested))
28
+ return requested;
29
+ const prefix = `${objectName}.`;
30
+ if (!requested.includes('.') && has(prefix + requested)) {
31
+ return prefix + requested;
32
+ }
33
+ if (requested.startsWith(prefix) && has(requested.slice(prefix.length))) {
34
+ return requested.slice(prefix.length);
35
+ }
36
+ return undefined;
37
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ export declare function warnSuppressedListNav(objectName: string, viewId: string, viewDef: Record<string, unknown> | null | undefined, listSchema: Record<string, unknown> | null | undefined): boolean;
9
+ /** Test-only: clear the one-shot warning memory. */
10
+ export declare function resetSuppressedListNavWarnings(): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * ADR-0053: on an object's default list ("views" mode) the ViewTabBar is the
10
+ * only navigation control — `userFilters` / `quickFilters` belong to page
11
+ * lists (InterfaceListPage, "filters" mode) and are suppressed by
12
+ * `ObjectView.renderListView`.
13
+ *
14
+ * The suppression is correct, but until the phase-4 guardrail (zod `refine`
15
+ * + `check` rule) lands, an author who puts `userFilters` on an object list
16
+ * view gets a valid schema and a page that renders nothing where they expect
17
+ * filter controls — zero signal at any layer (#2219). Surface the drop with
18
+ * a one-shot console warning per object/view.
19
+ *
20
+ * Returns whether the view carried a suppressed field (mainly for tests).
21
+ */
22
+ const warned = new Set();
23
+ export function warnSuppressedListNav(objectName, viewId, viewDef, listSchema) {
24
+ const offending = ['userFilters', 'quickFilters'].filter((k) => (viewDef?.[k] ?? listSchema?.[k]) != null);
25
+ if (offending.length === 0)
26
+ return false;
27
+ const key = `${objectName}.${viewId}`;
28
+ if (!warned.has(key)) {
29
+ warned.add(key);
30
+ console.warn(`[ObjectView] View "${viewId}" on object "${objectName}" defines ` +
31
+ `${offending.join(' and ')}, which are ignored on an object list view ` +
32
+ `(ADR-0053 "views" mode — the view switcher is the only nav control here). ` +
33
+ `Move them to a page list (InterfaceListPage "filters" mode).`);
34
+ }
35
+ return true;
36
+ }
37
+ /** Test-only: clear the one-shot warning memory. */
38
+ export function resetSuppressedListNavWarnings() {
39
+ warned.clear();
40
+ }