@object-ui/app-shell 11.2.0 → 11.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +562 -0
- package/README.md +23 -0
- package/dist/console/ConsoleShell.js +17 -2
- package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
- package/dist/console/home/CloudOnboardingNext.js +14 -4
- package/dist/console/home/HomePage.js +34 -7
- package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
- package/dist/console/organizations/OrganizationsPage.js +16 -7
- package/dist/hooks/useConsoleActionRuntime.js +32 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/preview/DraftChangesPanel.d.ts +3 -1
- package/dist/preview/DraftChangesPanel.js +6 -5
- package/dist/utils/deriveRelatedLists.d.ts +20 -5
- package/dist/utils/deriveRelatedLists.js +31 -13
- package/dist/utils/index.d.ts +2 -24
- package/dist/utils/index.js +14 -101
- package/dist/utils/resolveViewId.d.ts +23 -0
- package/dist/utils/resolveViewId.js +37 -0
- package/dist/utils/warnSuppressedListNav.d.ts +10 -0
- package/dist/utils/warnSuppressedListNav.js +40 -0
- package/dist/views/DashboardView.js +2 -3
- package/dist/views/InterfaceListPage.js +10 -5
- package/dist/views/ObjectView.js +65 -12
- package/dist/views/PageView.js +2 -3
- package/dist/views/RecordDetailView.js +131 -104
- package/dist/views/RecordFormPage.js +7 -1
- package/dist/views/RelatedRecordActionsBridge.d.ts +24 -0
- package/dist/views/RelatedRecordActionsBridge.js +114 -0
- package/dist/views/ReportView.js +2 -3
- package/dist/views/metadata-admin/PackagesPage.js +18 -7
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
- package/dist/views/metadata-admin/clientValidation.js +8 -2
- package/dist/views/metadata-admin/color-variant-field.d.ts +1 -12
- package/dist/views/metadata-admin/color-variant-field.js +11 -0
- package/dist/views/metadata-admin/i18n.d.ts +12 -21
- package/dist/views/metadata-admin/i18n.js +343 -2
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +25 -11
- package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
- package/dist/views/metadata-admin/permission-slice.js +70 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +11 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
- package/dist/views/metadata-admin/previews/OutlineStrip.d.ts +1 -13
- package/dist/views/metadata-admin/previews/OutlineStrip.js +12 -0
- package/dist/views/metadata-admin/previews/PagePreview.js +9 -0
- package/dist/views/metadata-admin/previews/SourcePageEditor.d.ts +28 -0
- package/dist/views/metadata-admin/previews/SourcePageEditor.js +83 -0
- package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
- package/dist/views/studio-design/BuilderLanding.js +133 -0
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +31 -0
- package/dist/views/studio-design/ObjectFormDesigner.js +226 -0
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectSettingsPanel.js +45 -0
- package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectValidationsPanel.js +78 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +20 -0
- package/dist/views/studio-design/StudioDesignSurface.js +1306 -0
- package/dist/views/studio-design/metadataError.d.ts +23 -0
- package/dist/views/studio-design/metadataError.js +44 -0
- package/dist/views/studio-design/packages-io.d.ts +27 -0
- package/dist/views/studio-design/packages-io.js +61 -0
- package/package.json +46 -43
|
@@ -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;
|