From 278a13ebfa99890fcbc0b1301d35ba6db3363040 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 03:59:59 +0800 Subject: [PATCH] fix(studio): authorable Automations + Interfaces-nav in a fresh package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding a brand-new package end-to-end surfaced two blocking dead-ends in the pillar Studio: - Automations pillar could not create a flow — a zero-flow package showed an endless '加载中…' (loading conflated with empty) and had no create button. Add a listed-state flag (real '还没有自动化' empty state) + a '+ 新建' inline creator that saves a minimal start→end autolaunched skeleton draft and opens it in the flow designer. - Interfaces nav items could not be bound to a target AND silently failed to save: the editor produced { label, object } (no id/type), which fails the app spec's navigation union ('navigation.N: Invalid input'), so drafts never persisted and published nav stayed empty. Render a StudioNavItemInspector (right panel) with an object picker from the package's published ∪ draft objects, emitting a spec-valid ObjectNavItem { id, type:'object', objectName, label }; nav save drops unbound placeholders + backfills a snake_case id. Also fills in home.build.* / home.template.* i18n (en/zh) for the Home builder-cover cards. Verified end-to-end in the browser: designed 3 related objects → created a flow → bound nav to objects → atomic publish → standalone app renders a 3-item sidebar with the application list and resolved lookups. Co-Authored-By: Claude Opus 4.8 --- .../studio-automations-nav-authoring.md | 32 ++ .../studio-design/StudioDesignSurface.tsx | 274 +++++++++++++++++- packages/i18n/src/locales/en.ts | 8 + packages/i18n/src/locales/zh.ts | 8 + 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 .changeset/studio-automations-nav-authoring.md diff --git a/.changeset/studio-automations-nav-authoring.md b/.changeset/studio-automations-nav-authoring.md new file mode 100644 index 0000000000..b848ae87ee --- /dev/null +++ b/.changeset/studio-automations-nav-authoring.md @@ -0,0 +1,32 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +fix(studio): make the Automations and Interfaces pillars authorable in a fresh package + +Dogfooding a brand-new package end-to-end (design objects → automations → +interfaces → publish → use) surfaced two blocking dead-ends in the pillar +Studio, both now fixed: + +- **Automations pillar had no way to create a flow.** For a package with zero + flows the rail rendered an endless "加载中…" (loading conflated with empty) + and offered no create affordance, so automations could never be authored. + It now tracks the list-loaded state (real empty state "还没有自动化 — 点「新建」开始") + and has a "+ 新建" inline creator that saves a minimal, valid `start → end` + autolaunched flow skeleton as a draft and opens it in the flow designer. + +- **Interfaces nav items could not be bound to a target — and silently failed + to save.** Selecting a nav item showed no inspector, and the item shape the + editor produced (`{ label, object }`, no `id`/`type`) failed the app spec's + navigation union ("navigation.N: Invalid input"), so the draft never + persisted and the published app navigation stayed empty. The right panel now + renders a `StudioNavItemInspector` with a business-friendly object picker + (populated from the package's published ∪ draft objects) that emits a + spec-valid `ObjectNavItem` (`{ id, type:'object', objectName, label }`), and + the nav save drops still-unbound placeholders + backfills a snake_case id so + one blank item can't fail the whole save. + +Also fills in the Home builder-cover i18n keys (`home.build.*`, +`home.template.*`) in `en`/`zh` so the "Build an app" / "Start with a template" +cards resolve real strings instead of falling back to defaults. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index d5f74cc365..841877426e 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -659,6 +659,113 @@ function NavTree({ } /** Interfaces pillar — real App nav · live canvas · inspector. */ +/** + * StudioNavItemInspector — right-panel editor for the selected nav item while + * editing an app's navigation. The Studio adds flat top-level items + * (`navigation[i]`), so binding is a business-friendly object picker rather + * than the raw path field of the generic AppNavInspector: picking an object + * writes `{ object }` (which the runtime resolves to that object's record + * list) and, if the label is still the placeholder, adopts the object's label. + */ +function StudioNavItemInspector({ + navId, + appDraft, + objects, + onNavPatch, + onClear, +}: { + navId: string; + appDraft: Record; + objects: Array<{ name: string; label: string }>; + onNavPatch: (patch: Record) => void; + onClear: () => void; +}): React.ReactElement { + const idx = React.useMemo(() => { + const m = /^navigation\[(\d+)\]$/.exec(navId); + return m ? Number(m[1]) : -1; + }, [navId]); + const nav = React.useMemo( + () => (Array.isArray(appDraft.navigation) ? (appDraft.navigation as Array>) : []), + [appDraft], + ); + const node = idx >= 0 ? nav[idx] : null; + if (!node) { + return ( +
在左侧选择一个菜单项。
+ ); + } + const patch = (updates: Record) => { + onNavPatch({ navigation: nav.map((n, i) => (i === idx ? { ...n, ...updates } : n)) }); + }; + const boundObject = String(node.object ?? node.objectName ?? ''); + const curLabel = String(node.label ?? node.title ?? node.name ?? ''); + const isPlaceholder = !curLabel || curLabel === 'New item'; + return ( +
+
+ + patch({ label: e.target.value })} + placeholder="如:职位" + className="w-full rounded border bg-background px-2 py-1 text-xs" + /> +
+
+ + +

+ {boundObject ? '这个菜单项会打开该对象的记录列表。' : '选择一个对象,菜单项将打开它的记录列表。'} +

+ {objects.length === 0 && ( +

+ 这个软件包还没有对象 — 先到 Data 支柱创建。 +

+ )} +
+ +
+ ); +} + function InterfacesPillar({ packageId, publishNonce = 0, @@ -703,6 +810,35 @@ function InterfacesPillar({ // no app", so the canvas shows a real empty state instead of an endless // spinner. const [appStatus, setAppStatus] = React.useState<'loading' | 'ready' | 'missing'>('loading'); + // Objects in THIS package (published ∪ draft) — the nav item inspector's + // object picker, so nav can be wired to sibling objects before publishing. + const [pkgObjects, setPkgObjects] = React.useState>([]); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const [pub, drafts] = await Promise.all([ + client.list('object', { packageId }) as Promise>>, + client.listDrafts({ packageId, type: 'object' }).catch(() => [] as Array>), + ]); + if (cancelled) return; + const byName = new Map(); + for (const raw of [...(pub || []), ...(drafts || [])]) { + const o = raw as Record; + const name = String(o.name ?? ''); + if (!name || byName.has(name)) continue; + byName.set(name, { name, label: String(o.label ?? o.name ?? name) }); + } + setPkgObjects([...byName.values()]); + } catch { + /* non-fatal — picker just stays empty */ + } + })(); + return () => { + cancelled = true; + }; + }, [client, packageId, publishNonce, draftNonce]); // Resolve THIS package's App → load its navigation tree. The query is scoped // to the package (`list('app', { packageId })`) so a design surface only ever @@ -845,7 +981,19 @@ function InterfacesPillar({ if (!appName) return; setNavSaving('draft'); try { - await client.save('app', appName, appDraft, { mode: 'draft', packageId }); + // "Add nav item" inserts a blank placeholder that only becomes a valid, + // spec-conformant item once a target is picked in the inspector. Drop + // still-untargeted placeholders (no `type`) so one stray blank can't fail + // the whole app's spec validation ("navigation.N: Invalid input"), and + // backfill a snake_case id defensively. + const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : []; + const cleanedNav = rawNav + .filter((n) => n && typeof (n as Record).type === 'string') + .map((n, i) => { + const item = n as Record; + return typeof item.id === 'string' && item.id ? item : { ...item, id: `nav_item_${i + 1}` }; + }); + await client.save('app', appName, { ...appDraft, navigation: cleanedNav }, { mode: 'draft', packageId }); setNavHasDraft(true); setNavDirty(false); onDraftSaved?.(); @@ -1069,7 +1217,15 @@ function InterfacesPillar({ )}
- {selection && Inspector && current ? ( + {editNav && navSel ? ( + setNavSel(null)} + /> + ) : selection && Inspector && current ? ( (false); const [hasDraft, setHasDraft] = React.useState(false); const [error, setError] = React.useState(null); + // Tells "still fetching the list" apart from "fetched, package has no flows" + // — without it the empty rail showed an endless "加载中…" for a fresh package. + const [listed, setListed] = React.useState(false); + // Inline create — a fresh package starts with zero flows, so the pillar must + // offer a way to author the first one (mirrors the object/app creators). + const [creating, setCreating] = React.useState(false); + const [newLabel, setNewLabel] = React.useState(''); + const [newName, setNewName] = React.useState(''); + const [createBusy, setCreateBusy] = React.useState(false); const Preview = getMetadataPreview(current?.type ?? ''); const inspector = getMetadataInspector('flow'); const isEditable = !!Preview; @@ -1879,6 +2044,8 @@ function AutomationsPillar({ setCurrent((c) => c ?? items[0] ?? null); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + } finally { + if (!cancelled) setListed(true); } })(); return () => { @@ -1886,6 +2053,42 @@ function AutomationsPillar({ }; }, [client, packageId]); + const doCreateFlow = React.useCallback(async () => { + const label = newLabel.trim(); + const name = toFieldName(newName.trim() || label); + if (!label || !name || name === 'field') return; + setCreateBusy(true); + setError(null); + try { + // Minimal valid, autolaunched skeleton: start → end. The designer fills in + // the trigger + nodes; publishing it is a separate, user-initiated step. + const skeleton = { + name, + label, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { id: 'end', type: 'end', label: '结束' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + await client.save('flow', name, skeleton, { mode: 'draft', packageId }); + const item: Surface = { type: 'flow', name, label }; + setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); + setCurrent(item); + setHasDraft(true); + setCreating(false); + setNewLabel(''); + setNewName(''); + onDraftSaved?.(); + toast.success(`自动化「${label}」已存为草稿`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCreateBusy(false); + } + }, [newLabel, newName, client, packageId, onDraftSaved]); + React.useEffect(() => { if (!current) return; let cancelled = false; @@ -1954,11 +2157,19 @@ function AutomationsPillar({
-