@object-ui/app-shell 11.3.0 → 11.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +700 -0
- package/README.md +32 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +25 -3
- package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
- package/dist/console/home/CloudOnboardingNext.js +14 -4
- package/dist/console/home/HomePage.js +34 -7
- package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
- package/dist/console/organizations/OrganizationsPage.js +16 -7
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useConsoleActionRuntime.js +32 -3
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/layout/AppHeader.js +13 -5
- package/dist/layout/AppSidebar.js +10 -4
- package/dist/observability/sentry.d.ts +5 -0
- package/dist/observability/sentry.js +6 -1
- package/dist/preview/DraftChangesPanel.d.ts +31 -1
- package/dist/preview/DraftChangesPanel.js +146 -18
- package/dist/urlParams.d.ts +68 -0
- package/dist/urlParams.js +76 -0
- package/dist/utils/appRoute.d.ts +15 -0
- package/dist/utils/appRoute.js +22 -0
- package/dist/utils/deriveRelatedLists.d.ts +20 -5
- package/dist/utils/deriveRelatedLists.js +31 -13
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/pageTabsUrlSync.d.ts +32 -0
- package/dist/utils/pageTabsUrlSync.js +43 -0
- package/dist/utils/recordFormNavigation.d.ts +40 -0
- package/dist/utils/recordFormNavigation.js +30 -0
- package/dist/utils/resolveViewId.d.ts +23 -0
- package/dist/utils/resolveViewId.js +37 -0
- package/dist/utils/warnSuppressedListNav.d.ts +10 -0
- package/dist/utils/warnSuppressedListNav.js +40 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +7 -5
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +65 -13
- package/dist/views/RecordDetailView.js +191 -123
- package/dist/views/RecordFormPage.js +7 -1
- package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
- package/dist/views/RelatedRecordActionsBridge.js +147 -0
- package/dist/views/metadata-admin/PackagesPage.js +18 -7
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.d.ts +12 -21
- package/dist/views/metadata-admin/i18n.js +557 -6
- package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
- package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
- package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
- package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
- package/dist/views/metadata-admin/nav-selection.js +81 -0
- package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
- package/dist/views/metadata-admin/permission-slice.js +70 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
- package/dist/views/studio-design/BuilderLanding.js +126 -0
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
- package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
- package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
- package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
- package/dist/views/studio-design/PackageIdInput.js +40 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
- package/dist/views/studio-design/StudioDesignSurface.js +962 -145
- package/dist/views/studio-design/metadataError.d.ts +23 -0
- package/dist/views/studio-design/metadataError.js +44 -0
- package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
- package/dist/views/studio-design/packageSurfaces.js +34 -0
- package/dist/views/studio-design/packages-io.d.ts +38 -0
- package/dist/views/studio-design/packages-io.js +73 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- package/package.json +42 -39
|
@@ -14,21 +14,36 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
14
14
|
* surface — it is an injected slot (`aiSlot`) the cloud edition fills.
|
|
15
15
|
*/
|
|
16
16
|
import * as React from 'react';
|
|
17
|
-
import { useParams, Link } from 'react-router-dom';
|
|
17
|
+
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
|
18
18
|
import { SchemaRenderer, useAdapter, SchemaRendererProvider } from '@object-ui/react';
|
|
19
19
|
import { GridFieldAuthoringProvider } from '@object-ui/components';
|
|
20
20
|
import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
|
|
21
21
|
import { ListView } from '@object-ui/plugin-list';
|
|
22
|
-
import {
|
|
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';
|
|
23
24
|
import { getMetadataPreview } from '../metadata-admin/preview-registry';
|
|
25
|
+
import { PermissionMatrixEditPage } from '../metadata-admin/PermissionMatrixEditor';
|
|
24
26
|
import { getMetadataInspector } from '../metadata-admin/inspector-registry';
|
|
25
27
|
import { useMetadataClient } from '../metadata-admin/useMetadata';
|
|
28
|
+
import { DESIGNER_SEL_PARAM, parseNavSelParam, formatNavSelParam, findNavPositionById, navIdAtPosition, } from '../metadata-admin/nav-selection';
|
|
29
|
+
import { formatMetadataError, formatPublishFailures } from './metadataError';
|
|
30
|
+
import { loadPackageSurfaces } from './packageSurfaces';
|
|
31
|
+
import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons';
|
|
32
|
+
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
|
|
26
33
|
import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
|
|
27
|
-
import { readFields, writeFields, newField } from '../metadata-admin/previews/object-fields-io';
|
|
34
|
+
import { readFields, writeFields, newField, toFieldName, toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io';
|
|
35
|
+
import { ObjectFormDesigner } from './ObjectFormDesigner';
|
|
36
|
+
import { ObjectValidationsPanel } from './ObjectValidationsPanel';
|
|
37
|
+
import { ObjectSettingsPanel } from './ObjectSettingsPanel';
|
|
38
|
+
import { fetchPackages, createBasePackage, PACKAGE_ID_RE } from './packages-io';
|
|
39
|
+
import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput';
|
|
40
|
+
import { DraftChangesPanel } from '../../preview/DraftChangesPanel';
|
|
41
|
+
import { toast } from 'sonner';
|
|
28
42
|
const PILLARS = [
|
|
29
43
|
{ key: 'data', label: 'Data', Icon: Database },
|
|
30
44
|
{ key: 'automations', label: 'Automations', Icon: Workflow },
|
|
31
45
|
{ key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard },
|
|
46
|
+
{ key: 'access', label: 'Access', Icon: Shield },
|
|
32
47
|
];
|
|
33
48
|
const KIND_ICON = {
|
|
34
49
|
group: Folder,
|
|
@@ -75,14 +90,283 @@ function extractDraftBody(resp) {
|
|
|
75
90
|
return null;
|
|
76
91
|
return Object.keys(body).length > 0 ? body : null;
|
|
77
92
|
}
|
|
93
|
+
/** Top-bar package switcher: list app packages (可写 base vs 只读 code), switch by
|
|
94
|
+
* navigation, and create a new writable base inline (POST /packages {id,name}). */
|
|
95
|
+
function PackageSwitcher({ packageId, tab }) {
|
|
96
|
+
const navigate = useNavigate();
|
|
97
|
+
const locale = useMetadataLocale();
|
|
98
|
+
const [open, setOpen] = React.useState(false);
|
|
99
|
+
const [pkgs, setPkgs] = React.useState(null);
|
|
100
|
+
const [creating, setCreating] = React.useState(false);
|
|
101
|
+
const [newName, setNewName] = React.useState('');
|
|
102
|
+
const [newId, setNewId] = React.useState('');
|
|
103
|
+
const [idTouched, setIdTouched] = React.useState(false);
|
|
104
|
+
const [busy, setBusy] = React.useState(false);
|
|
105
|
+
const [err, setErr] = React.useState(null);
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
let cancelled = false;
|
|
108
|
+
fetchPackages()
|
|
109
|
+
.then((parsed) => {
|
|
110
|
+
if (!cancelled)
|
|
111
|
+
setPkgs(parsed);
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
/* leave null — switcher still works for navigation-free display */
|
|
115
|
+
});
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
};
|
|
119
|
+
}, [packageId]);
|
|
120
|
+
const current = pkgs?.find((p) => p.id === packageId) ?? null;
|
|
121
|
+
const doCreate = React.useCallback(async () => {
|
|
122
|
+
const name = newName.trim();
|
|
123
|
+
const id = newId.trim();
|
|
124
|
+
if (!name || !PACKAGE_ID_RE.test(id))
|
|
125
|
+
return;
|
|
126
|
+
setBusy(true);
|
|
127
|
+
setErr(null);
|
|
128
|
+
try {
|
|
129
|
+
await createBasePackage(id, name);
|
|
130
|
+
toast.success(tFormat('engine.studio.pkg.created', locale, { name }));
|
|
131
|
+
setOpen(false);
|
|
132
|
+
setCreating(false);
|
|
133
|
+
setNewName('');
|
|
134
|
+
setNewId('');
|
|
135
|
+
setIdTouched(false);
|
|
136
|
+
navigate(`/studio/${encodeURIComponent(id)}/data`);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
setErr(formatMetadataError(e));
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
setBusy(false);
|
|
143
|
+
}
|
|
144
|
+
}, [newName, newId, navigate]);
|
|
145
|
+
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: () => {
|
|
146
|
+
setOpen(false);
|
|
147
|
+
navigate(`/studio/${encodeURIComponent(p.id)}/${tab}`);
|
|
148
|
+
}, className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
|
|
149
|
+
(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) => {
|
|
150
|
+
setNewName(e.target.value);
|
|
151
|
+
if (!idTouched) {
|
|
152
|
+
const slug = toFieldNameLoose(e.target.value).replace(/_/g, '-');
|
|
153
|
+
setNewId(slug ? `com.example.${slug}` : '');
|
|
154
|
+
}
|
|
155
|
+
}, onKeyDown: (e) => {
|
|
156
|
+
if (e.key === 'Enter')
|
|
157
|
+
void doCreate();
|
|
158
|
+
if (e.key === 'Escape')
|
|
159
|
+
setCreating(false);
|
|
160
|
+
}, 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(PackageIdSuggestionHint, { show: !idTouched && !!newName.trim() && !newId, locale: locale }), _jsx(PackageIdInput, { value: newId, onChange: (v) => {
|
|
161
|
+
setIdTouched(true);
|
|
162
|
+
setNewId(v);
|
|
163
|
+
}, onEnter: () => void doCreate(), onEscape: () => setCreating(false), placeholder: t('engine.studio.pkg.idPlaceholder', locale), locale: locale, testId: "pkg-switcher-id-input" }), 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)] })) })] })] }))] }));
|
|
164
|
+
}
|
|
78
165
|
export function StudioDesignSurface({ aiSlot }) {
|
|
79
166
|
const params = useParams();
|
|
80
167
|
const packageId = params.packageId ?? 'com.example.showcase';
|
|
81
168
|
const tab = params.tab ?? 'interfaces';
|
|
82
|
-
|
|
169
|
+
const locale = useMetadataLocale();
|
|
170
|
+
// Courtesy gate (ADR-0057 D10): a read-only code/installed package refuses
|
|
171
|
+
// authoring server-side (ADR-0070), so don't let the user build up doomed
|
|
172
|
+
// local edits first — disable the authoring affordances up front. Unknown
|
|
173
|
+
// writability (fetch failed / still loading) stays ungated; the server gate
|
|
174
|
+
// remains the authority either way.
|
|
175
|
+
const [pkgWritable, setPkgWritable] = React.useState(null);
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
setPkgWritable(null);
|
|
179
|
+
fetchPackages()
|
|
180
|
+
.then((list) => {
|
|
181
|
+
if (!cancelled)
|
|
182
|
+
setPkgWritable(list.find((p) => p.id === packageId)?.writable ?? null);
|
|
183
|
+
})
|
|
184
|
+
.catch(() => {
|
|
185
|
+
/* unknown — leave ungated */
|
|
186
|
+
});
|
|
187
|
+
return () => {
|
|
188
|
+
cancelled = true;
|
|
189
|
+
};
|
|
190
|
+
}, [packageId]);
|
|
191
|
+
const readOnly = pkgWritable === false;
|
|
192
|
+
// Package-level publish (ADR-0033/0037/0048): edits accumulate as per-item
|
|
193
|
+
// drafts STAMPED with this package (each save passes packageId → the draft row's
|
|
194
|
+
// sys_metadata.package_id). Publishing promotes exactly THIS package's drafts in
|
|
195
|
+
// one atomic pass (POST /packages/:id/publish-drafts), reviewed as a whole in
|
|
196
|
+
// DraftChangesPanel. There is no per-item publish.
|
|
197
|
+
const [changesOpen, setChangesOpen] = React.useState(false);
|
|
198
|
+
const [pendingCount, setPendingCount] = React.useState(null);
|
|
199
|
+
const [publishing, setPublishing] = React.useState(false);
|
|
200
|
+
const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline
|
|
201
|
+
const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → refresh the pending-draft count
|
|
202
|
+
const refreshPending = React.useCallback(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetch(`/api/v1/meta/_drafts?packageId=${encodeURIComponent(packageId)}`, {
|
|
205
|
+
credentials: 'include',
|
|
206
|
+
headers: { Accept: 'application/json' },
|
|
207
|
+
cache: 'no-store',
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok)
|
|
210
|
+
return setPendingCount(null);
|
|
211
|
+
const data = (await res.json());
|
|
212
|
+
const list = (Array.isArray(data) ? data : (data?.drafts ?? []));
|
|
213
|
+
setPendingCount(list.length);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
setPendingCount(null);
|
|
217
|
+
}
|
|
218
|
+
}, [packageId]);
|
|
219
|
+
React.useEffect(() => {
|
|
220
|
+
void refreshPending();
|
|
221
|
+
}, [refreshPending, publishNonce, draftNonce]);
|
|
222
|
+
const doPublish = React.useCallback(async () => {
|
|
223
|
+
setPublishing(true);
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
credentials: 'include',
|
|
228
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
229
|
+
body: '{}',
|
|
230
|
+
});
|
|
231
|
+
const payload = (await res.json().catch(() => null));
|
|
232
|
+
if (!res.ok || payload?.success === false) {
|
|
233
|
+
// Hard failure (e.g. package not found) — carry the field-anchored issues.
|
|
234
|
+
throw Object.assign(new Error(payload?.error?.message || `HTTP ${res.status}`), {
|
|
235
|
+
issues: payload?.error?.details?.issues,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const failed = payload?.data?.failed ?? [];
|
|
239
|
+
if (failed.length > 0) {
|
|
240
|
+
// Partial publish: some drafts did NOT go live. The server returns 200
|
|
241
|
+
// with them buried in `failed[]`, so the UI used to claim success and
|
|
242
|
+
// swallow the reason — surface which drafts failed and why instead.
|
|
243
|
+
toast.error(formatPublishFailures(failed));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
toast.success(t('engine.studio.publishedAll', locale));
|
|
247
|
+
setChangesOpen(false);
|
|
248
|
+
}
|
|
249
|
+
setPublishNonce((n) => n + 1);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
toast.error(formatMetadataError(e));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
setPublishing(false);
|
|
256
|
+
}
|
|
257
|
+
await refreshPending();
|
|
258
|
+
}, [packageId, refreshPending]);
|
|
259
|
+
const onDraftSaved = React.useCallback(() => setDraftNonce((n) => n + 1), []);
|
|
260
|
+
const hasPending = (pendingCount ?? 0) > 0;
|
|
261
|
+
// Builder → running-app bridge (Airtable's Launch): the builder edits the
|
|
262
|
+
// package (设计界面), the app is its published front-end. If this package
|
|
263
|
+
// ships an app, offer 打开应用 — opened in a new tab so the builder context
|
|
264
|
+
// survives. (App → builder is the reverse bridge, tracked separately.)
|
|
265
|
+
const shellNavigate = useNavigate();
|
|
266
|
+
const shellClient = useMetadataClient();
|
|
267
|
+
const [packageApp, setPackageApp] = React.useState(null);
|
|
268
|
+
// 创建应用 (package has no app yet): create a draft `app` item — the published
|
|
269
|
+
// front-end's on-ramp. The button flips to 打开应用 after the package publish.
|
|
270
|
+
const [appCreating, setAppCreating] = React.useState(false);
|
|
271
|
+
const [appLabel, setAppLabel] = React.useState('');
|
|
272
|
+
const [appName, setAppName] = React.useState('');
|
|
273
|
+
const [appNameTouched, setAppNameTouched] = React.useState(false);
|
|
274
|
+
const [appBusy, setAppBusy] = React.useState(false);
|
|
275
|
+
const [appDraftPending, setAppDraftPending] = React.useState(null);
|
|
276
|
+
// Scaffold the new app's navigation from the package's objects (default on) —
|
|
277
|
+
// otherwise a fresh app has zero menu items and every object must be wired by
|
|
278
|
+
// hand in the Interfaces pillar (objectui#2262).
|
|
279
|
+
const [appAddObjects, setAppAddObjects] = React.useState(true);
|
|
280
|
+
const loadPackageObjects = React.useCallback(async () => {
|
|
281
|
+
// Published objects + pending DRAFT objects, merged — a fresh package's
|
|
282
|
+
// objects are usually still drafts (same merge the Data pillar rail does).
|
|
283
|
+
const [list, draftHeaders] = await Promise.all([
|
|
284
|
+
shellClient.list('object', { packageId }),
|
|
285
|
+
shellClient.listDrafts({ packageId, type: 'object' }).catch(() => []),
|
|
286
|
+
]);
|
|
287
|
+
const items = (list || [])
|
|
288
|
+
.map((o) => ({ name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
|
|
289
|
+
.filter((o) => o.name);
|
|
290
|
+
const known = new Set(items.map((o) => o.name));
|
|
291
|
+
for (const d of draftHeaders) {
|
|
292
|
+
if (d.name && !known.has(d.name))
|
|
293
|
+
items.push({ name: d.name, label: d.name });
|
|
294
|
+
}
|
|
295
|
+
return items;
|
|
296
|
+
}, [shellClient, packageId]);
|
|
297
|
+
const doCreateApp = React.useCallback(async () => {
|
|
298
|
+
const label = appLabel.trim();
|
|
299
|
+
const name = toFieldName(appName.trim() || label);
|
|
300
|
+
if (!label || !name || name === 'field')
|
|
301
|
+
return;
|
|
302
|
+
setAppBusy(true);
|
|
303
|
+
try {
|
|
304
|
+
const navObjects = appAddObjects ? await loadPackageObjects().catch(() => []) : [];
|
|
305
|
+
await shellClient.save('app', name, buildAppSkeleton(name, label, navObjects), { mode: 'draft', packageId });
|
|
306
|
+
toast.success(tFormat('engine.studio.app.savedDraft', locale, { label }));
|
|
307
|
+
setAppDraftPending(label);
|
|
308
|
+
setAppCreating(false);
|
|
309
|
+
setAppLabel('');
|
|
310
|
+
setAppName('');
|
|
311
|
+
setAppNameTouched(false);
|
|
312
|
+
setDraftNonce((n) => n + 1);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
toast.error(formatMetadataError(e));
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
setAppBusy(false);
|
|
319
|
+
}
|
|
320
|
+
}, [appLabel, appName, appAddObjects, loadPackageObjects, shellClient, packageId]);
|
|
321
|
+
React.useEffect(() => {
|
|
322
|
+
let cancelled = false;
|
|
323
|
+
(async () => {
|
|
324
|
+
try {
|
|
325
|
+
const apps = (await shellClient.list('app', { packageId }));
|
|
326
|
+
const first = (apps || [])
|
|
327
|
+
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
|
|
328
|
+
.filter((a) => a.name)[0];
|
|
329
|
+
if (!cancelled)
|
|
330
|
+
setPackageApp(first ?? null);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
if (!cancelled)
|
|
334
|
+
setPackageApp(null);
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
return () => {
|
|
338
|
+
cancelled = true;
|
|
339
|
+
};
|
|
340
|
+
}, [shellClient, packageId, publishNonce]);
|
|
341
|
+
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 ' +
|
|
83
342
|
(tab === p.key
|
|
84
343
|
? 'bg-primary/10 font-medium text-primary'
|
|
85
|
-
: 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), p.
|
|
344
|
+
: '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), disabled: readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : 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 disabled:pointer-events-none disabled:opacity-50", 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) => {
|
|
345
|
+
setAppLabel(e.target.value);
|
|
346
|
+
if (!appNameTouched)
|
|
347
|
+
setAppName(toFieldNameLoose(e.target.value));
|
|
348
|
+
}, onKeyDown: (e) => {
|
|
349
|
+
if (e.key === 'Enter')
|
|
350
|
+
void doCreateApp();
|
|
351
|
+
if (e.key === 'Escape')
|
|
352
|
+
setAppCreating(false);
|
|
353
|
+
}, 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) => {
|
|
354
|
+
setAppNameTouched(true);
|
|
355
|
+
setAppName(toFieldNameLoose(e.target.value));
|
|
356
|
+
}, onKeyDown: (e) => {
|
|
357
|
+
if (e.key === 'Enter')
|
|
358
|
+
void doCreateApp();
|
|
359
|
+
if (e.key === 'Escape')
|
|
360
|
+
setAppCreating(false);
|
|
361
|
+
}, 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("label", { className: "flex cursor-pointer items-center gap-1.5 px-0.5 py-0.5 text-[11px] text-muted-foreground", children: [_jsx("input", { type: "checkbox", checked: appAddObjects, onChange: (e) => setAppAddObjects(e.target.checked), className: "h-3 w-3 accent-primary" }), t('engine.studio.app.scaffoldNav', locale)] }), _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",
|
|
362
|
+
// Publish is review-then-confirm: open the pending-changes panel,
|
|
363
|
+
// whose footer button fires the actual atomic package publish —
|
|
364
|
+
// never straight from this header click (objectui#2261).
|
|
365
|
+
onClick: () => setChangesOpen(true), disabled: publishing || !hasPending || readOnly, title: readOnly
|
|
366
|
+
? t('engine.studio.pkg.readonlyHint', locale)
|
|
367
|
+
: hasPending
|
|
368
|
+
? t('engine.studio.publishTitle', locale)
|
|
369
|
+
: 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, readOnly: readOnly })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : tab === 'access' ? (_jsx(AccessPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : (_jsx(InterfacesPillar, { packageId: packageId, publishNonce: publishNonce, draftNonce: draftNonce, onDraftSaved: onDraftSaved, onCreateApp: readOnly ? undefined : () => setAppCreating(true), readOnly: readOnly })) })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen, packageId: packageId, onPublish: readOnly ? undefined : doPublish, publishing: publishing })] }));
|
|
86
370
|
}
|
|
87
371
|
/** Recursive App-navigation tree (groups + typed leaves). */
|
|
88
372
|
function NavTree({ nodes, active, onPick, }) {
|
|
@@ -98,9 +382,60 @@ function NavTree({ nodes, active, onPick, }) {
|
|
|
98
382
|
}) }));
|
|
99
383
|
}
|
|
100
384
|
/** Interfaces pillar — real App nav · live canvas · inspector. */
|
|
101
|
-
|
|
385
|
+
/**
|
|
386
|
+
* StudioNavItemInspector — right-panel editor for the selected nav item while
|
|
387
|
+
* editing an app's navigation. The Studio adds flat top-level items
|
|
388
|
+
* (`navigation[i]`), so binding is a business-friendly object picker rather
|
|
389
|
+
* than the raw path field of the generic AppNavInspector: picking an object
|
|
390
|
+
* writes `{ object }` (which the runtime resolves to that object's record
|
|
391
|
+
* list) and, if the label is still the placeholder, adopts the object's label.
|
|
392
|
+
*/
|
|
393
|
+
function StudioNavItemInspector({ navId, appDraft, objects, onNavPatch, onClear, }) {
|
|
394
|
+
const locale = useMetadataLocale();
|
|
395
|
+
const idx = React.useMemo(() => {
|
|
396
|
+
const m = /^navigation\[(\d+)\]$/.exec(navId);
|
|
397
|
+
return m ? Number(m[1]) : -1;
|
|
398
|
+
}, [navId]);
|
|
399
|
+
const nav = React.useMemo(() => (Array.isArray(appDraft.navigation) ? appDraft.navigation : []), [appDraft]);
|
|
400
|
+
const node = idx >= 0 ? nav[idx] : null;
|
|
401
|
+
if (!node) {
|
|
402
|
+
return (_jsx("div", { className: "px-2 py-10 text-center text-xs text-muted-foreground", children: t('engine.studio.nav.selectItem', locale) }));
|
|
403
|
+
}
|
|
404
|
+
const patch = (updates) => {
|
|
405
|
+
onNavPatch({ navigation: nav.map((n, i) => (i === idx ? { ...n, ...updates } : n)) });
|
|
406
|
+
};
|
|
407
|
+
const boundObject = String(node.object ?? node.objectName ?? '');
|
|
408
|
+
const curLabel = String(node.label ?? node.title ?? node.name ?? '');
|
|
409
|
+
// A nav card is a placeholder until its label is edited or a target adopts a
|
|
410
|
+
// real label. Match both the legacy English sentinel and the locale-specific
|
|
411
|
+
// default from AppNavCanvas so items created in any locale are recognized.
|
|
412
|
+
const isPlaceholder = !curLabel || curLabel === 'New item' || curLabel === t('engine.appNav.newItem', locale);
|
|
413
|
+
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) => {
|
|
414
|
+
const objName = e.target.value;
|
|
415
|
+
const obj = objects.find((o) => o.name === objName);
|
|
416
|
+
if (!objName) {
|
|
417
|
+
// Unbind → back to an (invalid, dropped-on-save) placeholder.
|
|
418
|
+
patch({ type: undefined, objectName: undefined, object: undefined });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Emit a spec-complete ObjectNavItem: the app schema's nav is a
|
|
422
|
+
// discriminated union on `type` and BaseNavItem requires a
|
|
423
|
+
// snake_case `id`. Missing either fails "navigation.0: Invalid
|
|
424
|
+
// input" at save. `object`/`path` are cleared so no stray keys
|
|
425
|
+
// linger from the blank placeholder.
|
|
426
|
+
patch({
|
|
427
|
+
id: node.id || `nav_${objName}`,
|
|
428
|
+
type: 'object',
|
|
429
|
+
objectName: objName,
|
|
430
|
+
object: undefined,
|
|
431
|
+
path: undefined,
|
|
432
|
+
label: isPlaceholder && obj ? obj.label : curLabel,
|
|
433
|
+
});
|
|
434
|
+
}, 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) })] }));
|
|
435
|
+
}
|
|
436
|
+
function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraftSaved, onCreateApp, readOnly = false, }) {
|
|
102
437
|
const client = useMetadataClient();
|
|
103
|
-
const locale =
|
|
438
|
+
const locale = useMetadataLocale();
|
|
104
439
|
const [appLabel, setAppLabel] = React.useState(packageId);
|
|
105
440
|
const [appName, setAppName] = React.useState(null);
|
|
106
441
|
const [appDraft, setAppDraft] = React.useState({});
|
|
@@ -108,6 +443,37 @@ function InterfacesPillar({ packageId }) {
|
|
|
108
443
|
// nav editing — drag-drop reorder / rename / add / remove via AppNavCanvas
|
|
109
444
|
const [editNav, setEditNav] = React.useState(false);
|
|
110
445
|
const [navSel, setNavSel] = React.useState(null);
|
|
446
|
+
// #2272 — designer deep-link: `?sel=nav:<id>` selects the nav item with
|
|
447
|
+
// that spec `id` and switches the pillar into nav editing. The id is the
|
|
448
|
+
// stable external contract; positional `navigation[i]` selection ids stay
|
|
449
|
+
// internal. Selection changes mirror back to the URL (replace).
|
|
450
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
451
|
+
const navSelParam = parseNavSelParam(searchParams.get(DESIGNER_SEL_PARAM));
|
|
452
|
+
const appliedNavSelRef = React.useRef(null);
|
|
453
|
+
React.useEffect(() => {
|
|
454
|
+
if (!navSelParam || navTree.length === 0)
|
|
455
|
+
return;
|
|
456
|
+
if (appliedNavSelRef.current === navSelParam)
|
|
457
|
+
return;
|
|
458
|
+
const hit = findNavPositionById({ navigation: navTree }, navSelParam);
|
|
459
|
+
if (!hit)
|
|
460
|
+
return;
|
|
461
|
+
appliedNavSelRef.current = navSelParam;
|
|
462
|
+
setEditNav(true);
|
|
463
|
+
setNavSel({ kind: 'nav', id: hit.selectionId });
|
|
464
|
+
}, [navSelParam, navTree]);
|
|
465
|
+
React.useEffect(() => {
|
|
466
|
+
const navId = navSel ? navIdAtPosition({ navigation: navTree }, navSel.id) : null;
|
|
467
|
+
setSearchParams((prev) => {
|
|
468
|
+
const next = new URLSearchParams(prev);
|
|
469
|
+
if (navId)
|
|
470
|
+
next.set(DESIGNER_SEL_PARAM, formatNavSelParam(navId));
|
|
471
|
+
else
|
|
472
|
+
next.delete(DESIGNER_SEL_PARAM);
|
|
473
|
+
return next;
|
|
474
|
+
}, { replace: true });
|
|
475
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
476
|
+
}, [navSel]);
|
|
111
477
|
const [navDirty, setNavDirty] = React.useState(false);
|
|
112
478
|
const [navHasDraft, setNavHasDraft] = React.useState(false);
|
|
113
479
|
const [navSaving, setNavSaving] = React.useState(false);
|
|
@@ -118,22 +484,77 @@ function InterfacesPillar({ packageId }) {
|
|
|
118
484
|
const [saving, setSaving] = React.useState(false);
|
|
119
485
|
const [hasDraft, setHasDraft] = React.useState(false);
|
|
120
486
|
const [error, setError] = React.useState(null);
|
|
121
|
-
//
|
|
487
|
+
// App resolution status — tells "still loading" apart from "this package has
|
|
488
|
+
// no app", so the canvas shows a real empty state instead of an endless
|
|
489
|
+
// spinner.
|
|
490
|
+
const [appStatus, setAppStatus] = React.useState('loading');
|
|
491
|
+
// Objects in THIS package (published ∪ draft) — the nav item inspector's
|
|
492
|
+
// object picker, so nav can be wired to sibling objects before publishing.
|
|
493
|
+
const [pkgObjects, setPkgObjects] = React.useState([]);
|
|
122
494
|
React.useEffect(() => {
|
|
123
495
|
let cancelled = false;
|
|
124
496
|
(async () => {
|
|
125
497
|
try {
|
|
126
|
-
const
|
|
498
|
+
const [pub, drafts] = await Promise.all([
|
|
499
|
+
client.list('object', { packageId }),
|
|
500
|
+
client.listDrafts({ packageId, type: 'object' }).catch(() => []),
|
|
501
|
+
]);
|
|
127
502
|
if (cancelled)
|
|
128
503
|
return;
|
|
129
|
-
const
|
|
130
|
-
|
|
504
|
+
const byName = new Map();
|
|
505
|
+
for (const raw of [...(pub || []), ...(drafts || [])]) {
|
|
506
|
+
const o = raw;
|
|
507
|
+
const name = String(o.name ?? '');
|
|
508
|
+
if (!name || byName.has(name))
|
|
509
|
+
continue;
|
|
510
|
+
byName.set(name, { name, label: String(o.label ?? o.name ?? name) });
|
|
511
|
+
}
|
|
512
|
+
setPkgObjects([...byName.values()]);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* non-fatal — picker just stays empty */
|
|
516
|
+
}
|
|
517
|
+
})();
|
|
518
|
+
return () => {
|
|
519
|
+
cancelled = true;
|
|
520
|
+
};
|
|
521
|
+
}, [client, packageId, publishNonce, draftNonce]);
|
|
522
|
+
// Resolve THIS package's App → load its navigation tree. The query is scoped
|
|
523
|
+
// to the package (`list('app', { packageId })`) so a design surface only ever
|
|
524
|
+
// shows the current package's app — never another package's. `list()` sees
|
|
525
|
+
// published metadata only, so a freshly-created (unpublished) app is found via
|
|
526
|
+
// `listDrafts()` instead, keeping it designable before its first publish.
|
|
527
|
+
React.useEffect(() => {
|
|
528
|
+
let cancelled = false;
|
|
529
|
+
setAppStatus('loading');
|
|
530
|
+
(async () => {
|
|
531
|
+
try {
|
|
532
|
+
const published = (await client.list('app', { packageId }));
|
|
533
|
+
if (cancelled)
|
|
131
534
|
return;
|
|
132
|
-
|
|
133
|
-
|
|
535
|
+
let name = published?.[0]?.name ? String(published[0].name) : null;
|
|
536
|
+
let label = published?.[0]
|
|
537
|
+
? String(published[0].label ?? published[0].name ?? packageId)
|
|
538
|
+
: packageId;
|
|
539
|
+
if (!name) {
|
|
540
|
+
const drafts = await client.listDrafts({ packageId, type: 'app' });
|
|
541
|
+
if (cancelled)
|
|
542
|
+
return;
|
|
543
|
+
const d = drafts?.[0];
|
|
544
|
+
if (d?.name) {
|
|
545
|
+
name = String(d.name);
|
|
546
|
+
label = String(d.name);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!name) {
|
|
550
|
+
setAppStatus('missing');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
setAppLabel(label);
|
|
554
|
+
setAppName(name);
|
|
134
555
|
const [layRaw, appDraftResp] = await Promise.all([
|
|
135
|
-
client.layered('app',
|
|
136
|
-
client.getDraft('app',
|
|
556
|
+
client.layered('app', name),
|
|
557
|
+
client.getDraft('app', name).catch(() => null),
|
|
137
558
|
]);
|
|
138
559
|
if (cancelled)
|
|
139
560
|
return;
|
|
@@ -141,8 +562,12 @@ function InterfacesPillar({ packageId }) {
|
|
|
141
562
|
const eff = (lay.effective ?? lay.code ?? {});
|
|
142
563
|
const appDraftBody = extractDraftBody(appDraftResp);
|
|
143
564
|
const body = appDraftBody ? { ...eff, ...appDraftBody } : eff;
|
|
565
|
+
if (typeof body.label === 'string' || typeof body.name === 'string') {
|
|
566
|
+
setAppLabel(String(body.label ?? body.name ?? label));
|
|
567
|
+
}
|
|
144
568
|
setAppDraft(body);
|
|
145
569
|
setNavHasDraft(!!appDraftBody);
|
|
570
|
+
setAppStatus('ready');
|
|
146
571
|
const tree = Array.isArray(body.navigation) ? body.navigation : [];
|
|
147
572
|
// auto-open the first resolvable leaf
|
|
148
573
|
const firstLeaf = (function find(nodes) {
|
|
@@ -163,14 +588,16 @@ function InterfacesPillar({ packageId }) {
|
|
|
163
588
|
setCurrent((cur) => cur ?? firstLeaf);
|
|
164
589
|
}
|
|
165
590
|
catch (e) {
|
|
166
|
-
if (!cancelled)
|
|
167
|
-
setError(
|
|
591
|
+
if (!cancelled) {
|
|
592
|
+
setError(formatMetadataError(e));
|
|
593
|
+
setAppStatus('missing');
|
|
594
|
+
}
|
|
168
595
|
}
|
|
169
596
|
})();
|
|
170
597
|
return () => {
|
|
171
598
|
cancelled = true;
|
|
172
599
|
};
|
|
173
|
-
}, [client, packageId]);
|
|
600
|
+
}, [client, packageId, publishNonce, draftNonce]);
|
|
174
601
|
const Preview = getMetadataPreview(current?.type ?? '');
|
|
175
602
|
const Inspector = getMetadataInspector(current?.type ?? '');
|
|
176
603
|
// Object leaves render as a runtime records grid (preview = runtime); schema
|
|
@@ -204,7 +631,7 @@ function InterfacesPillar({ packageId }) {
|
|
|
204
631
|
}
|
|
205
632
|
catch (e) {
|
|
206
633
|
if (!cancelled)
|
|
207
|
-
setError(
|
|
634
|
+
setError(formatMetadataError(e));
|
|
208
635
|
}
|
|
209
636
|
finally {
|
|
210
637
|
if (!cancelled)
|
|
@@ -214,38 +641,24 @@ function InterfacesPillar({ packageId }) {
|
|
|
214
641
|
return () => {
|
|
215
642
|
cancelled = true;
|
|
216
643
|
};
|
|
217
|
-
}, [client, current, isEditable]);
|
|
644
|
+
}, [client, current, isEditable, publishNonce]);
|
|
218
645
|
const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
|
|
219
646
|
const doSave = React.useCallback(async () => {
|
|
220
647
|
if (!current)
|
|
221
648
|
return;
|
|
222
649
|
setSaving('draft');
|
|
223
650
|
try {
|
|
224
|
-
await client.save(current.type, current.name, draft, { mode: 'draft' });
|
|
651
|
+
await client.save(current.type, current.name, draft, { mode: 'draft', packageId });
|
|
225
652
|
setHasDraft(true);
|
|
653
|
+
onDraftSaved?.();
|
|
226
654
|
}
|
|
227
655
|
catch (e) {
|
|
228
|
-
setError(
|
|
229
|
-
}
|
|
230
|
-
finally {
|
|
231
|
-
setSaving(false);
|
|
232
|
-
}
|
|
233
|
-
}, [client, current, draft]);
|
|
234
|
-
const doPublish = React.useCallback(async () => {
|
|
235
|
-
if (!current)
|
|
236
|
-
return;
|
|
237
|
-
setSaving('publish');
|
|
238
|
-
try {
|
|
239
|
-
await client.publish(current.type, current.name);
|
|
240
|
-
setHasDraft(false);
|
|
241
|
-
}
|
|
242
|
-
catch (e) {
|
|
243
|
-
setError(e instanceof Error ? e.message : String(e));
|
|
656
|
+
setError(formatMetadataError(e));
|
|
244
657
|
}
|
|
245
658
|
finally {
|
|
246
659
|
setSaving(false);
|
|
247
660
|
}
|
|
248
|
-
}, [client, current]);
|
|
661
|
+
}, [client, current, draft, onDraftSaved]);
|
|
249
662
|
// nav editing — patch appDraft.navigation, then save/publish the App overlay
|
|
250
663
|
const onNavPatch = React.useCallback((patch) => {
|
|
251
664
|
setAppDraft((d) => ({ ...d, ...patch }));
|
|
@@ -256,37 +669,39 @@ function InterfacesPillar({ packageId }) {
|
|
|
256
669
|
return;
|
|
257
670
|
setNavSaving('draft');
|
|
258
671
|
try {
|
|
259
|
-
|
|
672
|
+
// "Add nav item" inserts a blank placeholder that only becomes a valid,
|
|
673
|
+
// spec-conformant item once a target is picked in the inspector. Drop
|
|
674
|
+
// still-untargeted placeholders (no `type`) so one stray blank can't fail
|
|
675
|
+
// the whole app's spec validation ("navigation.N: Invalid input"), and
|
|
676
|
+
// backfill a snake_case id defensively.
|
|
677
|
+
const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : [];
|
|
678
|
+
const cleanedNav = rawNav
|
|
679
|
+
.filter((n) => n && typeof n.type === 'string')
|
|
680
|
+
.map((n, i) => {
|
|
681
|
+
const item = n;
|
|
682
|
+
return typeof item.id === 'string' && item.id ? item : { ...item, id: `nav_item_${i + 1}` };
|
|
683
|
+
});
|
|
684
|
+
await client.save('app', appName, { ...appDraft, navigation: cleanedNav }, { mode: 'draft', packageId });
|
|
260
685
|
setNavHasDraft(true);
|
|
261
686
|
setNavDirty(false);
|
|
687
|
+
onDraftSaved?.();
|
|
262
688
|
}
|
|
263
689
|
catch (e) {
|
|
264
|
-
setError(
|
|
265
|
-
}
|
|
266
|
-
finally {
|
|
267
|
-
setNavSaving(false);
|
|
268
|
-
}
|
|
269
|
-
}, [client, appName, appDraft]);
|
|
270
|
-
const doNavPublish = React.useCallback(async () => {
|
|
271
|
-
if (!appName)
|
|
272
|
-
return;
|
|
273
|
-
setNavSaving('publish');
|
|
274
|
-
try {
|
|
275
|
-
await client.publish('app', appName);
|
|
276
|
-
setNavHasDraft(false);
|
|
277
|
-
}
|
|
278
|
-
catch (e) {
|
|
279
|
-
setError(e instanceof Error ? e.message : String(e));
|
|
690
|
+
setError(formatMetadataError(e));
|
|
280
691
|
}
|
|
281
692
|
finally {
|
|
282
693
|
setNavSaving(false);
|
|
283
694
|
}
|
|
284
|
-
}, [client, appName]);
|
|
285
|
-
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:
|
|
695
|
+
}, [client, appName, appDraft, onDraftSaved]);
|
|
696
|
+
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 || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, 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' && !readOnly && (_jsx("button", { type: "button", onClick: () => {
|
|
286
697
|
setEditNav((v) => !v);
|
|
287
698
|
setNavSel(null);
|
|
288
|
-
}, title: editNav ? '
|
|
289
|
-
(editNav ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted'), children: editNav ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "h-3 w-3" }), "
|
|
699
|
+
}, 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] ' +
|
|
700
|
+
(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
|
|
701
|
+
? t('engine.studio.loadFailed', locale)
|
|
702
|
+
: appStatus === 'loading'
|
|
703
|
+
? t('engine.studio.loading', locale)
|
|
704
|
+
: 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)] })) })] })] })] }));
|
|
290
705
|
}
|
|
291
706
|
/** Next unused `field_N` name for a freshly-added field. */
|
|
292
707
|
function nextFieldName(existing) {
|
|
@@ -336,11 +751,12 @@ function renderStudioGridList(props) {
|
|
|
336
751
|
addDeleteRecordsInline: true,
|
|
337
752
|
}, dataSource: ds, onEdit: onEdit, className: className, refreshKey: refreshKey }));
|
|
338
753
|
}
|
|
339
|
-
function DataPillar({ packageId }) {
|
|
754
|
+
function DataPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
|
|
340
755
|
const client = useMetadataClient();
|
|
341
756
|
const adapter = useAdapter();
|
|
342
|
-
const locale =
|
|
757
|
+
const locale = useMetadataLocale();
|
|
343
758
|
const [objects, setObjects] = React.useState([]);
|
|
759
|
+
const [objectsLoaded, setObjectsLoaded] = React.useState(false);
|
|
344
760
|
const [current, setCurrent] = React.useState(null);
|
|
345
761
|
const [objDraft, setObjDraft] = React.useState({});
|
|
346
762
|
const [loading, setLoading] = React.useState(false);
|
|
@@ -350,32 +766,88 @@ function DataPillar({ packageId }) {
|
|
|
350
766
|
const [dirty, setDirty] = React.useState(false);
|
|
351
767
|
const [hasDraft, setHasDraft] = React.useState(false);
|
|
352
768
|
const [saving, setSaving] = React.useState(false);
|
|
769
|
+
// Timestamp of the last successful draft save — renders a "last saved HH:MM"
|
|
770
|
+
// hint next to the Save button (framework#2615 P3: nothing confirmed a draft
|
|
771
|
+
// save persisted, unlike the sibling pillars which toast).
|
|
772
|
+
const [savedAt, setSavedAt] = React.useState(null);
|
|
353
773
|
const [gridVer, setGridVer] = React.useState(0);
|
|
774
|
+
// Records grid ⇄ Form ⇄ Validations ⇄ Settings — four views of the SAME
|
|
775
|
+
// object. Grid/Form are the runtime renderer (same-renderer principle);
|
|
776
|
+
// Validations edits `validations` rules; Settings edits object basics +
|
|
777
|
+
// the ADR-0085 semantic roles. All patch the one `objDraft`.
|
|
778
|
+
const [viewMode, setViewMode] = React.useState('grid');
|
|
779
|
+
// Within the Form view: 布局 (WYSIWYG drag/section designer) ⇄ 预览 (live form).
|
|
780
|
+
const [formMode, setFormMode] = React.useState('layout');
|
|
781
|
+
// Tracks which object's baseline is currently loaded — so we (re)load exactly
|
|
782
|
+
// once per selected object and never clobber an in-progress draft.
|
|
783
|
+
const loadedNameRef = React.useRef(null);
|
|
784
|
+
// Left-rail search + inline "new object" creator (design §4: rail = search + New).
|
|
785
|
+
const [query, setQuery] = React.useState('');
|
|
786
|
+
const [creating, setCreating] = React.useState(false);
|
|
787
|
+
const [newLabel, setNewLabel] = React.useState('');
|
|
788
|
+
const [newName, setNewName] = React.useState('');
|
|
789
|
+
const [nameTouched, setNameTouched] = React.useState(false);
|
|
790
|
+
const [createBusy, setCreateBusy] = React.useState(false);
|
|
791
|
+
// Whether the selected object exists beyond the draft (published/code baseline).
|
|
792
|
+
// A draft-only object has NO physical table yet (DDL lands at publish), so the
|
|
793
|
+
// Records grid must not fire data SQL against it.
|
|
794
|
+
const [hasBaseline, setHasBaseline] = React.useState(true);
|
|
354
795
|
React.useEffect(() => {
|
|
355
796
|
let cancelled = false;
|
|
356
797
|
(async () => {
|
|
357
798
|
try {
|
|
358
|
-
|
|
799
|
+
// Published objects + pending DRAFT objects, merged. `list()` only
|
|
800
|
+
// sees published/active metadata, so a freshly-created writable base
|
|
801
|
+
// whose objects are all drafts would render an empty (previously:
|
|
802
|
+
// forever-"loading") rail. Draft headers carry no label — show the
|
|
803
|
+
// machine name until the draft body loads on selection.
|
|
804
|
+
const [list, draftHeaders] = await Promise.all([
|
|
805
|
+
client.list('object', { packageId }),
|
|
806
|
+
client.listDrafts({ packageId, type: 'object' }).catch(() => []),
|
|
807
|
+
]);
|
|
359
808
|
if (cancelled)
|
|
360
809
|
return;
|
|
361
810
|
const items = (list || [])
|
|
362
811
|
.map((o) => ({ type: 'object', name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
|
|
363
812
|
.filter((o) => o.name);
|
|
813
|
+
const known = new Set(items.map((o) => o.name));
|
|
814
|
+
for (const d of draftHeaders) {
|
|
815
|
+
if (d.name && !known.has(d.name)) {
|
|
816
|
+
items.push({ type: 'object', name: d.name, label: d.name });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
364
819
|
setObjects(items);
|
|
365
820
|
setCurrent((c) => c ?? items[0] ?? null);
|
|
821
|
+
// First-run: an empty writable package opens the creator right away —
|
|
822
|
+
// the first thing to do here is make an object, so put the inputs up.
|
|
823
|
+
if (items.length === 0 && !readOnly)
|
|
824
|
+
setCreating(true);
|
|
366
825
|
}
|
|
367
826
|
catch (e) {
|
|
368
827
|
if (!cancelled)
|
|
369
|
-
setError(
|
|
828
|
+
setError(formatMetadataError(e));
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
if (!cancelled)
|
|
832
|
+
setObjectsLoaded(true);
|
|
370
833
|
}
|
|
371
834
|
})();
|
|
372
835
|
return () => {
|
|
373
836
|
cancelled = true;
|
|
374
837
|
};
|
|
375
|
-
}, [client, packageId]);
|
|
838
|
+
}, [client, packageId, readOnly]);
|
|
376
839
|
React.useEffect(() => {
|
|
377
840
|
if (!current)
|
|
378
841
|
return;
|
|
842
|
+
// Load once per selected object. Bail if this object's baseline is already
|
|
843
|
+
// loaded — a client-identity churn or a child remount must NOT re-fetch and
|
|
844
|
+
// clobber the in-progress form-layout draft the designer is editing.
|
|
845
|
+
// Keyed by object + publishNonce: a package publish (nonce++) re-reads the
|
|
846
|
+
// fresh published baseline; otherwise we never clobber an in-progress draft.
|
|
847
|
+
const loadKey = `${current.name}#${publishNonce}`;
|
|
848
|
+
if (loadedNameRef.current === loadKey)
|
|
849
|
+
return;
|
|
850
|
+
loadedNameRef.current = loadKey;
|
|
379
851
|
let cancelled = false;
|
|
380
852
|
setLoading(true);
|
|
381
853
|
setError(null);
|
|
@@ -394,10 +866,11 @@ function DataPillar({ packageId }) {
|
|
|
394
866
|
const draftBody = extractDraftBody(draftResp);
|
|
395
867
|
setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
|
|
396
868
|
setHasDraft(!!draftBody);
|
|
869
|
+
setHasBaseline(!!(lay.effective ?? lay.code));
|
|
397
870
|
}
|
|
398
871
|
catch (e) {
|
|
399
872
|
if (!cancelled)
|
|
400
|
-
setError(
|
|
873
|
+
setError(formatMetadataError(e));
|
|
401
874
|
}
|
|
402
875
|
finally {
|
|
403
876
|
if (!cancelled)
|
|
@@ -407,61 +880,87 @@ function DataPillar({ packageId }) {
|
|
|
407
880
|
return () => {
|
|
408
881
|
cancelled = true;
|
|
409
882
|
};
|
|
410
|
-
}, [client, current]);
|
|
883
|
+
}, [client, current, publishNonce]);
|
|
411
884
|
const fieldCount = React.useMemo(() => readFields(objDraft.fields).entries.length, [objDraft]);
|
|
412
885
|
const onPatch = React.useCallback((patch) => {
|
|
413
886
|
setObjDraft((d) => ({ ...d, ...patch }));
|
|
414
887
|
setDirty(true);
|
|
415
888
|
}, []);
|
|
416
889
|
// "+ add field": append a fresh text field and select it for editing in the panel.
|
|
890
|
+
// Guarded in addition to being hidden — it's also reachable through
|
|
891
|
+
// GridFieldAuthoringProvider/ObjectFormDesigner.
|
|
417
892
|
const addField = React.useCallback(() => {
|
|
893
|
+
if (readOnly)
|
|
894
|
+
return;
|
|
418
895
|
const view = readFields(objDraft.fields);
|
|
419
896
|
const name = nextFieldName(view.entries.map((e) => e.name));
|
|
420
|
-
view.entries.push(newField(name, 'text', '
|
|
897
|
+
view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale)));
|
|
421
898
|
setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
|
|
422
899
|
setDirty(true);
|
|
423
900
|
setFieldSel({ kind: 'field', id: name });
|
|
424
|
-
}, [objDraft]);
|
|
425
|
-
|
|
426
|
-
|
|
901
|
+
}, [objDraft, readOnly]);
|
|
902
|
+
// "+ new object": create a fresh object as a DRAFT in this package (runtime
|
|
903
|
+
// create — same path the classic Studio editor uses), seeded with one text
|
|
904
|
+
// field so the form/grid isn't empty. It stays draft-only (no physical table)
|
|
905
|
+
// until the package publish, so we land on 表单·布局 — the metadata-level
|
|
906
|
+
// surface that never fires data SQL.
|
|
907
|
+
const doCreateObject = React.useCallback(async () => {
|
|
908
|
+
if (readOnly)
|
|
427
909
|
return;
|
|
428
|
-
|
|
910
|
+
const label = newLabel.trim();
|
|
911
|
+
const name = toFieldName(newName.trim() || label);
|
|
912
|
+
if (!label || !name || name === 'field')
|
|
913
|
+
return; // CJK label → identifier must be typed
|
|
914
|
+
if (objects.some((o) => o.name === name)) {
|
|
915
|
+
setError(tFormat('engine.studio.data.idExists', locale, { name }));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
setCreateBusy(true);
|
|
429
919
|
setError(null);
|
|
430
920
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
921
|
+
const body = buildObjectSkeleton(name, label, t('engine.studio.data.nameFieldLabel', locale));
|
|
922
|
+
await client.save('object', name, body, { mode: 'draft', packageId });
|
|
923
|
+
const surface = { type: 'object', name, label };
|
|
924
|
+
setObjects((prev) => [...prev, surface]);
|
|
925
|
+
setCurrent(surface);
|
|
926
|
+
setViewMode('form');
|
|
927
|
+
setFormMode('layout');
|
|
928
|
+
setCreating(false);
|
|
929
|
+
setNewLabel('');
|
|
930
|
+
setNewName('');
|
|
931
|
+
setNameTouched(false);
|
|
932
|
+
onDraftSaved?.();
|
|
434
933
|
}
|
|
435
934
|
catch (e) {
|
|
436
|
-
setError(
|
|
935
|
+
setError(formatMetadataError(e));
|
|
437
936
|
}
|
|
438
937
|
finally {
|
|
439
|
-
|
|
938
|
+
setCreateBusy(false);
|
|
440
939
|
}
|
|
441
|
-
}, [client,
|
|
442
|
-
const
|
|
940
|
+
}, [newLabel, newName, objects, client, packageId, onDraftSaved, readOnly]);
|
|
941
|
+
const doSave = React.useCallback(async () => {
|
|
443
942
|
if (!current)
|
|
444
943
|
return;
|
|
445
|
-
setSaving('
|
|
944
|
+
setSaving('draft');
|
|
446
945
|
setError(null);
|
|
447
946
|
try {
|
|
448
|
-
await client.
|
|
449
|
-
|
|
450
|
-
// so the remounted grid re-fetches the new/edited columns without a reload.
|
|
451
|
-
adapter?.clearCache?.();
|
|
452
|
-
setHasDraft(false);
|
|
947
|
+
await client.save('object', current.name, objDraft, { mode: 'draft', packageId });
|
|
948
|
+
setHasDraft(true);
|
|
453
949
|
setDirty(false);
|
|
454
|
-
|
|
950
|
+
setSavedAt(new Date());
|
|
951
|
+
toast.success(tFormat('engine.studio.data.savedDraft', locale, { label: current.label }));
|
|
952
|
+
onDraftSaved?.();
|
|
455
953
|
}
|
|
456
954
|
catch (e) {
|
|
457
|
-
setError(
|
|
955
|
+
setError(formatMetadataError(e));
|
|
458
956
|
}
|
|
459
957
|
finally {
|
|
460
958
|
setSaving(false);
|
|
461
959
|
}
|
|
462
|
-
}, [
|
|
960
|
+
}, [client, current, objDraft, onDraftSaved, packageId, locale]);
|
|
463
961
|
// Drag-reorder columns → reorder the object's `fields` metadata (field display
|
|
464
|
-
// order follows metadata order),
|
|
962
|
+
// order follows metadata order), saved as a DRAFT. Published later via the
|
|
963
|
+
// package release — NOT auto-published per reorder as it used to be.
|
|
465
964
|
const doReorderFields = React.useCallback(async (orderedNames) => {
|
|
466
965
|
if (!current)
|
|
467
966
|
return;
|
|
@@ -476,49 +975,124 @@ function DataPillar({ packageId }) {
|
|
|
476
975
|
const entries = view.entries.map((e) => (visible.has(e.name) ? visibleInOrder[vi++] : e));
|
|
477
976
|
const body = { ...objDraft, fields: writeFields({ ...view, entries }) };
|
|
478
977
|
setObjDraft(body);
|
|
479
|
-
setSaving('
|
|
978
|
+
setSaving('draft');
|
|
480
979
|
setError(null);
|
|
481
980
|
try {
|
|
482
|
-
await client.save('object', current.name, body, { mode: 'draft' });
|
|
483
|
-
|
|
484
|
-
adapter?.clearCache?.();
|
|
485
|
-
setHasDraft(false);
|
|
981
|
+
await client.save('object', current.name, body, { mode: 'draft', packageId });
|
|
982
|
+
setHasDraft(true);
|
|
486
983
|
setDirty(false);
|
|
487
|
-
|
|
984
|
+
onDraftSaved?.();
|
|
985
|
+
setGridVer((v) => v + 1); // remount so the grid reflects the new (draft) order
|
|
488
986
|
}
|
|
489
987
|
catch (e) {
|
|
490
|
-
setError(
|
|
988
|
+
setError(formatMetadataError(e));
|
|
491
989
|
}
|
|
492
990
|
finally {
|
|
493
991
|
setSaving(false);
|
|
494
992
|
}
|
|
495
|
-
}, [client, current, objDraft,
|
|
993
|
+
}, [client, current, objDraft, onDraftSaved]);
|
|
496
994
|
const inspector = getMetadataInspector('object');
|
|
497
|
-
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] }),
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
995
|
+
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) })), savedAt && !dirty && (_jsx("span", { className: "ml-auto text-[11px] text-muted-foreground", "data-testid": "data-saved-at", children: tFormat('engine.studio.data.lastSaved', locale, {
|
|
996
|
+
time: savedAt.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
|
997
|
+
}) })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: (savedAt && !dirty ? '' : 'ml-auto ') +
|
|
998
|
+
'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
|
|
999
|
+
.filter((o) => !query.trim() ||
|
|
1000
|
+
o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
|
|
1001
|
+
o.name.toLowerCase().includes(query.trim().toLowerCase()))
|
|
1002
|
+
.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 ' +
|
|
1003
|
+
(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: readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', locale)] })) : creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
|
|
1004
|
+
setNewLabel(e.target.value);
|
|
1005
|
+
if (!nameTouched)
|
|
1006
|
+
setNewName(toFieldNameLoose(e.target.value));
|
|
1007
|
+
}, onKeyDown: (e) => {
|
|
1008
|
+
if (e.key === 'Enter')
|
|
1009
|
+
void doCreateObject();
|
|
1010
|
+
if (e.key === 'Escape')
|
|
1011
|
+
setCreating(false);
|
|
1012
|
+
}, 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) => {
|
|
1013
|
+
setNameTouched(true);
|
|
1014
|
+
setNewName(toFieldNameLoose(e.target.value));
|
|
1015
|
+
}, onKeyDown: (e) => {
|
|
1016
|
+
if (e.key === 'Enter')
|
|
1017
|
+
void doCreateObject();
|
|
1018
|
+
if (e.key === 'Escape')
|
|
1019
|
+
setCreating(false);
|
|
1020
|
+
}, 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 ' +
|
|
1021
|
+
(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 ' +
|
|
1022
|
+
(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 ' +
|
|
1023
|
+
(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 ' +
|
|
1024
|
+
(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'
|
|
1025
|
+
? t('engine.studio.data.badge.grid', locale)
|
|
1026
|
+
: viewMode === 'rules'
|
|
1027
|
+
? t('engine.studio.data.badge.rules', locale)
|
|
1028
|
+
: viewMode === 'settings'
|
|
1029
|
+
? t('engine.studio.data.badge.settings', locale)
|
|
1030
|
+
: formMode === 'layout'
|
|
1031
|
+
? t('engine.studio.data.badge.formLayout', locale)
|
|
1032
|
+
: t('engine.studio.data.badge.formPreview', locale)] }), (viewMode === 'grid' || viewMode === 'form') && !readOnly && (_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, disabled: readOnly })) : viewMode === 'settings' ? (_jsx(ObjectSettingsPanel, { name: current.name, draft: objDraft, onPatch: onPatch, locale: locale, disabled: readOnly })) : 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: () => {
|
|
1033
|
+
setViewMode('form');
|
|
1034
|
+
setFormMode('layout');
|
|
1035
|
+
}, 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: {
|
|
1036
|
+
// Read-only package: keep the per-column inspector (view props)
|
|
1037
|
+
// but drop add-column and drag-reorder — both are doomed writes.
|
|
1038
|
+
...(readOnly
|
|
1039
|
+
? {}
|
|
1040
|
+
: {
|
|
1041
|
+
onAddColumn: addField,
|
|
1042
|
+
addColumnLabel: t('engine.studio.data.addField', locale),
|
|
1043
|
+
onReorderFields: doReorderFields,
|
|
1044
|
+
}),
|
|
1045
|
+
onEditColumn: (fieldName) => {
|
|
1046
|
+
// ignore non-field columns (e.g. the row-actions column)
|
|
1047
|
+
if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
|
|
1048
|
+
setFieldSel({ kind: 'field', id: fieldName });
|
|
1049
|
+
}
|
|
520
1050
|
},
|
|
521
|
-
|
|
1051
|
+
editColumnLabel: t('engine.studio.data.editFieldProps', locale),
|
|
1052
|
+
}, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(PluginObjectView, { schema: {
|
|
1053
|
+
type: 'object-view',
|
|
1054
|
+
objectName: current.name,
|
|
1055
|
+
// No saved view exists in design mode, so show the object's
|
|
1056
|
+
// own fields as columns (in metadata order), dropping
|
|
1057
|
+
// framework-managed/audit fields so the grid opens on the
|
|
1058
|
+
// meaningful columns first — the way Airtable does.
|
|
1059
|
+
table: {
|
|
1060
|
+
fields: readFields(objDraft.fields)
|
|
1061
|
+
.entries.map((e) => e.name)
|
|
1062
|
+
// Also drop a field named `actions`: the grid always pins
|
|
1063
|
+
// its own row-actions column headed "Actions", so a data
|
|
1064
|
+
// column of the same name reads as a duplicated column.
|
|
1065
|
+
// The field stays editable in the form designer.
|
|
1066
|
+
.filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n) && n !== 'actions'),
|
|
1067
|
+
},
|
|
1068
|
+
}, 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 ' +
|
|
1069
|
+
(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 ' +
|
|
1070
|
+
(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, readOnly: readOnly })) : !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: `
|
|
1071
|
+
/* Design preview: the form is a click-to-select canvas, not a data-entry
|
|
1072
|
+
* form. Disable interaction on field contents so a click anywhere on a
|
|
1073
|
+
* field routes to its [data-field] wrapper (→ select), and neutralize the
|
|
1074
|
+
* create/cancel footer so nothing is submittable here. */
|
|
1075
|
+
.os-form-authoring [data-field]{border-radius:8px;cursor:pointer;transition:box-shadow .12s;padding:8px;margin:-8px 0;}
|
|
1076
|
+
.os-form-authoring [data-field] *{pointer-events:none;}
|
|
1077
|
+
.os-form-authoring [data-field]:hover{box-shadow:0 0 0 1px hsl(var(--border));}
|
|
1078
|
+
.os-form-authoring form > div:last-child:has(button){display:none;}
|
|
1079
|
+
${fieldSel?.kind === 'field'
|
|
1080
|
+
? `.os-form-authoring [data-field="${String(fieldSel.id).replace(/[^\w-]/g, '')}"]{box-shadow:0 0 0 2px hsl(var(--primary));}`
|
|
1081
|
+
: ''}
|
|
1082
|
+
` }), _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) => {
|
|
1083
|
+
const el = e.target.closest('[data-field]');
|
|
1084
|
+
const name = el?.getAttribute('data-field');
|
|
1085
|
+
if (name && readFields(objDraft.fields).entries.some((f) => f.name === name)) {
|
|
1086
|
+
setFieldSel({ kind: 'field', id: name });
|
|
1087
|
+
}
|
|
1088
|
+
}, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(ObjectForm, { schema: {
|
|
1089
|
+
type: 'object-form',
|
|
1090
|
+
objectName: current.name,
|
|
1091
|
+
mode: 'create',
|
|
1092
|
+
fields: readFields(objDraft.fields)
|
|
1093
|
+
.entries.map((e) => e.name)
|
|
1094
|
+
.filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
|
|
1095
|
+
}, 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, {
|
|
522
1096
|
type: 'object',
|
|
523
1097
|
name: current.name,
|
|
524
1098
|
draft: objDraft,
|
|
@@ -526,14 +1100,30 @@ function DataPillar({ packageId }) {
|
|
|
526
1100
|
onPatch,
|
|
527
1101
|
onClearSelection: () => setFieldSel(null),
|
|
528
1102
|
onSelectionChange: setFieldSel,
|
|
529
|
-
readOnly
|
|
1103
|
+
readOnly,
|
|
530
1104
|
locale,
|
|
531
1105
|
}) })] }))] })] }));
|
|
532
1106
|
}
|
|
533
|
-
/**
|
|
534
|
-
|
|
1107
|
+
/**
|
|
1108
|
+
* A flow's live status in the Automations rail: a colored dot + On/Off, from the
|
|
1109
|
+
* engine's runtime state (persisted `status` is intent; this is what's actually
|
|
1110
|
+
* live). Renders nothing for a flow the engine doesn't know yet (never published)
|
|
1111
|
+
* — the amber "unpublished draft" chip already covers that case.
|
|
1112
|
+
*/
|
|
1113
|
+
export function FlowStatusDot({ state, locale }) {
|
|
1114
|
+
if (!state)
|
|
1115
|
+
return null;
|
|
1116
|
+
const { enabled, bound } = state;
|
|
1117
|
+
const title = enabled
|
|
1118
|
+
? bound
|
|
1119
|
+
? t('engine.studio.auto.onBound', locale)
|
|
1120
|
+
: t('engine.studio.auto.onUnbound', locale)
|
|
1121
|
+
: t('engine.studio.auto.offTitle', locale);
|
|
1122
|
+
return (_jsxs("span", { title: title, className: "inline-flex shrink-0 items-center gap-1", children: [_jsx("span", { className: 'h-1.5 w-1.5 rounded-full ' + (enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40') }), _jsx("span", { className: 'text-[10px] ' + (enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'), children: enabled ? t('engine.studio.auto.on', locale) : t('engine.studio.auto.off', locale) })] }));
|
|
1123
|
+
}
|
|
1124
|
+
function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
|
|
535
1125
|
const client = useMetadataClient();
|
|
536
|
-
const locale =
|
|
1126
|
+
const locale = useMetadataLocale();
|
|
537
1127
|
const [flows, setFlows] = React.useState([]);
|
|
538
1128
|
const [current, setCurrent] = React.useState(null);
|
|
539
1129
|
const [draft, setDraft] = React.useState({});
|
|
@@ -542,31 +1132,107 @@ function AutomationsPillar({ packageId }) {
|
|
|
542
1132
|
const [saving, setSaving] = React.useState(false);
|
|
543
1133
|
const [hasDraft, setHasDraft] = React.useState(false);
|
|
544
1134
|
const [error, setError] = React.useState(null);
|
|
1135
|
+
// Tells "still fetching the list" apart from "fetched, package has no flows"
|
|
1136
|
+
// — without it the empty rail showed an endless "加载中…" for a fresh package.
|
|
1137
|
+
const [listed, setListed] = React.useState(false);
|
|
1138
|
+
// Inline create — a fresh package starts with zero flows, so the pillar must
|
|
1139
|
+
// offer a way to author the first one (mirrors the object/app creators).
|
|
1140
|
+
const [creating, setCreating] = React.useState(false);
|
|
1141
|
+
const [newLabel, setNewLabel] = React.useState('');
|
|
1142
|
+
const [newName, setNewName] = React.useState('');
|
|
1143
|
+
const [createBusy, setCreateBusy] = React.useState(false);
|
|
545
1144
|
const Preview = getMetadataPreview(current?.type ?? '');
|
|
546
1145
|
const inspector = getMetadataInspector('flow');
|
|
547
1146
|
const isEditable = !!Preview;
|
|
1147
|
+
// Runtime enable/bound state per flow (GET /automation/_status). Persisted
|
|
1148
|
+
// `status` is intent; this is what's actually live in the engine — the truth
|
|
1149
|
+
// behind the rail's status dots. Refetched after a publish (publishNonce);
|
|
1150
|
+
// degrades silently on an older backend / offline (dots just don't render).
|
|
1151
|
+
const [flowStatus, setFlowStatus] = React.useState({});
|
|
548
1152
|
React.useEffect(() => {
|
|
549
1153
|
let cancelled = false;
|
|
550
1154
|
(async () => {
|
|
551
1155
|
try {
|
|
552
|
-
const
|
|
1156
|
+
const res = await fetch('/api/v1/automation/_status', { credentials: 'include', headers: { Accept: 'application/json' } });
|
|
1157
|
+
if (!res.ok)
|
|
1158
|
+
return;
|
|
1159
|
+
const payload = (await res.json().catch(() => null));
|
|
1160
|
+
const list = payload?.data?.flows ?? payload?.flows ?? [];
|
|
1161
|
+
if (cancelled || !Array.isArray(list))
|
|
1162
|
+
return;
|
|
1163
|
+
const map = {};
|
|
1164
|
+
for (const s of list)
|
|
1165
|
+
if (s?.name)
|
|
1166
|
+
map[s.name] = { enabled: s.enabled !== false, bound: !!s.bound };
|
|
1167
|
+
setFlowStatus(map);
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
/* offline / older backend → no dots */
|
|
1171
|
+
}
|
|
1172
|
+
})();
|
|
1173
|
+
return () => {
|
|
1174
|
+
cancelled = true;
|
|
1175
|
+
};
|
|
1176
|
+
}, [publishNonce]);
|
|
1177
|
+
React.useEffect(() => {
|
|
1178
|
+
let cancelled = false;
|
|
1179
|
+
(async () => {
|
|
1180
|
+
try {
|
|
1181
|
+
// Published flows ∪ pending DRAFT flows — `list()` only sees
|
|
1182
|
+
// published/active metadata, so a just-authored flow that hasn't been
|
|
1183
|
+
// published yet (or a fresh writable-base package whose flows are all
|
|
1184
|
+
// drafts) would render an empty rail even though "Changes · N" shows the
|
|
1185
|
+
// draft exists. Mirrors the Data / Interfaces / Access pillars, which all
|
|
1186
|
+
// merge their drafts. Keyed on `publishNonce` too so drafts that go live
|
|
1187
|
+
// collapse back into the published rail after a package publish.
|
|
1188
|
+
const items = await loadPackageSurfaces(client, 'flow', packageId);
|
|
553
1189
|
if (cancelled)
|
|
554
1190
|
return;
|
|
555
|
-
const items = (list || [])
|
|
556
|
-
.map((f) => ({ type: 'flow', name: String(f.name ?? ''), label: String(f.label ?? f.name ?? '') }))
|
|
557
|
-
.filter((f) => f.name);
|
|
558
1191
|
setFlows(items);
|
|
559
1192
|
setCurrent((c) => c ?? items[0] ?? null);
|
|
560
1193
|
}
|
|
561
1194
|
catch (e) {
|
|
562
1195
|
if (!cancelled)
|
|
563
|
-
setError(
|
|
1196
|
+
setError(formatMetadataError(e));
|
|
1197
|
+
}
|
|
1198
|
+
finally {
|
|
1199
|
+
if (!cancelled)
|
|
1200
|
+
setListed(true);
|
|
564
1201
|
}
|
|
565
1202
|
})();
|
|
566
1203
|
return () => {
|
|
567
1204
|
cancelled = true;
|
|
568
1205
|
};
|
|
569
|
-
}, [client, packageId]);
|
|
1206
|
+
}, [client, packageId, publishNonce]);
|
|
1207
|
+
const doCreateFlow = React.useCallback(async () => {
|
|
1208
|
+
const label = newLabel.trim();
|
|
1209
|
+
const name = toFieldName(newName.trim() || label);
|
|
1210
|
+
if (!label || !name || name === 'field')
|
|
1211
|
+
return;
|
|
1212
|
+
setCreateBusy(true);
|
|
1213
|
+
setError(null);
|
|
1214
|
+
try {
|
|
1215
|
+
// Minimal valid, autolaunched skeleton: start → end. The designer fills in
|
|
1216
|
+
// the trigger + nodes; publishing it is a separate, user-initiated step.
|
|
1217
|
+
const skeleton = buildFlowSkeleton(name, label, t('engine.studio.auto.nodeStart', locale), t('engine.studio.auto.nodeEnd', locale));
|
|
1218
|
+
await client.save('flow', name, skeleton, { mode: 'draft', packageId });
|
|
1219
|
+
const item = { type: 'flow', name, label };
|
|
1220
|
+
setFlows((fs) => [...fs.filter((f) => f.name !== name), item]);
|
|
1221
|
+
setCurrent(item);
|
|
1222
|
+
setHasDraft(true);
|
|
1223
|
+
setCreating(false);
|
|
1224
|
+
setNewLabel('');
|
|
1225
|
+
setNewName('');
|
|
1226
|
+
onDraftSaved?.();
|
|
1227
|
+
toast.success(tFormat('engine.studio.auto.savedDraft', locale, { label }));
|
|
1228
|
+
}
|
|
1229
|
+
catch (e) {
|
|
1230
|
+
setError(formatMetadataError(e));
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
setCreateBusy(false);
|
|
1234
|
+
}
|
|
1235
|
+
}, [newLabel, newName, client, packageId, onDraftSaved]);
|
|
570
1236
|
React.useEffect(() => {
|
|
571
1237
|
if (!current)
|
|
572
1238
|
return;
|
|
@@ -590,7 +1256,7 @@ function AutomationsPillar({ packageId }) {
|
|
|
590
1256
|
}
|
|
591
1257
|
catch (e) {
|
|
592
1258
|
if (!cancelled)
|
|
593
|
-
setError(
|
|
1259
|
+
setError(formatMetadataError(e));
|
|
594
1260
|
}
|
|
595
1261
|
finally {
|
|
596
1262
|
if (!cancelled)
|
|
@@ -600,7 +1266,7 @@ function AutomationsPillar({ packageId }) {
|
|
|
600
1266
|
return () => {
|
|
601
1267
|
cancelled = true;
|
|
602
1268
|
};
|
|
603
|
-
}, [client, current]);
|
|
1269
|
+
}, [client, current, publishNonce]);
|
|
604
1270
|
const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
|
|
605
1271
|
const doSave = React.useCallback(async () => {
|
|
606
1272
|
if (!current)
|
|
@@ -608,34 +1274,56 @@ function AutomationsPillar({ packageId }) {
|
|
|
608
1274
|
setSaving('draft');
|
|
609
1275
|
setError(null);
|
|
610
1276
|
try {
|
|
611
|
-
await client.save('flow', current.name, draft, { mode: 'draft' });
|
|
1277
|
+
await client.save('flow', current.name, draft, { mode: 'draft', packageId });
|
|
612
1278
|
setHasDraft(true);
|
|
1279
|
+
onDraftSaved?.();
|
|
613
1280
|
}
|
|
614
1281
|
catch (e) {
|
|
615
|
-
setError(
|
|
1282
|
+
setError(formatMetadataError(e));
|
|
616
1283
|
}
|
|
617
1284
|
finally {
|
|
618
1285
|
setSaving(false);
|
|
619
1286
|
}
|
|
620
|
-
}, [client, current, draft]);
|
|
621
|
-
|
|
1287
|
+
}, [client, current, draft, onDraftSaved]);
|
|
1288
|
+
// Enable/disable persists via the flow's deployment `status` (active = on,
|
|
1289
|
+
// obsolete = off) — the engine honors it on the next publish. The switch flips
|
|
1290
|
+
// it and saves the draft immediately; the change goes live when the package is
|
|
1291
|
+
// published (so "review before enabling" is preserved).
|
|
1292
|
+
const flowEnabled = draft.status !== 'obsolete' && draft.status !== 'invalid';
|
|
1293
|
+
const toggleEnabled = React.useCallback(async () => {
|
|
622
1294
|
if (!current)
|
|
623
1295
|
return;
|
|
624
|
-
|
|
1296
|
+
const next = !(draft.status !== 'obsolete' && draft.status !== 'invalid');
|
|
1297
|
+
const nextDraft = { ...draft, status: next ? 'active' : 'obsolete' };
|
|
1298
|
+
setDraft(nextDraft);
|
|
1299
|
+
setSaving('draft');
|
|
625
1300
|
setError(null);
|
|
626
1301
|
try {
|
|
627
|
-
await client.
|
|
628
|
-
setHasDraft(
|
|
1302
|
+
await client.save('flow', current.name, nextDraft, { mode: 'draft', packageId });
|
|
1303
|
+
setHasDraft(true);
|
|
1304
|
+
onDraftSaved?.();
|
|
1305
|
+
toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale));
|
|
629
1306
|
}
|
|
630
1307
|
catch (e) {
|
|
631
|
-
setError(
|
|
1308
|
+
setError(formatMetadataError(e));
|
|
632
1309
|
}
|
|
633
1310
|
finally {
|
|
634
1311
|
setSaving(false);
|
|
635
1312
|
}
|
|
636
|
-
}, [client, current]);
|
|
637
|
-
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:
|
|
638
|
-
|
|
1313
|
+
}, [client, current, draft, packageId, onDraftSaved, locale]);
|
|
1314
|
+
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) })), current && (_jsxs("button", { type: "button", role: "switch", "aria-checked": flowEnabled, onClick: toggleEnabled, disabled: !isEditable || !!saving, title: flowEnabled ? t('engine.studio.auto.disableTitle', locale) : t('engine.studio.auto.enableTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [_jsx("span", { className: 'relative inline-flex h-3.5 w-6 shrink-0 items-center rounded-full transition-colors ' + (flowEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'), children: _jsx("span", { className: 'inline-block h-2.5 w-2.5 rounded-full bg-white transition-transform ' + (flowEnabled ? 'translate-x-3' : 'translate-x-0.5') }) }), flowEnabled ? t('engine.studio.auto.enabled', locale) : t('engine.studio.auto.disabled', locale)] })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, 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) }), !readOnly && (_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 &&
|
|
1315
|
+
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 ' +
|
|
1316
|
+
(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 }), _jsx(FlowStatusDot, { state: flowStatus[f.name], locale: locale })] }, 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) => {
|
|
1317
|
+
if (e.key === 'Enter')
|
|
1318
|
+
void doCreateFlow();
|
|
1319
|
+
if (e.key === 'Escape')
|
|
1320
|
+
setCreating(false);
|
|
1321
|
+
}, 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) => {
|
|
1322
|
+
if (e.key === 'Enter')
|
|
1323
|
+
void doCreateFlow();
|
|
1324
|
+
if (e.key === 'Escape')
|
|
1325
|
+
setCreating(false);
|
|
1326
|
+
}, 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, {
|
|
639
1327
|
type: current.type,
|
|
640
1328
|
name: current.name,
|
|
641
1329
|
draft,
|
|
@@ -644,7 +1332,7 @@ function AutomationsPillar({ packageId }) {
|
|
|
644
1332
|
onSelectionChange: setSelection,
|
|
645
1333
|
onPatch,
|
|
646
1334
|
locale,
|
|
647
|
-
})) : (_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" }), "
|
|
1335
|
+
})) : (_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, {
|
|
648
1336
|
type: 'flow',
|
|
649
1337
|
name: current.name,
|
|
650
1338
|
draft,
|
|
@@ -654,6 +1342,135 @@ function AutomationsPillar({ packageId }) {
|
|
|
654
1342
|
onSelectionChange: setSelection,
|
|
655
1343
|
readOnly: false,
|
|
656
1344
|
locale,
|
|
657
|
-
})) : (_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" }),
|
|
1345
|
+
})) : (_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)] })) })] })] })] }));
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Access pillar — the permission workbench (builder-ui §7, ADR-0084's fourth
|
|
1349
|
+
* content pillar). Left rail: the environment's permission sets / profiles;
|
|
1350
|
+
* main: the Salesforce-style PermissionMatrixEditPage (objects × CRUD/VAMA +
|
|
1351
|
+
* field-level R/W).
|
|
1352
|
+
*
|
|
1353
|
+
* Scope note (ADR-0086 P0/P1/P2): the pillar is scoped to the current package.
|
|
1354
|
+
* The left rail lists only permission sets this package owns — the metadata API
|
|
1355
|
+
* filters `permission` by the record-level `package_id` provenance server-side
|
|
1356
|
+
* (P1), so environment-owned platform defaults (`admin_full_access`,
|
|
1357
|
+
* `member_default`, …) are excluded by the backend. The object MATRIX lists only
|
|
1358
|
+
* the objects this package declares, and Save merges just that slice back,
|
|
1359
|
+
* leaving other packages' contributed rows untouched (P0). Save writes a package
|
|
1360
|
+
* DRAFT and publishes with the whole package via the top-bar Publish (P2, D6).
|
|
1361
|
+
*/
|
|
1362
|
+
function AccessPillar({ packageId, publishNonce, onDraftSaved, readOnly = false, }) {
|
|
1363
|
+
const client = useMetadataClient();
|
|
1364
|
+
const locale = useMetadataLocale();
|
|
1365
|
+
const [perms, setPerms] = React.useState([]);
|
|
1366
|
+
const [loaded, setLoaded] = React.useState(false);
|
|
1367
|
+
const [error, setError] = React.useState(null);
|
|
1368
|
+
const [current, setCurrent] = React.useState(null);
|
|
1369
|
+
const [query, setQuery] = React.useState('');
|
|
1370
|
+
// inline creator (same rail pattern as the Data pillar's object creator)
|
|
1371
|
+
const [creating, setCreating] = React.useState(false);
|
|
1372
|
+
const [newLabel, setNewLabel] = React.useState('');
|
|
1373
|
+
const [newName, setNewName] = React.useState('');
|
|
1374
|
+
const [nameTouched, setNameTouched] = React.useState(false);
|
|
1375
|
+
const [busy, setBusy] = React.useState(false);
|
|
1376
|
+
const load = React.useCallback(async () => {
|
|
1377
|
+
try {
|
|
1378
|
+
// Scope the rail to this package server-side (ADR-0086 P1): the metadata
|
|
1379
|
+
// API filters `permission` by the record-level `package_id` provenance, so
|
|
1380
|
+
// it returns only the sets this package owns — environment-owned platform
|
|
1381
|
+
// defaults (`admin_full_access`, `member_default`, …) are excluded by the
|
|
1382
|
+
// backend, not the client. (The `?package=` list rows don't echo the
|
|
1383
|
+
// provenance columns, so a client-side filter can't do this.)
|
|
1384
|
+
//
|
|
1385
|
+
// ADR-0086 P2 (D6): a package permission set is draft/published metadata,
|
|
1386
|
+
// so the rail shows published ∪ pending-draft sets — a set created (or
|
|
1387
|
+
// renamed) as a draft but not yet published must still appear, just like
|
|
1388
|
+
// the Data/Interfaces pillars merge their drafts. Draft headers are
|
|
1389
|
+
// already package-scoped by `listDrafts({ packageId })`.
|
|
1390
|
+
const [list, drafts] = await Promise.all([
|
|
1391
|
+
client.list('permission', { packageId }),
|
|
1392
|
+
client.listDrafts({ packageId, type: 'permission' }).catch(() => []),
|
|
1393
|
+
]);
|
|
1394
|
+
const byName = new Map();
|
|
1395
|
+
for (const p of list || []) {
|
|
1396
|
+
const name = String(p.name ?? p.id ?? '');
|
|
1397
|
+
if (!name)
|
|
1398
|
+
continue;
|
|
1399
|
+
byName.set(name, {
|
|
1400
|
+
name,
|
|
1401
|
+
label: String(p.label ?? p.name ?? ''),
|
|
1402
|
+
isProfile: !!p.isProfile,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
for (const d of drafts || []) {
|
|
1406
|
+
const name = String(d?.name ?? '');
|
|
1407
|
+
if (!name || byName.has(name))
|
|
1408
|
+
continue;
|
|
1409
|
+
byName.set(name, { name, label: name });
|
|
1410
|
+
}
|
|
1411
|
+
const scoped = [...byName.values()];
|
|
1412
|
+
setPerms(scoped);
|
|
1413
|
+
setCurrent((c) => c ?? scoped[0]?.name ?? null);
|
|
1414
|
+
}
|
|
1415
|
+
catch (e) {
|
|
1416
|
+
setError(formatMetadataError(e));
|
|
1417
|
+
}
|
|
1418
|
+
finally {
|
|
1419
|
+
setLoaded(true);
|
|
1420
|
+
}
|
|
1421
|
+
}, [client, packageId]);
|
|
1422
|
+
React.useEffect(() => {
|
|
1423
|
+
void load();
|
|
1424
|
+
// Re-read after a package publish so drafts that went live collapse into
|
|
1425
|
+
// the published rail (ADR-0086 P2).
|
|
1426
|
+
}, [load, publishNonce]);
|
|
1427
|
+
const doCreate = React.useCallback(async () => {
|
|
1428
|
+
const label = newLabel.trim();
|
|
1429
|
+
const name = toFieldName(newName.trim() || label);
|
|
1430
|
+
if (!label || !name || name === 'field')
|
|
1431
|
+
return;
|
|
1432
|
+
setBusy(true);
|
|
1433
|
+
try {
|
|
1434
|
+
// Package door → create as a DRAFT stamped with this package (D6/D7),
|
|
1435
|
+
// published atomically with the rest of the package.
|
|
1436
|
+
await client.save('permission', name, buildPermissionSkeleton(name, label), { mode: 'draft', packageId });
|
|
1437
|
+
toast.success(tFormat('engine.studio.access.created', locale, { label }));
|
|
1438
|
+
setCreating(false);
|
|
1439
|
+
setNewLabel('');
|
|
1440
|
+
setNewName('');
|
|
1441
|
+
setNameTouched(false);
|
|
1442
|
+
onDraftSaved?.();
|
|
1443
|
+
await load();
|
|
1444
|
+
setCurrent(name);
|
|
1445
|
+
}
|
|
1446
|
+
catch (e) {
|
|
1447
|
+
toast.error(formatMetadataError(e));
|
|
1448
|
+
}
|
|
1449
|
+
finally {
|
|
1450
|
+
setBusy(false);
|
|
1451
|
+
}
|
|
1452
|
+
}, [client, newLabel, newName, load, packageId, onDraftSaved, locale]);
|
|
1453
|
+
const filtered = perms.filter((p) => !query.trim() ||
|
|
1454
|
+
p.label.toLowerCase().includes(query.trim().toLowerCase()) ||
|
|
1455
|
+
p.name.toLowerCase().includes(query.trim().toLowerCase()));
|
|
1456
|
+
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 ' +
|
|
1457
|
+
(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) => {
|
|
1458
|
+
setNewLabel(e.target.value);
|
|
1459
|
+
if (!nameTouched)
|
|
1460
|
+
setNewName(toFieldNameLoose(e.target.value));
|
|
1461
|
+
}, onKeyDown: (e) => {
|
|
1462
|
+
if (e.key === 'Enter')
|
|
1463
|
+
void doCreate();
|
|
1464
|
+
if (e.key === 'Escape')
|
|
1465
|
+
setCreating(false);
|
|
1466
|
+
}, 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) => {
|
|
1467
|
+
setNameTouched(true);
|
|
1468
|
+
setNewName(toFieldNameLoose(e.target.value));
|
|
1469
|
+
}, onKeyDown: (e) => {
|
|
1470
|
+
if (e.key === 'Enter')
|
|
1471
|
+
void doCreate();
|
|
1472
|
+
if (e.key === 'Escape')
|
|
1473
|
+
setCreating(false);
|
|
1474
|
+
}, 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) })] })] })) : readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', 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) })) })] })] }));
|
|
658
1475
|
}
|
|
659
1476
|
export default StudioDesignSurface;
|