Skip to content

Commit 3c7abf9

Browse files
os-zhuangclaude
andauthored
feat(studio): Data rail search + inline new-object (draft into the package) (#2169)
Close the two remaining v1 rail gaps (builder-ui §4): the objects rail gains a search filter and an inline 新建对象 creator (显示名 + auto-derived snake_case 标识符, hand-editable for CJK labels). Creation saves the object as a DRAFT in the current package (runtime create), seeds one text field, and lands in 表单·布局 — the metadata-level designer. Draft-only objects (no physical table until the package publish) get honest placeholders: the Records grid explains data arrives after publish instead of firing SQL at a missing table, and 预览 explains there is no published definition yet. After the package publish both become live automatically (publishNonce). Browser-verified end-to-end on a fresh backend: search filters; creating into a read-only code package surfaces the ADR-0070 writable_package_required error; creating into a writable base (POST /packages) lands in 布局 with 变更·1; both placeholders show; top-bar 发布 → drafts 0, physical table created (data query 200), grid + preview turn live. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 00e7735 commit 3c7abf9

2 files changed

Lines changed: 199 additions & 20 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
feat(studio): Data pillar left rail gains search + inline "new object"
6+
7+
Closes the two remaining v1 rail gaps from the builder design (§4): the objects rail
8+
now has a **search** filter and an inline **新建对象** creator (显示名 + auto-derived
9+
snake_case 标识符 — hand-editable, since CJK labels can't derive one). Creating saves
10+
the object as a **draft in the current package** (same runtime-create path the classic
11+
Studio editor uses), seeded with one text field, and lands in 表单 · 布局 — the
12+
metadata-level designer.
13+
14+
Draft-only objects (no physical table until the package publish) now get honest
15+
placeholders instead of broken surfaces: the Records grid explains that data arrives
16+
after publish (instead of firing SQL at a table that doesn't exist), and 预览 explains
17+
there is no published definition yet.

packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx

Lines changed: 182 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@ import { getMetadataPreview, type MetadataSelection } from '../metadata-admin/pr
4848
import { getMetadataInspector } from '../metadata-admin/inspector-registry';
4949
import { useMetadataClient } from '../metadata-admin/useMetadata';
5050
import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
51-
import { readFields, writeFields, newField } from '../metadata-admin/previews/object-fields-io';
51+
import {
52+
readFields,
53+
writeFields,
54+
newField,
55+
toFieldName,
56+
toFieldNameLoose,
57+
} from '../metadata-admin/previews/object-fields-io';
5258
import { ObjectFormDesigner } from './ObjectFormDesigner';
5359
import { DraftChangesPanel } from '../../preview/DraftChangesPanel';
5460
import { toast } from 'sonner';
@@ -772,6 +778,17 @@ function DataPillar({
772778
// Tracks which object's baseline is currently loaded — so we (re)load exactly
773779
// once per selected object and never clobber an in-progress draft.
774780
const loadedNameRef = React.useRef<string | null>(null);
781+
// Left-rail search + inline "new object" creator (design §4: rail = search + New).
782+
const [query, setQuery] = React.useState('');
783+
const [creating, setCreating] = React.useState(false);
784+
const [newLabel, setNewLabel] = React.useState('');
785+
const [newName, setNewName] = React.useState('');
786+
const [nameTouched, setNameTouched] = React.useState(false);
787+
const [createBusy, setCreateBusy] = React.useState(false);
788+
// Whether the selected object exists beyond the draft (published/code baseline).
789+
// A draft-only object has NO physical table yet (DDL lands at publish), so the
790+
// Records grid must not fire data SQL against it.
791+
const [hasBaseline, setHasBaseline] = React.useState(true);
775792

776793
React.useEffect(() => {
777794
let cancelled = false;
@@ -820,6 +837,7 @@ function DataPillar({
820837
const draftBody = extractDraftBody(draftResp);
821838
setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
822839
setHasDraft(!!draftBody);
840+
setHasBaseline(!!(lay.effective ?? lay.code));
823841
} catch (e) {
824842
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
825843
} finally {
@@ -848,6 +866,45 @@ function DataPillar({
848866
setFieldSel({ kind: 'field', id: name });
849867
}, [objDraft]);
850868

869+
// "+ new object": create a fresh object as a DRAFT in this package (runtime
870+
// create — same path the classic Studio editor uses), seeded with one text
871+
// field so the form/grid isn't empty. It stays draft-only (no physical table)
872+
// until the package publish, so we land on 表单·布局 — the metadata-level
873+
// surface that never fires data SQL.
874+
const doCreateObject = React.useCallback(async () => {
875+
const label = newLabel.trim();
876+
const name = toFieldName(newName.trim() || label);
877+
if (!label || !name || name === 'field') return; // CJK label → identifier must be typed
878+
if (objects.some((o) => o.name === name)) {
879+
setError(`标识符 "${name}" 已存在`);
880+
return;
881+
}
882+
setCreateBusy(true);
883+
setError(null);
884+
try {
885+
const body: Record<string, unknown> = {
886+
name,
887+
label,
888+
fields: { name: { type: 'text', label: '名称' } },
889+
};
890+
await client.save('object', name, body, { mode: 'draft', packageId });
891+
const surface: Surface = { type: 'object', name, label };
892+
setObjects((prev) => [...prev, surface]);
893+
setCurrent(surface);
894+
setViewMode('form');
895+
setFormMode('layout');
896+
setCreating(false);
897+
setNewLabel('');
898+
setNewName('');
899+
setNameTouched(false);
900+
onDraftSaved?.();
901+
} catch (e) {
902+
setError(e instanceof Error ? e.message : String(e));
903+
} finally {
904+
setCreateBusy(false);
905+
}
906+
}, [newLabel, newName, objects, client, packageId, onDraftSaved]);
907+
851908
const doSave = React.useCallback(async () => {
852909
if (!current) return;
853910
setSaving('draft');
@@ -928,24 +985,100 @@ function DataPillar({
928985
</div>
929986

930987
<div className="flex min-h-0 flex-1">
931-
<nav className="w-52 shrink-0 overflow-auto border-r p-2">
932-
<p className="px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground">对象</p>
933-
{objects.length === 0 && (
934-
<p className="px-2 py-3 text-[11px] text-muted-foreground">{error ? '加载失败' : '加载中…'}</p>
935-
)}
936-
{objects.map((o) => (
937-
<button
938-
key={o.name}
939-
onClick={() => setCurrent(o)}
940-
className={
941-
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
942-
(current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60')
943-
}
944-
>
945-
<Database className="h-3.5 w-3.5 shrink-0" />
946-
<span className="flex-1 truncate">{o.label}</span>
947-
</button>
948-
))}
988+
<nav className="flex w-52 shrink-0 flex-col border-r">
989+
<div className="shrink-0 p-2 pb-1">
990+
<p className="px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground">对象</p>
991+
<input
992+
value={query}
993+
onChange={(e) => setQuery(e.target.value)}
994+
placeholder="搜索对象…"
995+
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"
996+
/>
997+
</div>
998+
<div className="min-h-0 flex-1 overflow-auto p-2 pt-1">
999+
{objects.length === 0 && (
1000+
<p className="px-2 py-3 text-[11px] text-muted-foreground">{error ? '加载失败' : '加载中…'}</p>
1001+
)}
1002+
{objects
1003+
.filter(
1004+
(o) =>
1005+
!query.trim() ||
1006+
o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
1007+
o.name.toLowerCase().includes(query.trim().toLowerCase()),
1008+
)
1009+
.map((o) => (
1010+
<button
1011+
key={o.name}
1012+
onClick={() => setCurrent(o)}
1013+
className={
1014+
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1015+
(current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60')
1016+
}
1017+
>
1018+
<Database className="h-3.5 w-3.5 shrink-0" />
1019+
<span className="flex-1 truncate">{o.label}</span>
1020+
</button>
1021+
))}
1022+
</div>
1023+
<div className="shrink-0 border-t p-2">
1024+
{creating ? (
1025+
<div className="flex flex-col gap-1.5">
1026+
<input
1027+
autoFocus
1028+
value={newLabel}
1029+
onChange={(e) => {
1030+
setNewLabel(e.target.value);
1031+
if (!nameTouched) setNewName(toFieldNameLoose(e.target.value));
1032+
}}
1033+
onKeyDown={(e) => {
1034+
if (e.key === 'Enter') void doCreateObject();
1035+
if (e.key === 'Escape') setCreating(false);
1036+
}}
1037+
placeholder="显示名(如:报修工单)"
1038+
className="h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary"
1039+
/>
1040+
<input
1041+
value={newName}
1042+
onChange={(e) => {
1043+
setNameTouched(true);
1044+
setNewName(toFieldNameLoose(e.target.value));
1045+
}}
1046+
onKeyDown={(e) => {
1047+
if (e.key === 'Enter') void doCreateObject();
1048+
if (e.key === 'Escape') setCreating(false);
1049+
}}
1050+
placeholder="标识符(如:repair_ticket)"
1051+
className="h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary"
1052+
/>
1053+
<div className="flex items-center gap-1.5">
1054+
<button
1055+
type="button"
1056+
onClick={() => void doCreateObject()}
1057+
disabled={createBusy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field'}
1058+
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"
1059+
>
1060+
{createBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
1061+
创建(存为草稿)
1062+
</button>
1063+
<button
1064+
type="button"
1065+
onClick={() => setCreating(false)}
1066+
className="rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
1067+
>
1068+
取消
1069+
</button>
1070+
</div>
1071+
</div>
1072+
) : (
1073+
<button
1074+
type="button"
1075+
onClick={() => setCreating(true)}
1076+
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"
1077+
>
1078+
<Plus className="h-3.5 w-3.5" /> 新建对象
1079+
</button>
1080+
)}
1081+
</div>
9491082
</nav>
9501083

9511084
<main className="flex min-w-0 flex-1 flex-col overflow-hidden p-4">
@@ -999,7 +1132,28 @@ function DataPillar({
9991132
{error}
10001133
</div>
10011134
)}
1002-
{viewMode === 'grid' ? (
1135+
{viewMode === 'grid' && !hasBaseline ? (
1136+
/* Draft-only object: no physical table until the package publish —
1137+
* rendering the runtime grid would fire data SQL against a table
1138+
* that doesn't exist. Say so instead of erroring. */
1139+
<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">
1140+
<p className="text-sm font-medium">未发布的新对象</p>
1141+
<p className="max-w-md text-[11px] leading-5 text-muted-foreground">
1142+
「记录」网格查询真实数据,而这个对象发布前还没有数据表。请先在「表单 · 布局」里设计字段与分组,
1143+
然后点顶栏「发布」— 发布后这里就是它的实时数据网格。
1144+
</p>
1145+
<button
1146+
type="button"
1147+
onClick={() => {
1148+
setViewMode('form');
1149+
setFormMode('layout');
1150+
}}
1151+
className="mt-1 inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted"
1152+
>
1153+
去「表单 · 布局」设计字段
1154+
</button>
1155+
</div>
1156+
) : viewMode === 'grid' ? (
10031157
<>
10041158
{/* Records grid — fields are the columns. Header "+" adds a field, the
10051159
* per-column edit affordance opens the field editor, and dragging a
@@ -1098,6 +1252,14 @@ function DataPillar({
10981252
onSelectField={(name) => setFieldSel({ kind: 'field', id: name })}
10991253
onAddField={addField}
11001254
/>
1255+
) : !hasBaseline ? (
1256+
/* Draft-only object: there is no published definition to preview yet. */
1257+
<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">
1258+
<p className="text-sm font-medium">尚无已发布定义</p>
1259+
<p className="max-w-md text-[11px] leading-5 text-muted-foreground">
1260+
「预览」渲染已发布的运行态表单,而这个对象还未发布。在「布局」里确认草稿,点顶栏「发布」后即可预览。
1261+
</p>
1262+
</div>
11011263
) : (
11021264
<>
11031265
{/* Form — the real runtime ObjectForm ("same renderer"): the object's

0 commit comments

Comments
 (0)