@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +562 -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 +2 -0
  11. package/dist/index.js +6 -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/index.d.ts +2 -24
  17. package/dist/utils/index.js +14 -101
  18. package/dist/utils/resolveViewId.d.ts +23 -0
  19. package/dist/utils/resolveViewId.js +37 -0
  20. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  21. package/dist/utils/warnSuppressedListNav.js +40 -0
  22. package/dist/views/DashboardView.js +2 -3
  23. package/dist/views/InterfaceListPage.js +10 -5
  24. package/dist/views/ObjectView.js +65 -12
  25. package/dist/views/PageView.js +2 -3
  26. package/dist/views/RecordDetailView.js +131 -104
  27. package/dist/views/RecordFormPage.js +7 -1
  28. package/dist/views/RelatedRecordActionsBridge.d.ts +24 -0
  29. package/dist/views/RelatedRecordActionsBridge.js +114 -0
  30. package/dist/views/ReportView.js +2 -3
  31. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  32. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  33. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  34. package/dist/views/metadata-admin/clientValidation.js +8 -2
  35. package/dist/views/metadata-admin/color-variant-field.d.ts +1 -12
  36. package/dist/views/metadata-admin/color-variant-field.js +11 -0
  37. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  38. package/dist/views/metadata-admin/i18n.js +343 -2
  39. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +25 -11
  40. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  41. package/dist/views/metadata-admin/permission-slice.js +70 -0
  42. package/dist/views/metadata-admin/previews/AppNavCanvas.js +11 -7
  43. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  44. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  45. package/dist/views/metadata-admin/previews/OutlineStrip.d.ts +1 -13
  46. package/dist/views/metadata-admin/previews/OutlineStrip.js +12 -0
  47. package/dist/views/metadata-admin/previews/PagePreview.js +9 -0
  48. package/dist/views/metadata-admin/previews/SourcePageEditor.d.ts +28 -0
  49. package/dist/views/metadata-admin/previews/SourcePageEditor.js +83 -0
  50. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  51. package/dist/views/studio-design/BuilderLanding.js +133 -0
  52. package/dist/views/studio-design/ObjectFormDesigner.d.ts +31 -0
  53. package/dist/views/studio-design/ObjectFormDesigner.js +226 -0
  54. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  55. package/dist/views/studio-design/ObjectSettingsPanel.js +45 -0
  56. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  57. package/dist/views/studio-design/ObjectValidationsPanel.js +78 -0
  58. package/dist/views/studio-design/StudioDesignSurface.d.ts +20 -0
  59. package/dist/views/studio-design/StudioDesignSurface.js +1306 -0
  60. package/dist/views/studio-design/metadataError.d.ts +23 -0
  61. package/dist/views/studio-design/metadataError.js +44 -0
  62. package/dist/views/studio-design/packages-io.d.ts +27 -0
  63. package/dist/views/studio-design/packages-io.js +61 -0
  64. package/package.json +46 -43
@@ -0,0 +1,1306 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * StudioDesignSurface — the open-source WYSIWYG design surface (ADR-0080).
5
+ *
6
+ * Routed as /studio/:packageId/{data|automations|interfaces} — three pillars,
7
+ * each composed AROUND existing renderers (no new editor code):
8
+ * - Interfaces: the real App navigation tree → live canvas (getMetadataPreview)
9
+ * + inspector (getMetadataInspector), edits persisting via draft → publish.
10
+ * - Data: the package's objects → fields + record grid.
11
+ * - Automations: flows → FlowPreview (default OFF / review-then-enable).
12
+ *
13
+ * Open-core boundary: the left AI copilot is NOT part of the open-source
14
+ * surface — it is an injected slot (`aiSlot`) the cloud edition fills.
15
+ */
16
+ import * as React from 'react';
17
+ import { useParams, useNavigate, Link } from 'react-router-dom';
18
+ import { SchemaRenderer, useAdapter, SchemaRendererProvider } from '@object-ui/react';
19
+ import { GridFieldAuthoringProvider } from '@object-ui/components';
20
+ import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
21
+ import { ListView } from '@object-ui/plugin-list';
22
+ import { ObjectForm } from '@object-ui/plugin-form';
23
+ import { Boxes, FileText, Database, LayoutDashboard, BarChart3, Table2, Folder, Compass, Workflow, SlidersHorizontal, MousePointer2, Eye, Loader2, Save, Pencil, Check, Plus, X, GitBranch, Rocket, ChevronDown, Lock, ExternalLink, Home as HomeIcon, Shield, } from 'lucide-react';
24
+ import { getMetadataPreview } from '../metadata-admin/preview-registry';
25
+ import { PermissionMatrixEditPage } from '../metadata-admin/PermissionMatrixEditor';
26
+ import { getMetadataInspector } from '../metadata-admin/inspector-registry';
27
+ import { useMetadataClient } from '../metadata-admin/useMetadata';
28
+ import { formatMetadataError, formatPublishFailures } from './metadataError';
29
+ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
30
+ import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
31
+ import { readFields, writeFields, newField, toFieldName, toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io';
32
+ import { ObjectFormDesigner } from './ObjectFormDesigner';
33
+ import { ObjectValidationsPanel } from './ObjectValidationsPanel';
34
+ import { ObjectSettingsPanel } from './ObjectSettingsPanel';
35
+ import { fetchPackages, createBasePackage, PACKAGE_ID_RE } from './packages-io';
36
+ import { DraftChangesPanel } from '../../preview/DraftChangesPanel';
37
+ import { toast } from 'sonner';
38
+ const PILLARS = [
39
+ { key: 'data', label: 'Data', Icon: Database },
40
+ { key: 'automations', label: 'Automations', Icon: Workflow },
41
+ { key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard },
42
+ { key: 'access', label: 'Access', Icon: Shield },
43
+ ];
44
+ const KIND_ICON = {
45
+ group: Folder,
46
+ page: FileText,
47
+ object: Database,
48
+ dashboard: LayoutDashboard,
49
+ report: BarChart3,
50
+ view: Table2,
51
+ };
52
+ const navIcon = (type) => KIND_ICON[type ?? ''] ?? Compass;
53
+ /** Resolve a leaf nav node → the surface {type,name} it binds to. */
54
+ function resolveSurface(node) {
55
+ const label = String(node.label ?? '');
56
+ switch (node.type) {
57
+ case 'page':
58
+ return node.pageName || node.page ? { type: 'page', name: String(node.pageName || node.page), label } : null;
59
+ case 'object':
60
+ return node.objectName || node.object
61
+ ? { type: 'object', name: String(node.objectName || node.object), label }
62
+ : null;
63
+ case 'dashboard':
64
+ return node.dashboardName || node.dashboard
65
+ ? { type: 'dashboard', name: String(node.dashboardName || node.dashboard), label }
66
+ : null;
67
+ case 'report':
68
+ return node.reportName || node.report
69
+ ? { type: 'report', name: String(node.reportName || node.report), label }
70
+ : null;
71
+ case 'view':
72
+ return node.viewName || node.view ? { type: 'view', name: String(node.viewName || node.view), label } : null;
73
+ default:
74
+ return null;
75
+ }
76
+ }
77
+ /** Normalize the framework draft envelope `{ type, name, item }` → body | null. */
78
+ function extractDraftBody(resp) {
79
+ if (!resp || typeof resp !== 'object')
80
+ return null;
81
+ const env = resp;
82
+ if (!('item' in env))
83
+ return null;
84
+ const body = env.item;
85
+ if (!body || typeof body !== 'object')
86
+ return null;
87
+ return Object.keys(body).length > 0 ? body : null;
88
+ }
89
+ /** Top-bar package switcher: list app packages (可写 base vs 只读 code), switch by
90
+ * navigation, and create a new writable base inline (POST /packages {id,name}). */
91
+ function PackageSwitcher({ packageId, tab }) {
92
+ const navigate = useNavigate();
93
+ const locale = useMetadataLocale();
94
+ const [open, setOpen] = React.useState(false);
95
+ const [pkgs, setPkgs] = React.useState(null);
96
+ const [creating, setCreating] = React.useState(false);
97
+ const [newName, setNewName] = React.useState('');
98
+ const [newId, setNewId] = React.useState('');
99
+ const [idTouched, setIdTouched] = React.useState(false);
100
+ const [busy, setBusy] = React.useState(false);
101
+ const [err, setErr] = React.useState(null);
102
+ React.useEffect(() => {
103
+ let cancelled = false;
104
+ fetchPackages()
105
+ .then((parsed) => {
106
+ if (!cancelled)
107
+ setPkgs(parsed);
108
+ })
109
+ .catch(() => {
110
+ /* leave null — switcher still works for navigation-free display */
111
+ });
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [packageId]);
116
+ const current = pkgs?.find((p) => p.id === packageId) ?? null;
117
+ const doCreate = React.useCallback(async () => {
118
+ const name = newName.trim();
119
+ const id = newId.trim();
120
+ if (!name || !PACKAGE_ID_RE.test(id))
121
+ return;
122
+ setBusy(true);
123
+ setErr(null);
124
+ try {
125
+ await createBasePackage(id, name);
126
+ toast.success(tFormat('engine.studio.pkg.created', locale, { name }));
127
+ setOpen(false);
128
+ setCreating(false);
129
+ setNewName('');
130
+ setNewId('');
131
+ setIdTouched(false);
132
+ navigate(`/studio/${encodeURIComponent(id)}/data`);
133
+ }
134
+ catch (e) {
135
+ setErr(formatMetadataError(e));
136
+ }
137
+ finally {
138
+ setBusy(false);
139
+ }
140
+ }, [newName, newId, navigate]);
141
+ return (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setOpen((v) => !v), className: "flex items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[13px] font-medium hover:bg-muted", title: t('engine.studio.pkg.switchTitle', locale), children: [_jsx(Boxes, { className: "h-4 w-4" }), " ", current?.name ?? packageId, current && !current.writable && (_jsxs("span", { className: "inline-flex items-center gap-0.5 rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-normal text-amber-600 dark:text-amber-300", children: [_jsx(Lock, { className: "h-2.5 w-2.5" }), " ", t('engine.studio.pkg.readonly', locale)] })), _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), open && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setOpen(false) }), _jsxs("div", { className: "absolute left-0 top-full z-50 mt-1 w-80 rounded-lg border bg-background p-1.5 shadow-lg", children: [_jsx("p", { className: "px-2 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: t('engine.studio.pkg.heading', locale) }), _jsxs("div", { className: "max-h-64 overflow-auto", children: [pkgs === null && _jsx("p", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: t('engine.studio.loading', locale) }), pkgs?.length === 0 && _jsx("p", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: t('engine.studio.pkg.none', locale) }), pkgs?.map((p) => (_jsxs("button", { type: "button", onClick: () => {
142
+ setOpen(false);
143
+ navigate(`/studio/${encodeURIComponent(p.id)}/${tab}`);
144
+ }, className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
145
+ (p.id === packageId ? 'bg-muted font-medium' : 'hover:bg-muted/60'), children: [_jsx(Boxes, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: p.name }), _jsx("span", { className: "block truncate font-mono text-[10px] text-muted-foreground", children: p.id })] }), p.writable ? (_jsx("span", { className: "rounded bg-emerald-400/15 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300", children: t('engine.studio.pkg.writable', locale) })) : (_jsxs("span", { className: "inline-flex items-center gap-0.5 rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300", children: [_jsx(Lock, { className: "h-2.5 w-2.5" }), " ", t('engine.studio.pkg.readonly', locale)] }))] }, p.id)))] }), _jsx("div", { className: "mt-1 border-t pt-1.5", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5 px-1 pb-1", children: [_jsx("input", { autoFocus: true, value: newName, onChange: (e) => {
146
+ setNewName(e.target.value);
147
+ if (!idTouched) {
148
+ const slug = toFieldNameLoose(e.target.value).replace(/_/g, '-');
149
+ setNewId(slug ? `com.example.${slug}` : '');
150
+ }
151
+ }, onKeyDown: (e) => {
152
+ if (e.key === 'Enter')
153
+ void doCreate();
154
+ if (e.key === 'Escape')
155
+ setCreating(false);
156
+ }, placeholder: t('engine.studio.pkg.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newId, onChange: (e) => {
157
+ setIdTouched(true);
158
+ setNewId(e.target.value.toLowerCase().replace(/[^a-z0-9_.-]/g, ''));
159
+ }, onKeyDown: (e) => {
160
+ if (e.key === 'Enter')
161
+ void doCreate();
162
+ if (e.key === 'Escape')
163
+ setCreating(false);
164
+ }, placeholder: t('engine.studio.pkg.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), err && _jsx("p", { className: "text-[10px] text-destructive", children: err }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newName.trim() || !PACKAGE_ID_RE.test(newId.trim()), className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.pkg.createWritable', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.pkg.new', locale)] })) })] })] }))] }));
165
+ }
166
+ export function StudioDesignSurface({ aiSlot }) {
167
+ const params = useParams();
168
+ const packageId = params.packageId ?? 'com.example.showcase';
169
+ const tab = params.tab ?? 'interfaces';
170
+ const locale = useMetadataLocale();
171
+ // Package-level publish (ADR-0033/0037/0048): edits accumulate as per-item
172
+ // drafts STAMPED with this package (each save passes packageId → the draft row's
173
+ // sys_metadata.package_id). Publishing promotes exactly THIS package's drafts in
174
+ // one atomic pass (POST /packages/:id/publish-drafts), reviewed as a whole in
175
+ // DraftChangesPanel. There is no per-item publish.
176
+ const [changesOpen, setChangesOpen] = React.useState(false);
177
+ const [pendingCount, setPendingCount] = React.useState(null);
178
+ const [publishing, setPublishing] = React.useState(false);
179
+ const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline
180
+ const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → refresh the pending-draft count
181
+ const refreshPending = React.useCallback(async () => {
182
+ try {
183
+ const res = await fetch(`/api/v1/meta/_drafts?packageId=${encodeURIComponent(packageId)}`, {
184
+ credentials: 'include',
185
+ headers: { Accept: 'application/json' },
186
+ cache: 'no-store',
187
+ });
188
+ if (!res.ok)
189
+ return setPendingCount(null);
190
+ const data = (await res.json());
191
+ const list = (Array.isArray(data) ? data : (data?.drafts ?? []));
192
+ setPendingCount(list.length);
193
+ }
194
+ catch {
195
+ setPendingCount(null);
196
+ }
197
+ }, [packageId]);
198
+ React.useEffect(() => {
199
+ void refreshPending();
200
+ }, [refreshPending, publishNonce, draftNonce]);
201
+ const doPublish = React.useCallback(async () => {
202
+ setPublishing(true);
203
+ try {
204
+ const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
205
+ method: 'POST',
206
+ credentials: 'include',
207
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
208
+ body: '{}',
209
+ });
210
+ const payload = (await res.json().catch(() => null));
211
+ if (!res.ok || payload?.success === false) {
212
+ // Hard failure (e.g. package not found) — carry the field-anchored issues.
213
+ throw Object.assign(new Error(payload?.error?.message || `HTTP ${res.status}`), {
214
+ issues: payload?.error?.details?.issues,
215
+ });
216
+ }
217
+ const failed = payload?.data?.failed ?? [];
218
+ if (failed.length > 0) {
219
+ // Partial publish: some drafts did NOT go live. The server returns 200
220
+ // with them buried in `failed[]`, so the UI used to claim success and
221
+ // swallow the reason — surface which drafts failed and why instead.
222
+ toast.error(formatPublishFailures(failed));
223
+ }
224
+ else {
225
+ toast.success(t('engine.studio.publishedAll', locale));
226
+ setChangesOpen(false);
227
+ }
228
+ setPublishNonce((n) => n + 1);
229
+ }
230
+ catch (e) {
231
+ toast.error(formatMetadataError(e));
232
+ }
233
+ finally {
234
+ setPublishing(false);
235
+ }
236
+ await refreshPending();
237
+ }, [packageId, refreshPending]);
238
+ const onDraftSaved = React.useCallback(() => setDraftNonce((n) => n + 1), []);
239
+ const hasPending = (pendingCount ?? 0) > 0;
240
+ // Builder → running-app bridge (Airtable's Launch): the builder edits the
241
+ // package (设计界面), the app is its published front-end. If this package
242
+ // ships an app, offer 打开应用 — opened in a new tab so the builder context
243
+ // survives. (App → builder is the reverse bridge, tracked separately.)
244
+ const shellNavigate = useNavigate();
245
+ const shellClient = useMetadataClient();
246
+ const [packageApp, setPackageApp] = React.useState(null);
247
+ // 创建应用 (package has no app yet): create a draft `app` item — the published
248
+ // front-end's on-ramp. The button flips to 打开应用 after the package publish.
249
+ const [appCreating, setAppCreating] = React.useState(false);
250
+ const [appLabel, setAppLabel] = React.useState('');
251
+ const [appName, setAppName] = React.useState('');
252
+ const [appNameTouched, setAppNameTouched] = React.useState(false);
253
+ const [appBusy, setAppBusy] = React.useState(false);
254
+ const [appDraftPending, setAppDraftPending] = React.useState(null);
255
+ const doCreateApp = React.useCallback(async () => {
256
+ const label = appLabel.trim();
257
+ const name = toFieldName(appName.trim() || label);
258
+ if (!label || !name || name === 'field')
259
+ return;
260
+ setAppBusy(true);
261
+ try {
262
+ await shellClient.save('app', name, { name, label, active: true, navigation: [] }, { mode: 'draft', packageId });
263
+ toast.success(tFormat('engine.studio.app.savedDraft', locale, { label }));
264
+ setAppDraftPending(label);
265
+ setAppCreating(false);
266
+ setAppLabel('');
267
+ setAppName('');
268
+ setAppNameTouched(false);
269
+ setDraftNonce((n) => n + 1);
270
+ }
271
+ catch (e) {
272
+ toast.error(formatMetadataError(e));
273
+ }
274
+ finally {
275
+ setAppBusy(false);
276
+ }
277
+ }, [appLabel, appName, shellClient, packageId]);
278
+ React.useEffect(() => {
279
+ let cancelled = false;
280
+ (async () => {
281
+ try {
282
+ const apps = (await shellClient.list('app', { packageId }));
283
+ const first = (apps || [])
284
+ .map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
285
+ .filter((a) => a.name)[0];
286
+ if (!cancelled)
287
+ setPackageApp(first ?? null);
288
+ }
289
+ catch {
290
+ if (!cancelled)
291
+ setPackageApp(null);
292
+ }
293
+ })();
294
+ return () => {
295
+ cancelled = true;
296
+ };
297
+ }, [shellClient, packageId, publishNonce]);
298
+ return (_jsxs("div", { className: "flex h-screen w-full overflow-hidden bg-background text-foreground", children: [aiSlot ? _jsx("aside", { className: "w-64 shrink-0 overflow-auto border-r bg-muted/40", children: aiSlot }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex items-center gap-3 border-b px-3 py-2", children: [_jsx("button", { type: "button", onClick: () => shellNavigate('/home'), title: t('engine.studio.home', locale), className: "rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(HomeIcon, { className: "h-4 w-4" }) }), _jsx(PackageSwitcher, { packageId: packageId, tab: tab }), _jsx("span", { className: "text-muted-foreground", children: "\u00B7" }), _jsx("nav", { className: "flex gap-1", children: PILLARS.map((p) => (_jsxs(Link, { to: `/studio/${packageId}/${p.key}`, className: 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors ' +
299
+ (tab === p.key
300
+ ? 'bg-primary/10 font-medium text-primary'
301
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), t(`engine.studio.pillar.${p.key}`, locale)] }, p.key))) }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [packageApp ? (_jsxs("button", { type: "button", onClick: () => window.open(`/apps/${encodeURIComponent(packageApp.name)}`, '_blank'), title: tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label }), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), t('engine.studio.app.open', locale)] })) : appDraftPending ? (_jsx("span", { title: t('engine.studio.app.willOpenAfterPublish', locale), className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: tFormat('engine.studio.app.pending', locale, { label: appDraftPending }) })) : (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAppCreating((v) => !v), title: t('engine.studio.app.noneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), t('engine.studio.app.create', locale)] }), appCreating && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setAppCreating(false) }), _jsxs("div", { className: "absolute right-0 top-full z-50 mt-1 flex w-72 flex-col gap-1.5 rounded-lg border bg-background p-2 shadow-lg", children: [_jsx("input", { autoFocus: true, value: appLabel, onChange: (e) => {
302
+ setAppLabel(e.target.value);
303
+ if (!appNameTouched)
304
+ setAppName(toFieldNameLoose(e.target.value));
305
+ }, onKeyDown: (e) => {
306
+ if (e.key === 'Enter')
307
+ void doCreateApp();
308
+ if (e.key === 'Escape')
309
+ setAppCreating(false);
310
+ }, placeholder: t('engine.studio.app.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: appName, onChange: (e) => {
311
+ setAppNameTouched(true);
312
+ setAppName(toFieldNameLoose(e.target.value));
313
+ }, onKeyDown: (e) => {
314
+ if (e.key === 'Enter')
315
+ void doCreateApp();
316
+ if (e.key === 'Escape')
317
+ setAppCreating(false);
318
+ }, placeholder: t('engine.studio.app.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("button", { type: "button", onClick: () => void doCreateApp(), disabled: appBusy || !appLabel.trim() || !toFieldName(appName.trim() || appLabel) || toFieldName(appName.trim() || appLabel) === 'field', className: "inline-flex items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [appBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] })] })] }))] })), _jsxs("button", { type: "button", onClick: () => setChangesOpen(true), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(GitBranch, { className: "h-3.5 w-3.5" }), t('engine.studio.changes', locale), hasPending ? ` · ${pendingCount}` : ''] }), _jsxs("button", { type: "button", onClick: doPublish, disabled: publishing || !hasPending, title: hasPending ? t('engine.studio.publishTitle', locale) : t('engine.studio.publishNoneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [publishing ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Rocket, { className: "h-3.5 w-3.5" }), t('engine.studio.publish', locale)] })] })] }), _jsx("div", { className: "min-h-0 flex-1", children: tab === 'data' ? (_jsx(DataPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : tab === 'access' ? (_jsx(AccessPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : (_jsx(InterfacesPillar, { packageId: packageId, publishNonce: publishNonce, draftNonce: draftNonce, onDraftSaved: onDraftSaved, onCreateApp: () => setAppCreating(true) })) })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen, packageId: packageId })] }));
319
+ }
320
+ /** Recursive App-navigation tree (groups + typed leaves). */
321
+ function NavTree({ nodes, active, onPick, }) {
322
+ return (_jsx(_Fragment, { children: nodes.map((node, i) => {
323
+ if (node.type === 'group' || (Array.isArray(node.children) && node.children.length)) {
324
+ return (_jsxs("div", { className: "mb-1", children: [_jsxs("p", { className: "flex items-center gap-1 px-2 pb-1 pt-3 text-[11px] text-muted-foreground", children: [_jsx(Folder, { className: "h-3 w-3" }), " ", node.label] }), _jsx("div", { className: "pl-1.5", children: _jsx(NavTree, { nodes: node.children ?? [], active: active, onPick: onPick }) })] }, node.id ?? i));
325
+ }
326
+ const surface = resolveSurface(node);
327
+ const Icon = navIcon(node.type);
328
+ const isActive = !!surface && active?.type === surface.type && active?.name === surface.name;
329
+ return (_jsxs("button", { onClick: () => surface && onPick(surface), disabled: !surface, title: surface ? `${surface.type} · ${surface.name}` : node.label, className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs disabled:opacity-40 ' +
330
+ (isActive ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Icon, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: node.label }), surface && surface.type !== 'page' && (_jsx("span", { className: "rounded bg-muted px-1 py-px text-[9px] uppercase text-muted-foreground", children: surface.type }))] }, node.id ?? i));
331
+ }) }));
332
+ }
333
+ /** Interfaces pillar — real App nav · live canvas · inspector. */
334
+ /**
335
+ * StudioNavItemInspector — right-panel editor for the selected nav item while
336
+ * editing an app's navigation. The Studio adds flat top-level items
337
+ * (`navigation[i]`), so binding is a business-friendly object picker rather
338
+ * than the raw path field of the generic AppNavInspector: picking an object
339
+ * writes `{ object }` (which the runtime resolves to that object's record
340
+ * list) and, if the label is still the placeholder, adopts the object's label.
341
+ */
342
+ function StudioNavItemInspector({ navId, appDraft, objects, onNavPatch, onClear, }) {
343
+ const locale = useMetadataLocale();
344
+ const idx = React.useMemo(() => {
345
+ const m = /^navigation\[(\d+)\]$/.exec(navId);
346
+ return m ? Number(m[1]) : -1;
347
+ }, [navId]);
348
+ const nav = React.useMemo(() => (Array.isArray(appDraft.navigation) ? appDraft.navigation : []), [appDraft]);
349
+ const node = idx >= 0 ? nav[idx] : null;
350
+ if (!node) {
351
+ return (_jsx("div", { className: "px-2 py-10 text-center text-xs text-muted-foreground", children: t('engine.studio.nav.selectItem', locale) }));
352
+ }
353
+ const patch = (updates) => {
354
+ onNavPatch({ navigation: nav.map((n, i) => (i === idx ? { ...n, ...updates } : n)) });
355
+ };
356
+ const boundObject = String(node.object ?? node.objectName ?? '');
357
+ const curLabel = String(node.label ?? node.title ?? node.name ?? '');
358
+ // A nav card is a placeholder until its label is edited or a target adopts a
359
+ // real label. Match both the legacy English sentinel and the locale-specific
360
+ // default from AppNavCanvas so items created in any locale are recognized.
361
+ const isPlaceholder = !curLabel || curLabel === 'New item' || curLabel === t('engine.appNav.newItem', locale);
362
+ return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-[11px] font-medium text-muted-foreground", children: t('engine.studio.nav.label', locale) }), _jsx("input", { value: curLabel, onChange: (e) => patch({ label: e.target.value }), placeholder: t('engine.studio.nav.labelPlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 text-xs" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-[11px] font-medium text-muted-foreground", children: t('engine.studio.nav.linkObject', locale) }), _jsxs("select", { value: boundObject, onChange: (e) => {
363
+ const objName = e.target.value;
364
+ const obj = objects.find((o) => o.name === objName);
365
+ if (!objName) {
366
+ // Unbind → back to an (invalid, dropped-on-save) placeholder.
367
+ patch({ type: undefined, objectName: undefined, object: undefined });
368
+ return;
369
+ }
370
+ // Emit a spec-complete ObjectNavItem: the app schema's nav is a
371
+ // discriminated union on `type` and BaseNavItem requires a
372
+ // snake_case `id`. Missing either fails "navigation.0: Invalid
373
+ // input" at save. `object`/`path` are cleared so no stray keys
374
+ // linger from the blank placeholder.
375
+ patch({
376
+ id: node.id || `nav_${objName}`,
377
+ type: 'object',
378
+ objectName: objName,
379
+ object: undefined,
380
+ path: undefined,
381
+ label: isPlaceholder && obj ? obj.label : curLabel,
382
+ });
383
+ }, className: "w-full rounded border bg-background px-2 py-1 text-xs", children: [_jsx("option", { value: "", children: t('engine.studio.nav.chooseObject', locale) }), objects.map((o) => (_jsxs("option", { value: o.name, children: [o.label, " (", o.name, ")"] }, o.name)))] }), _jsx("p", { className: "mt-1 text-[11px] text-muted-foreground", children: boundObject ? t('engine.studio.nav.boundHint', locale) : t('engine.studio.nav.unboundHint', locale) }), objects.length === 0 && (_jsx("p", { className: "mt-1 text-[11px] text-amber-600 dark:text-amber-400", children: t('engine.studio.nav.noObjects', locale) }))] }), _jsx("button", { type: "button", onClick: onClear, className: "text-[11px] text-muted-foreground underline-offset-2 hover:underline", children: t('engine.studio.deselect', locale) })] }));
384
+ }
385
+ function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraftSaved, onCreateApp, }) {
386
+ const client = useMetadataClient();
387
+ const locale = useMetadataLocale();
388
+ const [appLabel, setAppLabel] = React.useState(packageId);
389
+ const [appName, setAppName] = React.useState(null);
390
+ const [appDraft, setAppDraft] = React.useState({});
391
+ const navTree = React.useMemo(() => (Array.isArray(appDraft.navigation) ? appDraft.navigation : []), [appDraft]);
392
+ // nav editing — drag-drop reorder / rename / add / remove via AppNavCanvas
393
+ const [editNav, setEditNav] = React.useState(false);
394
+ const [navSel, setNavSel] = React.useState(null);
395
+ const [navDirty, setNavDirty] = React.useState(false);
396
+ const [navHasDraft, setNavHasDraft] = React.useState(false);
397
+ const [navSaving, setNavSaving] = React.useState(false);
398
+ const [current, setCurrent] = React.useState(null);
399
+ const [draft, setDraft] = React.useState({});
400
+ const [selection, setSelection] = React.useState(null);
401
+ const [loading, setLoading] = React.useState(false);
402
+ const [saving, setSaving] = React.useState(false);
403
+ const [hasDraft, setHasDraft] = React.useState(false);
404
+ const [error, setError] = React.useState(null);
405
+ // App resolution status — tells "still loading" apart from "this package has
406
+ // no app", so the canvas shows a real empty state instead of an endless
407
+ // spinner.
408
+ const [appStatus, setAppStatus] = React.useState('loading');
409
+ // Objects in THIS package (published ∪ draft) — the nav item inspector's
410
+ // object picker, so nav can be wired to sibling objects before publishing.
411
+ const [pkgObjects, setPkgObjects] = React.useState([]);
412
+ React.useEffect(() => {
413
+ let cancelled = false;
414
+ (async () => {
415
+ try {
416
+ const [pub, drafts] = await Promise.all([
417
+ client.list('object', { packageId }),
418
+ client.listDrafts({ packageId, type: 'object' }).catch(() => []),
419
+ ]);
420
+ if (cancelled)
421
+ return;
422
+ const byName = new Map();
423
+ for (const raw of [...(pub || []), ...(drafts || [])]) {
424
+ const o = raw;
425
+ const name = String(o.name ?? '');
426
+ if (!name || byName.has(name))
427
+ continue;
428
+ byName.set(name, { name, label: String(o.label ?? o.name ?? name) });
429
+ }
430
+ setPkgObjects([...byName.values()]);
431
+ }
432
+ catch {
433
+ /* non-fatal — picker just stays empty */
434
+ }
435
+ })();
436
+ return () => {
437
+ cancelled = true;
438
+ };
439
+ }, [client, packageId, publishNonce, draftNonce]);
440
+ // Resolve THIS package's App → load its navigation tree. The query is scoped
441
+ // to the package (`list('app', { packageId })`) so a design surface only ever
442
+ // shows the current package's app — never another package's. `list()` sees
443
+ // published metadata only, so a freshly-created (unpublished) app is found via
444
+ // `listDrafts()` instead, keeping it designable before its first publish.
445
+ React.useEffect(() => {
446
+ let cancelled = false;
447
+ setAppStatus('loading');
448
+ (async () => {
449
+ try {
450
+ const published = (await client.list('app', { packageId }));
451
+ if (cancelled)
452
+ return;
453
+ let name = published?.[0]?.name ? String(published[0].name) : null;
454
+ let label = published?.[0]
455
+ ? String(published[0].label ?? published[0].name ?? packageId)
456
+ : packageId;
457
+ if (!name) {
458
+ const drafts = await client.listDrafts({ packageId, type: 'app' });
459
+ if (cancelled)
460
+ return;
461
+ const d = drafts?.[0];
462
+ if (d?.name) {
463
+ name = String(d.name);
464
+ label = String(d.name);
465
+ }
466
+ }
467
+ if (!name) {
468
+ setAppStatus('missing');
469
+ return;
470
+ }
471
+ setAppLabel(label);
472
+ setAppName(name);
473
+ const [layRaw, appDraftResp] = await Promise.all([
474
+ client.layered('app', name),
475
+ client.getDraft('app', name).catch(() => null),
476
+ ]);
477
+ if (cancelled)
478
+ return;
479
+ const lay = layRaw;
480
+ const eff = (lay.effective ?? lay.code ?? {});
481
+ const appDraftBody = extractDraftBody(appDraftResp);
482
+ const body = appDraftBody ? { ...eff, ...appDraftBody } : eff;
483
+ if (typeof body.label === 'string' || typeof body.name === 'string') {
484
+ setAppLabel(String(body.label ?? body.name ?? label));
485
+ }
486
+ setAppDraft(body);
487
+ setNavHasDraft(!!appDraftBody);
488
+ setAppStatus('ready');
489
+ const tree = Array.isArray(body.navigation) ? body.navigation : [];
490
+ // auto-open the first resolvable leaf
491
+ const firstLeaf = (function find(nodes) {
492
+ for (const n of nodes) {
493
+ if (n.type === 'group' || n.children?.length) {
494
+ const r = find(n.children ?? []);
495
+ if (r)
496
+ return r;
497
+ }
498
+ else {
499
+ const s = resolveSurface(n);
500
+ if (s)
501
+ return s;
502
+ }
503
+ }
504
+ return null;
505
+ })(tree);
506
+ setCurrent((cur) => cur ?? firstLeaf);
507
+ }
508
+ catch (e) {
509
+ if (!cancelled) {
510
+ setError(formatMetadataError(e));
511
+ setAppStatus('missing');
512
+ }
513
+ }
514
+ })();
515
+ return () => {
516
+ cancelled = true;
517
+ };
518
+ }, [client, packageId, publishNonce, draftNonce]);
519
+ const Preview = getMetadataPreview(current?.type ?? '');
520
+ const Inspector = getMetadataInspector(current?.type ?? '');
521
+ // Object leaves render as a runtime records grid (preview = runtime); schema
522
+ // editing is the Data pillar's job, so they are not draft-editable in this canvas.
523
+ const isEditable = !!Preview && current?.type !== 'object';
524
+ // Load the selected surface's draft (only for editable preview types).
525
+ React.useEffect(() => {
526
+ if (!current || !isEditable) {
527
+ setDraft({});
528
+ setHasDraft(false);
529
+ return;
530
+ }
531
+ let cancelled = false;
532
+ setLoading(true);
533
+ setError(null);
534
+ setSelection(null);
535
+ (async () => {
536
+ try {
537
+ const [lay, draftResp] = await Promise.all([
538
+ client.layered(current.type, current.name),
539
+ client.getDraft(current.type, current.name).catch(() => null),
540
+ ]);
541
+ if (cancelled)
542
+ return;
543
+ const baseline = (lay.effective ??
544
+ lay.code ??
545
+ {});
546
+ const body = extractDraftBody(draftResp);
547
+ setDraft(body ? { ...baseline, ...body } : baseline);
548
+ setHasDraft(!!body);
549
+ }
550
+ catch (e) {
551
+ if (!cancelled)
552
+ setError(formatMetadataError(e));
553
+ }
554
+ finally {
555
+ if (!cancelled)
556
+ setLoading(false);
557
+ }
558
+ })();
559
+ return () => {
560
+ cancelled = true;
561
+ };
562
+ }, [client, current, isEditable, publishNonce]);
563
+ const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
564
+ const doSave = React.useCallback(async () => {
565
+ if (!current)
566
+ return;
567
+ setSaving('draft');
568
+ try {
569
+ await client.save(current.type, current.name, draft, { mode: 'draft', packageId });
570
+ setHasDraft(true);
571
+ onDraftSaved?.();
572
+ }
573
+ catch (e) {
574
+ setError(formatMetadataError(e));
575
+ }
576
+ finally {
577
+ setSaving(false);
578
+ }
579
+ }, [client, current, draft, onDraftSaved]);
580
+ // nav editing — patch appDraft.navigation, then save/publish the App overlay
581
+ const onNavPatch = React.useCallback((patch) => {
582
+ setAppDraft((d) => ({ ...d, ...patch }));
583
+ setNavDirty(true);
584
+ }, []);
585
+ const doNavSave = React.useCallback(async () => {
586
+ if (!appName)
587
+ return;
588
+ setNavSaving('draft');
589
+ try {
590
+ // "Add nav item" inserts a blank placeholder that only becomes a valid,
591
+ // spec-conformant item once a target is picked in the inspector. Drop
592
+ // still-untargeted placeholders (no `type`) so one stray blank can't fail
593
+ // the whole app's spec validation ("navigation.N: Invalid input"), and
594
+ // backfill a snake_case id defensively.
595
+ const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : [];
596
+ const cleanedNav = rawNav
597
+ .filter((n) => n && typeof n.type === 'string')
598
+ .map((n, i) => {
599
+ const item = n;
600
+ return typeof item.id === 'string' && item.id ? item : { ...item, id: `nav_item_${i + 1}` };
601
+ });
602
+ await client.save('app', appName, { ...appDraft, navigation: cleanedNav }, { mode: 'draft', packageId });
603
+ setNavHasDraft(true);
604
+ setNavDirty(false);
605
+ onDraftSaved?.();
606
+ }
607
+ catch (e) {
608
+ setError(formatMetadataError(e));
609
+ }
610
+ finally {
611
+ setNavSaving(false);
612
+ }
613
+ }, [client, appName, appDraft, onDraftSaved]);
614
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: [current.type, " \u00B7 ", current.name] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: (editNav ? 'w-72' : 'w-52') + ' flex shrink-0 flex-col border-r', children: [_jsxs("div", { className: "shrink-0 border-b px-2 py-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("p", { className: "truncate text-[11px] font-medium text-muted-foreground", children: tFormat('engine.studio.if.navHeading', locale, { app: appLabel }) }), appStatus === 'ready' && (_jsx("button", { type: "button", onClick: () => {
615
+ setEditNav((v) => !v);
616
+ setNavSel(null);
617
+ }, title: editNav ? t('engine.studio.if.doneEditTitle', locale) : t('engine.studio.if.editNavTitle', locale), className: 'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ' +
618
+ (editNav ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted'), children: editNav ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "h-3 w-3" }), " ", t('engine.studio.done', locale)] })) : (_jsxs(_Fragment, { children: [_jsx(Pencil, { className: "h-3 w-3" }), " ", t('engine.studio.edit', locale)] })) }))] }), editNav && (_jsxs("div", { className: "mt-1.5 flex items-center gap-1.5", children: [navHasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublished', locale) })), _jsxs("button", { onClick: doNavSave, disabled: !navDirty || !!navSaving, className: "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [navSaving === 'draft' ? (_jsx(Loader2, { className: "h-3 w-3 animate-spin" })) : (_jsx(Save, { className: "h-3 w-3" })), t('engine.studio.saveDraft', locale)] })] }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-2", children: appStatus === 'missing' && !error ? (_jsxs("div", { className: "px-2 py-3", children: [_jsx("p", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.noApp', locale) }), onCreateApp && (_jsxs("button", { type: "button", onClick: onCreateApp, className: "mt-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.app.create', locale)] }))] })) : editNav ? (_jsx(AppNavCanvas, { draft: appDraft, rootKey: "navigation", onPatch: onNavPatch, selection: navSel, onSelectionChange: (s) => setNavSel(s ? { kind: s.kind, id: s.id } : null) })) : navTree.length === 0 ? (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error
619
+ ? t('engine.studio.loadFailed', locale)
620
+ : appStatus === 'loading'
621
+ ? t('engine.studio.loading', locale)
622
+ : t('engine.studio.if.noNavItems', locale) })) : (_jsx(NavTree, { nodes: navTree, active: current, onPick: setCurrent })) })] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), " ", t('engine.studio.if.previewIsRuntime', locale)] }), current && (_jsxs("span", { className: "text-[11px] text-muted-foreground", children: [current.type, " \u00B7 ", current.name] }))] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-line", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: appStatus === 'missing' && !error ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-16 text-center", children: [_jsx("div", { className: "flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(LayoutDashboard, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.if.noAppTitle', locale) }), _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: t('engine.studio.if.noAppHint', locale) })] }), onCreateApp && (_jsxs("button", { type: "button", onClick: onCreateApp, className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.app.create', locale)] }))] })) : !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " ", t('engine.studio.loading', locale)] })) : current.type === 'object' ? (_jsx(SchemaRenderer, { schema: { type: 'object-view', objectName: current.name } })) : Preview ? (_jsx(Preview, { type: current.type, name: current.name, draft: draft, editing: true, selection: selection, onSelectionChange: setSelection, onPatch: onPatch, locale: locale })) : (_jsx("div", { className: "py-12 text-center text-xs text-muted-foreground", children: tFormat('engine.studio.if.readonlyPreview', locale, { type: current.type }) })) }), isEditable ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.if.editHint', locale)] })) : current?.type === 'object' ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(Database, { className: "h-3 w-3" }), " ", t('engine.studio.if.objectHintPre', locale), _jsx("span", { className: "font-medium", children: "Data" }), t('engine.studio.if.objectHintPost', locale)] })) : null] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.inspector.props', locale) }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.deselect', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: editNav && navSel ? (_jsx(StudioNavItemInspector, { navId: navSel.id, appDraft: appDraft, objects: pkgObjects, onNavPatch: onNavPatch, onClear: () => setNavSel(null) })) : selection && Inspector && current ? (_jsx(Inspector, { type: current.type, name: current.name, draft: draft, selection: selection, onPatch: onPatch, onClearSelection: () => setSelection(null), onSelectionChange: setSelection, readOnly: false, locale: locale })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), t('engine.studio.inspector.emptyLine1', locale), _jsx("br", {}), t('engine.studio.inspector.emptyLine2', locale)] })) })] })] })] }));
623
+ }
624
+ /** Next unused `field_N` name for a freshly-added field. */
625
+ function nextFieldName(existing) {
626
+ let i = existing.length + 1;
627
+ let name = `field_${i}`;
628
+ while (existing.includes(name))
629
+ name = `field_${++i}`;
630
+ return name;
631
+ }
632
+ /**
633
+ * Data pillar — the package's objects: a records grid (Airtable parity) plus
634
+ * table-based field management. Add a field, or click a column header's edit
635
+ * affordance, to open ObjectFieldInspector (full type list + per-type config)
636
+ * in the right panel; changes persist via the object draft → publish overlay.
637
+ */
638
+ /**
639
+ * Framework-managed/audit fields. They lead the raw metadata order but aren't
640
+ * what a user manages in a data grid, so the Data pillar drops them from the
641
+ * column set (mirrors ObjectGrid's regular-vs-system split) to open on the
642
+ * meaningful fields first — the same way Airtable hides system columns.
643
+ */
644
+ const STUDIO_SYSTEM_FIELD_NAMES = new Set([
645
+ '_id', 'id', 'organization_id', 'org_id', 'space_id',
646
+ 'created_at', 'created_by', 'updated_at', 'updated_by',
647
+ 'modified_at', 'modified_by', 'created_time', 'modified_time', 'updated_time',
648
+ 'deleted_at', 'deleted_by',
649
+ ]);
650
+ /**
651
+ * Render the Data pillar's records grid using the SAME rich list surface as the
652
+ * runtime list pages — the standard toolbar (view switcher, search, sort, filter,
653
+ * group, hide-fields) plus Airtable-style inline data management. This is the
654
+ * plugin ObjectView's `renderListView` slot, so the object-view still owns data
655
+ * fetching while ListView owns the toolbar + grid. Defined at module scope (not
656
+ * inline) so it stays a static component reference.
657
+ */
658
+ function renderStudioGridList(props) {
659
+ const { schema: listSchema, dataSource: ds, onEdit, className, refreshKey } = props;
660
+ return (_jsx(ListView, { schema: {
661
+ ...listSchema,
662
+ viewType: 'grid',
663
+ showSearch: true,
664
+ showSort: true,
665
+ showFilters: true,
666
+ showGroup: true,
667
+ showHideFields: true,
668
+ inlineEdit: true,
669
+ addDeleteRecordsInline: true,
670
+ }, dataSource: ds, onEdit: onEdit, className: className, refreshKey: refreshKey }));
671
+ }
672
+ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
673
+ const client = useMetadataClient();
674
+ const adapter = useAdapter();
675
+ const locale = useMetadataLocale();
676
+ const [objects, setObjects] = React.useState([]);
677
+ const [objectsLoaded, setObjectsLoaded] = React.useState(false);
678
+ const [current, setCurrent] = React.useState(null);
679
+ const [objDraft, setObjDraft] = React.useState({});
680
+ const [loading, setLoading] = React.useState(false);
681
+ const [error, setError] = React.useState(null);
682
+ // field management — a selected field opens ObjectFieldInspector (full type + config)
683
+ const [fieldSel, setFieldSel] = React.useState(null);
684
+ const [dirty, setDirty] = React.useState(false);
685
+ const [hasDraft, setHasDraft] = React.useState(false);
686
+ const [saving, setSaving] = React.useState(false);
687
+ const [gridVer, setGridVer] = React.useState(0);
688
+ // Records grid ⇄ Form ⇄ Validations ⇄ Settings — four views of the SAME
689
+ // object. Grid/Form are the runtime renderer (same-renderer principle);
690
+ // Validations edits `validations` rules; Settings edits object basics +
691
+ // the ADR-0085 semantic roles. All patch the one `objDraft`.
692
+ const [viewMode, setViewMode] = React.useState('grid');
693
+ // Within the Form view: 布局 (WYSIWYG drag/section designer) ⇄ 预览 (live form).
694
+ const [formMode, setFormMode] = React.useState('layout');
695
+ // Tracks which object's baseline is currently loaded — so we (re)load exactly
696
+ // once per selected object and never clobber an in-progress draft.
697
+ const loadedNameRef = React.useRef(null);
698
+ // Left-rail search + inline "new object" creator (design §4: rail = search + New).
699
+ const [query, setQuery] = React.useState('');
700
+ const [creating, setCreating] = React.useState(false);
701
+ const [newLabel, setNewLabel] = React.useState('');
702
+ const [newName, setNewName] = React.useState('');
703
+ const [nameTouched, setNameTouched] = React.useState(false);
704
+ const [createBusy, setCreateBusy] = React.useState(false);
705
+ // Whether the selected object exists beyond the draft (published/code baseline).
706
+ // A draft-only object has NO physical table yet (DDL lands at publish), so the
707
+ // Records grid must not fire data SQL against it.
708
+ const [hasBaseline, setHasBaseline] = React.useState(true);
709
+ React.useEffect(() => {
710
+ let cancelled = false;
711
+ (async () => {
712
+ try {
713
+ // Published objects + pending DRAFT objects, merged. `list()` only
714
+ // sees published/active metadata, so a freshly-created writable base
715
+ // whose objects are all drafts would render an empty (previously:
716
+ // forever-"loading") rail. Draft headers carry no label — show the
717
+ // machine name until the draft body loads on selection.
718
+ const [list, draftHeaders] = await Promise.all([
719
+ client.list('object', { packageId }),
720
+ client.listDrafts({ packageId, type: 'object' }).catch(() => []),
721
+ ]);
722
+ if (cancelled)
723
+ return;
724
+ const items = (list || [])
725
+ .map((o) => ({ type: 'object', name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
726
+ .filter((o) => o.name);
727
+ const known = new Set(items.map((o) => o.name));
728
+ for (const d of draftHeaders) {
729
+ if (d.name && !known.has(d.name)) {
730
+ items.push({ type: 'object', name: d.name, label: d.name });
731
+ }
732
+ }
733
+ setObjects(items);
734
+ setCurrent((c) => c ?? items[0] ?? null);
735
+ // First-run: an empty writable package opens the creator right away —
736
+ // the first thing to do here is make an object, so put the inputs up.
737
+ if (items.length === 0)
738
+ setCreating(true);
739
+ }
740
+ catch (e) {
741
+ if (!cancelled)
742
+ setError(formatMetadataError(e));
743
+ }
744
+ finally {
745
+ if (!cancelled)
746
+ setObjectsLoaded(true);
747
+ }
748
+ })();
749
+ return () => {
750
+ cancelled = true;
751
+ };
752
+ }, [client, packageId]);
753
+ React.useEffect(() => {
754
+ if (!current)
755
+ return;
756
+ // Load once per selected object. Bail if this object's baseline is already
757
+ // loaded — a client-identity churn or a child remount must NOT re-fetch and
758
+ // clobber the in-progress form-layout draft the designer is editing.
759
+ // Keyed by object + publishNonce: a package publish (nonce++) re-reads the
760
+ // fresh published baseline; otherwise we never clobber an in-progress draft.
761
+ const loadKey = `${current.name}#${publishNonce}`;
762
+ if (loadedNameRef.current === loadKey)
763
+ return;
764
+ loadedNameRef.current = loadKey;
765
+ let cancelled = false;
766
+ setLoading(true);
767
+ setError(null);
768
+ setFieldSel(null);
769
+ setDirty(false);
770
+ (async () => {
771
+ try {
772
+ const [layRaw, draftResp] = await Promise.all([
773
+ client.layered('object', current.name),
774
+ client.getDraft('object', current.name).catch(() => null),
775
+ ]);
776
+ if (cancelled)
777
+ return;
778
+ const lay = layRaw;
779
+ const baseline = (lay.effective ?? lay.code ?? {});
780
+ const draftBody = extractDraftBody(draftResp);
781
+ setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
782
+ setHasDraft(!!draftBody);
783
+ setHasBaseline(!!(lay.effective ?? lay.code));
784
+ }
785
+ catch (e) {
786
+ if (!cancelled)
787
+ setError(formatMetadataError(e));
788
+ }
789
+ finally {
790
+ if (!cancelled)
791
+ setLoading(false);
792
+ }
793
+ })();
794
+ return () => {
795
+ cancelled = true;
796
+ };
797
+ }, [client, current, publishNonce]);
798
+ const fieldCount = React.useMemo(() => readFields(objDraft.fields).entries.length, [objDraft]);
799
+ const onPatch = React.useCallback((patch) => {
800
+ setObjDraft((d) => ({ ...d, ...patch }));
801
+ setDirty(true);
802
+ }, []);
803
+ // "+ add field": append a fresh text field and select it for editing in the panel.
804
+ const addField = React.useCallback(() => {
805
+ const view = readFields(objDraft.fields);
806
+ const name = nextFieldName(view.entries.map((e) => e.name));
807
+ view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale)));
808
+ setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
809
+ setDirty(true);
810
+ setFieldSel({ kind: 'field', id: name });
811
+ }, [objDraft]);
812
+ // "+ new object": create a fresh object as a DRAFT in this package (runtime
813
+ // create — same path the classic Studio editor uses), seeded with one text
814
+ // field so the form/grid isn't empty. It stays draft-only (no physical table)
815
+ // until the package publish, so we land on 表单·布局 — the metadata-level
816
+ // surface that never fires data SQL.
817
+ const doCreateObject = React.useCallback(async () => {
818
+ const label = newLabel.trim();
819
+ const name = toFieldName(newName.trim() || label);
820
+ if (!label || !name || name === 'field')
821
+ return; // CJK label → identifier must be typed
822
+ if (objects.some((o) => o.name === name)) {
823
+ setError(tFormat('engine.studio.data.idExists', locale, { name }));
824
+ return;
825
+ }
826
+ setCreateBusy(true);
827
+ setError(null);
828
+ try {
829
+ const body = {
830
+ name,
831
+ label,
832
+ fields: { name: { type: 'text', label: t('engine.studio.data.nameFieldLabel', locale) } },
833
+ };
834
+ await client.save('object', name, body, { mode: 'draft', packageId });
835
+ const surface = { type: 'object', name, label };
836
+ setObjects((prev) => [...prev, surface]);
837
+ setCurrent(surface);
838
+ setViewMode('form');
839
+ setFormMode('layout');
840
+ setCreating(false);
841
+ setNewLabel('');
842
+ setNewName('');
843
+ setNameTouched(false);
844
+ onDraftSaved?.();
845
+ }
846
+ catch (e) {
847
+ setError(formatMetadataError(e));
848
+ }
849
+ finally {
850
+ setCreateBusy(false);
851
+ }
852
+ }, [newLabel, newName, objects, client, packageId, onDraftSaved]);
853
+ const doSave = React.useCallback(async () => {
854
+ if (!current)
855
+ return;
856
+ setSaving('draft');
857
+ setError(null);
858
+ try {
859
+ await client.save('object', current.name, objDraft, { mode: 'draft', packageId });
860
+ setHasDraft(true);
861
+ setDirty(false);
862
+ onDraftSaved?.();
863
+ }
864
+ catch (e) {
865
+ setError(formatMetadataError(e));
866
+ }
867
+ finally {
868
+ setSaving(false);
869
+ }
870
+ }, [client, current, objDraft, onDraftSaved]);
871
+ // Drag-reorder columns → reorder the object's `fields` metadata (field display
872
+ // order follows metadata order), saved as a DRAFT. Published later via the
873
+ // package release — NOT auto-published per reorder as it used to be.
874
+ const doReorderFields = React.useCallback(async (orderedNames) => {
875
+ if (!current)
876
+ return;
877
+ const view = readFields(objDraft.fields);
878
+ // Reorder only the visible fields among their own slots; keep system /
879
+ // hidden fields (not shown as columns) in their original positions.
880
+ const visible = new Set(orderedNames);
881
+ const visibleInOrder = orderedNames
882
+ .map((n) => view.entries.find((e) => e.name === n))
883
+ .filter((e) => Boolean(e));
884
+ let vi = 0;
885
+ const entries = view.entries.map((e) => (visible.has(e.name) ? visibleInOrder[vi++] : e));
886
+ const body = { ...objDraft, fields: writeFields({ ...view, entries }) };
887
+ setObjDraft(body);
888
+ setSaving('draft');
889
+ setError(null);
890
+ try {
891
+ await client.save('object', current.name, body, { mode: 'draft', packageId });
892
+ setHasDraft(true);
893
+ setDirty(false);
894
+ onDraftSaved?.();
895
+ setGridVer((v) => v + 1); // remount so the grid reflects the new (draft) order
896
+ }
897
+ catch (e) {
898
+ setError(formatMetadataError(e));
899
+ }
900
+ finally {
901
+ setSaving(false);
902
+ }
903
+ }, [client, current, objDraft, onDraftSaved]);
904
+ const inspector = getMetadataInspector('object');
905
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: ["object \u00B7 ", current.name] }), _jsx("span", { children: tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.data.pickObject', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "shrink-0 p-2 pb-1", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.data.objects', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.data.searchObjects', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none placeholder:text-muted-foreground/70 focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [objects.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : objectsLoaded ? t('engine.studio.data.noObjects', locale) : t('engine.studio.loading', locale) })), objects
906
+ .filter((o) => !query.trim() ||
907
+ o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
908
+ o.name.toLowerCase().includes(query.trim().toLowerCase()))
909
+ .map((o) => (_jsxs("button", { onClick: () => setCurrent(o), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
910
+ (current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Database, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: o.label })] }, o.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
911
+ setNewLabel(e.target.value);
912
+ if (!nameTouched)
913
+ setNewName(toFieldNameLoose(e.target.value));
914
+ }, onKeyDown: (e) => {
915
+ if (e.key === 'Enter')
916
+ void doCreateObject();
917
+ if (e.key === 'Escape')
918
+ setCreating(false);
919
+ }, placeholder: t('engine.studio.data.labelPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newName, onChange: (e) => {
920
+ setNameTouched(true);
921
+ setNewName(toFieldNameLoose(e.target.value));
922
+ }, onKeyDown: (e) => {
923
+ if (e.key === 'Enter')
924
+ void doCreateObject();
925
+ if (e.key === 'Escape')
926
+ setCreating(false);
927
+ }, placeholder: t('engine.studio.data.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreateObject(), disabled: createBusy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [createBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "inline-flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.newObject', locale)] })) })] }), _jsx("main", { className: "flex min-w-0 flex-1 flex-col overflow-hidden p-4", children: !current ? (objectsLoaded && objects.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center gap-2 py-16 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.firstObjectTitle', locale) }), _jsx("p", { className: "max-w-sm text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.firstObjectHint', locale) })] })) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.data.pickObject', locale) }))) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex shrink-0 items-center gap-2", children: [_jsxs("div", { className: "inline-flex rounded-md border p-0.5 text-[11px]", children: [_jsx("button", { type: "button", onClick: () => setViewMode('grid'), className: 'rounded px-2.5 py-0.5 ' +
928
+ (viewMode === 'grid' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.records', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('form'), className: 'rounded px-2.5 py-0.5 ' +
929
+ (viewMode === 'form' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.form', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('rules'), className: 'rounded px-2.5 py-0.5 ' +
930
+ (viewMode === 'rules' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.rules', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('settings'), className: 'rounded px-2.5 py-0.5 ' +
931
+ (viewMode === 'settings' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.settings', locale) })] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), ' ', viewMode === 'grid'
932
+ ? t('engine.studio.data.badge.grid', locale)
933
+ : viewMode === 'rules'
934
+ ? t('engine.studio.data.badge.rules', locale)
935
+ : viewMode === 'settings'
936
+ ? t('engine.studio.data.badge.settings', locale)
937
+ : formMode === 'layout'
938
+ ? t('engine.studio.data.badge.formLayout', locale)
939
+ : t('engine.studio.data.badge.formPreview', locale)] }), (viewMode === 'grid' || viewMode === 'form') && (_jsxs("button", { type: "button", onClick: addField, title: t('engine.studio.data.addFieldTitle', locale), className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.addField', locale)] }))] }), error && (_jsx("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive whitespace-pre-line", children: error })), viewMode === 'rules' ? (_jsx(ObjectValidationsPanel, { draft: objDraft, onPatch: onPatch })) : viewMode === 'settings' ? (_jsx(ObjectSettingsPanel, { name: current.name, draft: objDraft, onPatch: onPatch, locale: locale })) : viewMode === 'grid' && !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.draftObjectTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.draftObjectHint', locale) }), _jsx("button", { type: "button", onClick: () => {
940
+ setViewMode('form');
941
+ setFormMode('layout');
942
+ }, className: "mt-1 inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted", children: t('engine.studio.data.goDesignFields', locale) })] })) : viewMode === 'grid' ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background", children: _jsx(GridFieldAuthoringProvider, { value: {
943
+ onAddColumn: addField,
944
+ addColumnLabel: t('engine.studio.data.addField', locale),
945
+ onEditColumn: (fieldName) => {
946
+ // ignore non-field columns (e.g. the row-actions column)
947
+ if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
948
+ setFieldSel({ kind: 'field', id: fieldName });
949
+ }
950
+ },
951
+ editColumnLabel: t('engine.studio.data.editFieldProps', locale),
952
+ onReorderFields: doReorderFields,
953
+ }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(PluginObjectView, { schema: {
954
+ type: 'object-view',
955
+ objectName: current.name,
956
+ // No saved view exists in design mode, so show the object's
957
+ // own fields as columns (in metadata order), dropping
958
+ // framework-managed/audit fields so the grid opens on the
959
+ // meaningful columns first — the way Airtable does.
960
+ table: {
961
+ fields: readFields(objDraft.fields)
962
+ .entries.map((e) => e.name)
963
+ .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
964
+ },
965
+ }, dataSource: adapter, renderListView: renderStudioGridList }, `${current.name}:${gridVer}`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.data.gridHint', locale)] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("div", { className: "inline-flex rounded-md border p-0.5 text-[11px]", children: [_jsx("button", { type: "button", onClick: () => setFormMode('layout'), className: 'rounded px-2.5 py-0.5 ' +
966
+ (formMode === 'layout' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.layout', locale) }), _jsx("button", { type: "button", onClick: () => setFormMode('preview'), className: 'rounded px-2.5 py-0.5 ' +
967
+ (formMode === 'preview' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.preview', locale) })] }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: formMode === 'layout' ? t('engine.studio.data.form.layoutBadge', locale) : t('engine.studio.data.form.previewBadge', locale) }), formMode === 'preview' && (dirty || hasDraft) && (_jsx("span", { className: "inline-flex items-center gap-1 rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.data.form.previewWarn', locale) }))] }), formMode === 'layout' ? (_jsx(ObjectFormDesigner, { draft: objDraft, systemFieldNames: STUDIO_SYSTEM_FIELD_NAMES, onChange: onPatch, selectedField: fieldSel?.kind === 'field' ? fieldSel.id : null, onSelectField: (name) => setFieldSel({ kind: 'field', id: name }), onAddField: addField })) : !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.form.noPublishedTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.form.noPublishedHint', locale) })] })) : (_jsxs(_Fragment, { children: [_jsx("style", { children: `
968
+ /* Design preview: the form is a click-to-select canvas, not a data-entry
969
+ * form. Disable interaction on field contents so a click anywhere on a
970
+ * field routes to its [data-field] wrapper (→ select), and neutralize the
971
+ * create/cancel footer so nothing is submittable here. */
972
+ .os-form-authoring [data-field]{border-radius:8px;cursor:pointer;transition:box-shadow .12s;padding:8px;margin:-8px 0;}
973
+ .os-form-authoring [data-field] *{pointer-events:none;}
974
+ .os-form-authoring [data-field]:hover{box-shadow:0 0 0 1px hsl(var(--border));}
975
+ .os-form-authoring form > div:last-child:has(button){display:none;}
976
+ ${fieldSel?.kind === 'field'
977
+ ? `.os-form-authoring [data-field="${String(fieldSel.id).replace(/[^\w-]/g, '')}"]{box-shadow:0 0 0 2px hsl(var(--primary));}`
978
+ : ''}
979
+ ` }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background p-6", children: _jsx("div", { className: "os-form-authoring mx-auto max-w-2xl", onClick: (e) => {
980
+ const el = e.target.closest('[data-field]');
981
+ const name = el?.getAttribute('data-field');
982
+ if (name && readFields(objDraft.fields).entries.some((f) => f.name === name)) {
983
+ setFieldSel({ kind: 'field', id: name });
984
+ }
985
+ }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(ObjectForm, { schema: {
986
+ type: 'object-form',
987
+ objectName: current.name,
988
+ mode: 'create',
989
+ fields: readFields(objDraft.fields)
990
+ .entries.map((e) => e.name)
991
+ .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
992
+ }, dataSource: adapter }, `${current.name}:${gridVer}:form`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.data.formHint', locale)] })] }))] }))] })) }), current && fieldSel && inspector && (_jsxs("aside", { className: "flex w-80 shrink-0 flex-col border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.data.fieldProps', locale) }), _jsx("button", { type: "button", onClick: () => setFieldSel(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.close', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-3", children: React.createElement(inspector, {
993
+ type: 'object',
994
+ name: current.name,
995
+ draft: objDraft,
996
+ selection: fieldSel,
997
+ onPatch,
998
+ onClearSelection: () => setFieldSel(null),
999
+ onSelectionChange: setFieldSel,
1000
+ readOnly: false,
1001
+ locale,
1002
+ }) })] }))] })] }));
1003
+ }
1004
+ /** Automations pillar — flows: list → FlowPreview (default OFF / review-then-enable). */
1005
+ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1006
+ const client = useMetadataClient();
1007
+ const locale = useMetadataLocale();
1008
+ const [flows, setFlows] = React.useState([]);
1009
+ const [current, setCurrent] = React.useState(null);
1010
+ const [draft, setDraft] = React.useState({});
1011
+ const [selection, setSelection] = React.useState(null);
1012
+ const [loading, setLoading] = React.useState(false);
1013
+ const [saving, setSaving] = React.useState(false);
1014
+ const [hasDraft, setHasDraft] = React.useState(false);
1015
+ const [error, setError] = React.useState(null);
1016
+ // Tells "still fetching the list" apart from "fetched, package has no flows"
1017
+ // — without it the empty rail showed an endless "加载中…" for a fresh package.
1018
+ const [listed, setListed] = React.useState(false);
1019
+ // Inline create — a fresh package starts with zero flows, so the pillar must
1020
+ // offer a way to author the first one (mirrors the object/app creators).
1021
+ const [creating, setCreating] = React.useState(false);
1022
+ const [newLabel, setNewLabel] = React.useState('');
1023
+ const [newName, setNewName] = React.useState('');
1024
+ const [createBusy, setCreateBusy] = React.useState(false);
1025
+ const Preview = getMetadataPreview(current?.type ?? '');
1026
+ const inspector = getMetadataInspector('flow');
1027
+ const isEditable = !!Preview;
1028
+ React.useEffect(() => {
1029
+ let cancelled = false;
1030
+ (async () => {
1031
+ try {
1032
+ const list = (await client.list('flow', { packageId }));
1033
+ if (cancelled)
1034
+ return;
1035
+ const items = (list || [])
1036
+ .map((f) => ({ type: 'flow', name: String(f.name ?? ''), label: String(f.label ?? f.name ?? '') }))
1037
+ .filter((f) => f.name);
1038
+ setFlows(items);
1039
+ setCurrent((c) => c ?? items[0] ?? null);
1040
+ }
1041
+ catch (e) {
1042
+ if (!cancelled)
1043
+ setError(formatMetadataError(e));
1044
+ }
1045
+ finally {
1046
+ if (!cancelled)
1047
+ setListed(true);
1048
+ }
1049
+ })();
1050
+ return () => {
1051
+ cancelled = true;
1052
+ };
1053
+ }, [client, packageId]);
1054
+ const doCreateFlow = React.useCallback(async () => {
1055
+ const label = newLabel.trim();
1056
+ const name = toFieldName(newName.trim() || label);
1057
+ if (!label || !name || name === 'field')
1058
+ return;
1059
+ setCreateBusy(true);
1060
+ setError(null);
1061
+ try {
1062
+ // Minimal valid, autolaunched skeleton: start → end. The designer fills in
1063
+ // the trigger + nodes; publishing it is a separate, user-initiated step.
1064
+ const skeleton = {
1065
+ name,
1066
+ label,
1067
+ type: 'autolaunched',
1068
+ nodes: [
1069
+ { id: 'start', type: 'start', label: t('engine.studio.auto.nodeStart', locale) },
1070
+ { id: 'end', type: 'end', label: t('engine.studio.auto.nodeEnd', locale) },
1071
+ ],
1072
+ edges: [{ id: 'e1', source: 'start', target: 'end' }],
1073
+ };
1074
+ await client.save('flow', name, skeleton, { mode: 'draft', packageId });
1075
+ const item = { type: 'flow', name, label };
1076
+ setFlows((fs) => [...fs.filter((f) => f.name !== name), item]);
1077
+ setCurrent(item);
1078
+ setHasDraft(true);
1079
+ setCreating(false);
1080
+ setNewLabel('');
1081
+ setNewName('');
1082
+ onDraftSaved?.();
1083
+ toast.success(tFormat('engine.studio.auto.savedDraft', locale, { label }));
1084
+ }
1085
+ catch (e) {
1086
+ setError(formatMetadataError(e));
1087
+ }
1088
+ finally {
1089
+ setCreateBusy(false);
1090
+ }
1091
+ }, [newLabel, newName, client, packageId, onDraftSaved]);
1092
+ React.useEffect(() => {
1093
+ if (!current)
1094
+ return;
1095
+ let cancelled = false;
1096
+ setLoading(true);
1097
+ setError(null);
1098
+ setSelection(null);
1099
+ (async () => {
1100
+ try {
1101
+ const [layRaw, draftResp] = await Promise.all([
1102
+ client.layered('flow', current.name),
1103
+ client.getDraft('flow', current.name).catch(() => null),
1104
+ ]);
1105
+ if (cancelled)
1106
+ return;
1107
+ const lay = layRaw;
1108
+ const baseline = (lay.effective ?? lay.code ?? {});
1109
+ const draftBody = extractDraftBody(draftResp);
1110
+ setDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
1111
+ setHasDraft(!!draftBody);
1112
+ }
1113
+ catch (e) {
1114
+ if (!cancelled)
1115
+ setError(formatMetadataError(e));
1116
+ }
1117
+ finally {
1118
+ if (!cancelled)
1119
+ setLoading(false);
1120
+ }
1121
+ })();
1122
+ return () => {
1123
+ cancelled = true;
1124
+ };
1125
+ }, [client, current, publishNonce]);
1126
+ const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
1127
+ const doSave = React.useCallback(async () => {
1128
+ if (!current)
1129
+ return;
1130
+ setSaving('draft');
1131
+ setError(null);
1132
+ try {
1133
+ await client.save('flow', current.name, draft, { mode: 'draft', packageId });
1134
+ setHasDraft(true);
1135
+ onDraftSaved?.();
1136
+ }
1137
+ catch (e) {
1138
+ setError(formatMetadataError(e));
1139
+ }
1140
+ finally {
1141
+ setSaving(false);
1142
+ }
1143
+ }, [client, current, draft, onDraftSaved]);
1144
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.auto.defaultOff', locale) }), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col overflow-auto border-r p-2", children: [_jsxs("div", { className: "flex items-center gap-1 px-2 pb-1 pt-1", children: [_jsx("p", { className: "flex-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.auto.heading', locale) }), _jsxs("button", { type: "button", onClick: () => setCreating((v) => !v), title: t('engine.studio.auto.newTitle', locale), className: "inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.new', locale)] })] }), flows.length > 0 &&
1145
+ flows.map((f) => (_jsxs("button", { onClick: () => setCurrent(f), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1146
+ (current?.name === f.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Workflow, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: f.label })] }, f.name))), flows.length === 0 && !creating && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : !listed ? t('engine.studio.loading', locale) : t('engine.studio.auto.none', locale) })), creating && (_jsxs("div", { className: "mt-1 space-y-1.5 rounded-md border bg-muted/30 p-2", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => setNewLabel(e.target.value), onKeyDown: (e) => {
1147
+ if (e.key === 'Enter')
1148
+ void doCreateFlow();
1149
+ if (e.key === 'Escape')
1150
+ setCreating(false);
1151
+ }, placeholder: t('engine.studio.auto.namePlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 text-xs" }), _jsx("input", { value: newName, onChange: (e) => setNewName(e.target.value), onKeyDown: (e) => {
1152
+ if (e.key === 'Enter')
1153
+ void doCreateFlow();
1154
+ if (e.key === 'Escape')
1155
+ setCreating(false);
1156
+ }, placeholder: t('engine.studio.auto.idPlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 font-mono text-[11px]" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("button", { type: "button", onClick: () => void doCreateFlow(), disabled: !newLabel.trim() || createBusy, className: "inline-flex flex-1 items-center justify-center gap-1 rounded bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [createBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded border px-2 py-1 text-[11px] hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] }))] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Workflow, { className: "h-3 w-3" }), " ", t('engine.studio.auto.canvasHint', locale)] }), current && _jsxs("span", { className: "text-[11px] text-muted-foreground", children: ["flow \u00B7 ", current.name] })] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-line", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.auto.pick', locale) })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " ", t('engine.studio.loading', locale)] })) : Preview ? (React.createElement(Preview, {
1157
+ type: current.type,
1158
+ name: current.name,
1159
+ draft,
1160
+ editing: true,
1161
+ selection,
1162
+ onSelectionChange: setSelection,
1163
+ onPatch,
1164
+ locale,
1165
+ })) : (_jsx("pre", { className: "overflow-auto text-[11px] text-muted-foreground", children: JSON.stringify(draft, null, 2) })) }), isEditable && (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.auto.editHint', locale)] }))] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.auto.config', locale) }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.deselect', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: selection && inspector && current ? (React.createElement(inspector, {
1166
+ type: 'flow',
1167
+ name: current.name,
1168
+ draft,
1169
+ selection,
1170
+ onPatch,
1171
+ onClearSelection: () => setSelection(null),
1172
+ onSelectionChange: setSelection,
1173
+ readOnly: false,
1174
+ locale,
1175
+ })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), t('engine.studio.auto.emptyLine1', locale), _jsx("br", {}), t('engine.studio.auto.emptyLine2', locale)] })) })] })] })] }));
1176
+ }
1177
+ /**
1178
+ * Access pillar — the permission workbench (builder-ui §7, ADR-0084's fourth
1179
+ * content pillar). Left rail: the environment's permission sets / profiles;
1180
+ * main: the Salesforce-style PermissionMatrixEditPage (objects × CRUD/VAMA +
1181
+ * field-level R/W).
1182
+ *
1183
+ * Scope note (ADR-0086 P0/P1/P2): the pillar is scoped to the current package.
1184
+ * The left rail lists only permission sets this package owns — the metadata API
1185
+ * filters `permission` by the record-level `package_id` provenance server-side
1186
+ * (P1), so environment-owned platform defaults (`admin_full_access`,
1187
+ * `member_default`, …) are excluded by the backend. The object MATRIX lists only
1188
+ * the objects this package declares, and Save merges just that slice back,
1189
+ * leaving other packages' contributed rows untouched (P0). Save writes a package
1190
+ * DRAFT and publishes with the whole package via the top-bar Publish (P2, D6).
1191
+ */
1192
+ function AccessPillar({ packageId, publishNonce, onDraftSaved, }) {
1193
+ const client = useMetadataClient();
1194
+ const locale = useMetadataLocale();
1195
+ const [perms, setPerms] = React.useState([]);
1196
+ const [loaded, setLoaded] = React.useState(false);
1197
+ const [error, setError] = React.useState(null);
1198
+ const [current, setCurrent] = React.useState(null);
1199
+ const [query, setQuery] = React.useState('');
1200
+ // inline creator (same rail pattern as the Data pillar's object creator)
1201
+ const [creating, setCreating] = React.useState(false);
1202
+ const [newLabel, setNewLabel] = React.useState('');
1203
+ const [newName, setNewName] = React.useState('');
1204
+ const [nameTouched, setNameTouched] = React.useState(false);
1205
+ const [busy, setBusy] = React.useState(false);
1206
+ const load = React.useCallback(async () => {
1207
+ try {
1208
+ // Scope the rail to this package server-side (ADR-0086 P1): the metadata
1209
+ // API filters `permission` by the record-level `package_id` provenance, so
1210
+ // it returns only the sets this package owns — environment-owned platform
1211
+ // defaults (`admin_full_access`, `member_default`, …) are excluded by the
1212
+ // backend, not the client. (The `?package=` list rows don't echo the
1213
+ // provenance columns, so a client-side filter can't do this.)
1214
+ //
1215
+ // ADR-0086 P2 (D6): a package permission set is draft/published metadata,
1216
+ // so the rail shows published ∪ pending-draft sets — a set created (or
1217
+ // renamed) as a draft but not yet published must still appear, just like
1218
+ // the Data/Interfaces pillars merge their drafts. Draft headers are
1219
+ // already package-scoped by `listDrafts({ packageId })`.
1220
+ const [list, drafts] = await Promise.all([
1221
+ client.list('permission', { packageId }),
1222
+ client.listDrafts({ packageId, type: 'permission' }).catch(() => []),
1223
+ ]);
1224
+ const byName = new Map();
1225
+ for (const p of list || []) {
1226
+ const name = String(p.name ?? p.id ?? '');
1227
+ if (!name)
1228
+ continue;
1229
+ byName.set(name, {
1230
+ name,
1231
+ label: String(p.label ?? p.name ?? ''),
1232
+ isProfile: !!p.isProfile,
1233
+ });
1234
+ }
1235
+ for (const d of drafts || []) {
1236
+ const name = String(d?.name ?? '');
1237
+ if (!name || byName.has(name))
1238
+ continue;
1239
+ byName.set(name, { name, label: name });
1240
+ }
1241
+ const scoped = [...byName.values()];
1242
+ setPerms(scoped);
1243
+ setCurrent((c) => c ?? scoped[0]?.name ?? null);
1244
+ }
1245
+ catch (e) {
1246
+ setError(formatMetadataError(e));
1247
+ }
1248
+ finally {
1249
+ setLoaded(true);
1250
+ }
1251
+ }, [client, packageId]);
1252
+ React.useEffect(() => {
1253
+ void load();
1254
+ // Re-read after a package publish so drafts that went live collapse into
1255
+ // the published rail (ADR-0086 P2).
1256
+ }, [load, publishNonce]);
1257
+ const doCreate = React.useCallback(async () => {
1258
+ const label = newLabel.trim();
1259
+ const name = toFieldName(newName.trim() || label);
1260
+ if (!label || !name || name === 'field')
1261
+ return;
1262
+ setBusy(true);
1263
+ try {
1264
+ // Package door → create as a DRAFT stamped with this package (D6/D7),
1265
+ // published atomically with the rest of the package.
1266
+ await client.save('permission', name, { name, label, objects: {}, fields: {} }, { mode: 'draft', packageId });
1267
+ toast.success(tFormat('engine.studio.access.created', locale, { label }));
1268
+ setCreating(false);
1269
+ setNewLabel('');
1270
+ setNewName('');
1271
+ setNameTouched(false);
1272
+ onDraftSaved?.();
1273
+ await load();
1274
+ setCurrent(name);
1275
+ }
1276
+ catch (e) {
1277
+ toast.error(formatMetadataError(e));
1278
+ }
1279
+ finally {
1280
+ setBusy(false);
1281
+ }
1282
+ }, [client, newLabel, newName, load, packageId, onDraftSaved, locale]);
1283
+ const filtered = perms.filter((p) => !query.trim() ||
1284
+ p.label.toLowerCase().includes(query.trim().toLowerCase()) ||
1285
+ p.name.toLowerCase().includes(query.trim().toLowerCase()));
1286
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx(Shield, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium text-foreground", children: t('engine.studio.access.title', locale) }), _jsx("span", { className: "rounded bg-muted px-1.5 py-0.5", children: t('engine.studio.access.subtitle', locale) })] }), _jsx("span", { title: t('engine.studio.access.bannerTitle', locale), className: "ml-auto rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.access.banner', locale) })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "p-2 pb-0", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.access.heading', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.access.search', locale), className: "mb-1 h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [perms.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : loaded ? t('engine.studio.access.none', locale) : t('engine.studio.loading', locale) })), filtered.map((p) => (_jsxs("button", { onClick: () => setCurrent(p.name), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1287
+ (current === p.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Shield, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "flex-1 truncate", children: p.label }), p.isProfile && (_jsx("span", { className: "rounded bg-muted px-1 py-px text-[9px] uppercase text-muted-foreground", children: "profile" }))] }, p.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
1288
+ setNewLabel(e.target.value);
1289
+ if (!nameTouched)
1290
+ setNewName(toFieldNameLoose(e.target.value));
1291
+ }, onKeyDown: (e) => {
1292
+ if (e.key === 'Enter')
1293
+ void doCreate();
1294
+ if (e.key === 'Escape')
1295
+ setCreating(false);
1296
+ }, placeholder: t('engine.studio.access.labelPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newName, onChange: (e) => {
1297
+ setNameTouched(true);
1298
+ setNewName(toFieldNameLoose(e.target.value));
1299
+ }, onKeyDown: (e) => {
1300
+ if (e.key === 'Enter')
1301
+ void doCreate();
1302
+ if (e.key === 'Escape')
1303
+ setCreating(false);
1304
+ }, placeholder: t('engine.studio.access.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.create', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.access.new', locale)] })) })] }), _jsx("main", { className: "min-w-0 flex-1 overflow-auto", children: current ? (_jsx(PermissionMatrixEditPage, { type: "permission", name: current, packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved }, current)) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: loaded && perms.length === 0 ? t('engine.studio.access.emptyMain', locale) : t('engine.studio.access.pick', locale) })) })] })] }));
1305
+ }
1306
+ export default StudioDesignSurface;