Skip to content

Commit 3622f6a

Browse files
os-zhuangclaude
andauthored
feat(components,app-shell): Airtable-style table field management — edit field properties + full type list (#2090)
Replaces the limited add-field modal (6 hardcoded types) with full table-based field management, reusing the existing ObjectFieldInspector. In the Data pillar: - "+ 添加字段" creates a field and opens the field-property editor. - Each column header gets a hover "edit field" affordance (extends the GridFieldAuthoring context with onEditColumn, guarded to skip non-field columns) that opens the same editor. - The editor (ObjectFieldInspector) exposes the full 38-type list grouped by category + per-type config (text min/max, number precision, currency, select options, lookup target, formula, ...), required/unique, default, description, advanced (readonly/hidden/indexed), and delete. - Edits accumulate in the object draft; 保存草稿 + 发布 persist the env overlay, then adapter.clearCache() + grid remount surface the new/edited column live. Verified live (showcase_account, OS_METADATA_WRITABLE=object): add a field, rename to "客户分级", publish -> overlay {name:field_19,type:text,label:客户分级}, effective fields 16->19, the column appears in the grid without a reload. The type selector lists all categories (text/number/date/logic/selection/relation/ media/calculated/advanced). Per the Airtable study, AI "field agents" are intentionally out of scope. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e921b9 commit 3622f6a

3 files changed

Lines changed: 176 additions & 132 deletions

File tree

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

Lines changed: 151 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ import {
3636
Pencil,
3737
Check,
3838
Plus,
39+
X,
3940
type LucideIcon,
4041
} from 'lucide-react';
4142
import { getMetadataPreview, type MetadataSelection } from '../metadata-admin/preview-registry';
4243
import { getMetadataInspector } from '../metadata-admin/inspector-registry';
4344
import { useMetadataClient } from '../metadata-admin/useMetadata';
4445
import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
46+
import { readFields, writeFields, newField } from '../metadata-admin/previews/object-fields-io';
4547

4648
const PILLARS = [
4749
{ key: 'data', label: 'Data' },
@@ -598,53 +600,34 @@ function InterfacesPillar({ packageId }: { packageId: string }): React.ReactElem
598600
);
599601
}
600602

601-
const NEW_FIELD_TYPES: Array<{ value: string; label: string }> = [
602-
{ value: 'text', label: '文本' },
603-
{ value: 'textarea', label: '长文本' },
604-
{ value: 'number', label: '数字' },
605-
{ value: 'boolean', label: '勾选' },
606-
{ value: 'date', label: '日期' },
607-
{ value: 'datetime', label: '日期时间' },
608-
];
609-
610-
/** Slugify a label into a safe field name, unique against existing names. */
611-
function toFieldName(label: string, existing: string[]): string {
612-
const base =
613-
label
614-
.trim()
615-
.toLowerCase()
616-
.replace(/[^a-z0-9]+/g, '_')
617-
.replace(/^_+|_+$/g, '') || `field_${existing.length + 1}`;
618-
let name = base;
619-
let i = 2;
620-
while (existing.includes(name)) name = `${base}_${i++}`;
603+
/** Next unused `field_N` name for a freshly-added field. */
604+
function nextFieldName(existing: string[]): string {
605+
let i = existing.length + 1;
606+
let name = `field_${i}`;
607+
while (existing.includes(name)) name = `field_${++i}`;
621608
return name;
622609
}
623610

624-
/** Append a field to an object body, honoring keyed-map or array `fields`. */
625-
function appendField(
626-
body: Record<string, unknown>,
627-
field: { name: string; type: string; label: string },
628-
): Record<string, unknown> {
629-
const raw = body.fields;
630-
if (Array.isArray(raw)) return { ...body, fields: [...raw, field] };
631-
return { ...body, fields: { ...((raw as object) ?? {}), [field.name]: field } };
632-
}
633-
634-
/** Data pillar — the package's objects: list → fields + record grid. */
611+
/**
612+
* Data pillar — the package's objects: a records grid (Airtable parity) plus
613+
* table-based field management. Add a field, or click a column header's edit
614+
* affordance, to open ObjectFieldInspector (full type list + per-type config)
615+
* in the right panel; changes persist via the object draft → publish overlay.
616+
*/
635617
function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
636618
const client = useMetadataClient();
637619
const adapter = useAdapter();
620+
const locale = 'zh-CN';
638621
const [objects, setObjects] = React.useState<Surface[]>([]);
639622
const [current, setCurrent] = React.useState<Surface | null>(null);
640-
const [obj, setObj] = React.useState<Record<string, unknown> | null>(null);
623+
const [objDraft, setObjDraft] = React.useState<Record<string, unknown>>({});
641624
const [loading, setLoading] = React.useState(false);
642625
const [error, setError] = React.useState<string | null>(null);
643-
// add-field (Airtable-style "+" on the grid column header)
644-
const [addOpen, setAddOpen] = React.useState(false);
645-
const [fLabel, setFLabel] = React.useState('');
646-
const [fType, setFType] = React.useState('text');
647-
const [saving, setSaving] = React.useState(false);
626+
// field management — a selected field opens ObjectFieldInspector (full type + config)
627+
const [fieldSel, setFieldSel] = React.useState<MetadataSelection | null>(null);
628+
const [dirty, setDirty] = React.useState(false);
629+
const [hasDraft, setHasDraft] = React.useState(false);
630+
const [saving, setSaving] = React.useState<false | 'draft' | 'publish'>(false);
648631
const [gridVer, setGridVer] = React.useState(0);
649632

650633
React.useEffect(() => {
@@ -665,19 +648,27 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
665648
return () => {
666649
cancelled = true;
667650
};
668-
}, [client]);
651+
}, [client, packageId]);
669652

670653
React.useEffect(() => {
671654
if (!current) return;
672655
let cancelled = false;
673656
setLoading(true);
657+
setError(null);
658+
setFieldSel(null);
659+
setDirty(false);
674660
(async () => {
675661
try {
676-
const lay = (await client.layered<Record<string, unknown>>('object', current.name)) as {
677-
effective?: Record<string, unknown>;
678-
code?: Record<string, unknown>;
679-
};
680-
if (!cancelled) setObj(lay.effective ?? lay.code ?? null);
662+
const [layRaw, draftResp] = await Promise.all([
663+
client.layered<Record<string, unknown>>('object', current.name),
664+
client.getDraft<Record<string, unknown>>('object', current.name).catch(() => null),
665+
]);
666+
if (cancelled) return;
667+
const lay = layRaw as { effective?: Record<string, unknown>; code?: Record<string, unknown> };
668+
const baseline = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
669+
const draftBody = extractDraftBody(draftResp);
670+
setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
671+
setHasDraft(!!draftBody);
681672
} catch (e) {
682673
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
683674
} finally {
@@ -689,41 +680,58 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
689680
};
690681
}, [client, current]);
691682

692-
const fields: Array<{ name: string; label?: string; type?: string }> = React.useMemo(() => {
693-
const raw = obj?.fields;
694-
if (Array.isArray(raw)) return raw as never;
695-
if (raw && typeof raw === 'object')
696-
return Object.entries(raw).map(([name, f]) => ({ name, ...(f as object) })) as never;
697-
return [];
698-
}, [obj]);
699-
700-
const doAddField = React.useCallback(async () => {
701-
if (!current || !obj || !fLabel.trim()) return;
702-
const name = toFieldName(
703-
fLabel,
704-
fields.map((f) => f.name),
705-
);
706-
const body = appendField(obj, { name, type: fType, label: fLabel.trim() });
707-
setSaving(true);
683+
const fieldCount = React.useMemo(() => readFields(objDraft.fields).entries.length, [objDraft]);
684+
685+
const onPatch = React.useCallback((patch: Record<string, unknown>) => {
686+
setObjDraft((d) => ({ ...d, ...patch }));
687+
setDirty(true);
688+
}, []);
689+
690+
// "+ add field": append a fresh text field and select it for editing in the panel.
691+
const addField = React.useCallback(() => {
692+
const view = readFields(objDraft.fields);
693+
const name = nextFieldName(view.entries.map((e) => e.name));
694+
view.entries.push(newField(name, 'text', '新字段'));
695+
setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
696+
setDirty(true);
697+
setFieldSel({ kind: 'field', id: name });
698+
}, [objDraft]);
699+
700+
const doSave = React.useCallback(async () => {
701+
if (!current) return;
702+
setSaving('draft');
703+
setError(null);
704+
try {
705+
await client.save('object', current.name, objDraft, { mode: 'draft' });
706+
setHasDraft(true);
707+
setDirty(false);
708+
} catch (e) {
709+
setError(e instanceof Error ? e.message : String(e));
710+
} finally {
711+
setSaving(false);
712+
}
713+
}, [client, current, objDraft]);
714+
715+
const doPublish = React.useCallback(async () => {
716+
if (!current) return;
717+
setSaving('publish');
708718
setError(null);
709719
try {
710-
await client.save('object', current.name, body, { mode: 'draft' });
711720
await client.publish('object', current.name);
712-
// Bust the data-layer object-schema cache so the remounted grid re-fetches
713-
// the new column without a full page reload (the grid reads its columns from
714-
// dataSource.getObjectSchema, a separate cache from the metadata client).
721+
// Bust the data-layer object-schema cache (separate from the metadata client)
722+
// so the remounted grid re-fetches the new/edited columns without a reload.
715723
(adapter as { clearCache?: () => void } | null)?.clearCache?.();
716-
setObj(body); // optimistic
724+
setHasDraft(false);
725+
setDirty(false);
717726
setGridVer((v) => v + 1);
718-
setAddOpen(false);
719-
setFLabel('');
720-
setFType('text');
721727
} catch (e) {
722728
setError(e instanceof Error ? e.message : String(e));
723729
} finally {
724730
setSaving(false);
725731
}
726-
}, [adapter, client, current, obj, fLabel, fType, fields]);
732+
}, [adapter, client, current]);
733+
734+
const inspector = getMetadataInspector('object');
727735

728736
return (
729737
<div className="flex h-full">
@@ -756,22 +764,42 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
756764
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
757765
object · {current.name}
758766
</span>
759-
<span className="text-[11px] text-muted-foreground">{fields.length} 字段</span>
767+
<span className="text-[11px] text-muted-foreground">{fieldCount} 字段</span>
768+
{hasDraft && (
769+
<span className="rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
770+
未发布草稿
771+
</span>
772+
)}
760773
<button
761774
type="button"
762-
onClick={() => setAddOpen(true)}
763-
title="给该对象添加一个字段"
775+
onClick={addField}
776+
title="添加一个字段(随后在右侧设置类型与属性)"
764777
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"
765778
>
766779
<Plus className="h-3.5 w-3.5" /> 添加字段
767780
</button>
768781
</div>
769-
{/* Data mode = the records themselves, as a directly-viewable grid
770-
* (Airtable parity). Fields are the columns; the trailing "+" column
771-
* header (via GridFieldAuthoringProvider) adds a new field. */}
782+
{error && (
783+
<div className="mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive">
784+
{error}
785+
</div>
786+
)}
787+
{/* Records grid — fields are the columns. The trailing "+" header adds a
788+
* field; each column header's edit affordance opens the field editor
789+
* (both via the GridFieldAuthoringProvider context the data-table reads). */}
772790
<div className="min-h-0 flex-1 overflow-auto rounded-lg border bg-background">
773791
<GridFieldAuthoringProvider
774-
value={{ onAddColumn: () => setAddOpen(true), addColumnLabel: '添加字段' }}
792+
value={{
793+
onAddColumn: addField,
794+
addColumnLabel: '添加字段',
795+
onEditColumn: (fieldName) => {
796+
// ignore non-field columns (e.g. the row-actions column)
797+
if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
798+
setFieldSel({ kind: 'field', id: fieldName });
799+
}
800+
},
801+
editColumnLabel: '编辑字段属性',
802+
}}
775803
>
776804
<SchemaRenderer
777805
key={`${current.name}:${gridVer}`}
@@ -782,64 +810,56 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
782810
</>
783811
)}
784812
</main>
785-
{addOpen && (
786-
<div
787-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
788-
onClick={() => !saving && setAddOpen(false)}
789-
>
790-
<div
791-
className="w-80 rounded-lg border bg-background p-4 shadow-lg"
792-
onClick={(e) => e.stopPropagation()}
793-
>
794-
<p className="mb-3 text-sm font-medium">添加字段 · {current?.label}</p>
795-
{error && (
796-
<div className="mb-2 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
797-
{error}
798-
</div>
799-
)}
800-
<label className="mb-1 block text-[11px] text-muted-foreground">字段名称</label>
801-
<input
802-
autoFocus
803-
value={fLabel}
804-
onChange={(e) => setFLabel(e.target.value)}
805-
onKeyDown={(e) => {
806-
if (e.key === 'Enter' && fLabel.trim() && !saving) doAddField();
807-
if (e.key === 'Escape') setAddOpen(false);
808-
}}
809-
placeholder="例如 备注"
810-
className="mb-3 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
811-
/>
812-
<label className="mb-1 block text-[11px] text-muted-foreground">类型</label>
813-
<select
814-
value={fType}
815-
onChange={(e) => setFType(e.target.value)}
816-
className="mb-4 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
813+
{/* field inspector — full type list + per-type config (reuses ObjectFieldInspector) */}
814+
{current && fieldSel && inspector && (
815+
<aside className="flex w-80 shrink-0 flex-col border-l">
816+
<header className="flex items-center gap-2 border-b px-3 py-2">
817+
<SlidersHorizontal className="h-3.5 w-3.5" />
818+
<span className="text-[13px] font-medium">字段属性</span>
819+
<span className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
820+
{fieldSel.id}
821+
</span>
822+
<button
823+
type="button"
824+
onClick={() => setFieldSel(null)}
825+
className="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
826+
aria-label="关闭"
817827
>
818-
{NEW_FIELD_TYPES.map((ft) => (
819-
<option key={ft.value} value={ft.value}>
820-
{ft.label}
821-
</option>
822-
))}
823-
</select>
824-
<div className="flex justify-end gap-2">
825-
<button
826-
onClick={() => setAddOpen(false)}
827-
disabled={saving}
828-
className="rounded-md border px-3 py-1 text-xs hover:bg-muted disabled:opacity-50"
829-
>
830-
取消
831-
</button>
832-
<button
833-
onClick={doAddField}
834-
disabled={!fLabel.trim() || saving}
835-
className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
836-
>
837-
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
838-
添加并发布
839-
</button>
840-
</div>
828+
<X className="h-3.5 w-3.5" />
829+
</button>
830+
</header>
831+
<div className="flex items-center gap-1.5 border-b px-3 py-1.5">
832+
<button
833+
onClick={doSave}
834+
disabled={!dirty || !!saving}
835+
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50"
836+
>
837+
{saving === 'draft' ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
838+
保存草稿
839+
</button>
840+
<button
841+
onClick={doPublish}
842+
disabled={!hasDraft || !!saving}
843+
className="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50"
844+
>
845+
{saving === 'publish' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
846+
发布
847+
</button>
841848
</div>
842-
</div>
849+
<div className="min-h-0 flex-1 overflow-auto p-3">
850+
{React.createElement(inspector, {
851+
type: 'object',
852+
name: current.name,
853+
draft: objDraft,
854+
selection: fieldSel,
855+
onPatch,
856+
onClearSelection: () => setFieldSel(null),
857+
onSelectionChange: setFieldSel,
858+
readOnly: false,
859+
locale,
860+
})}
861+
</div>
862+
</aside>
843863
)}
844864
</div>
845865
);

packages/components/src/context/gridFieldAuthoring.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ import React from 'react';
2222

2323
export interface GridFieldAuthoring {
2424
/** Invoked when the user clicks the trailing "+" add-column header affordance. */
25-
onAddColumn: () => void;
25+
onAddColumn?: () => void;
2626
/** Optional tooltip/aria-label for the add-column button (defaults to "Add field"). */
2727
addColumnLabel?: string;
28+
/**
29+
* Invoked when the user clicks the per-column "edit field" affordance in a
30+
* column header (Airtable-style). Receives the column's accessorKey (= field
31+
* name). Omit to hide the edit affordance.
32+
*/
33+
onEditColumn?: (fieldName: string) => void;
34+
/** Optional tooltip/aria-label for the edit-field button (defaults to "Edit field"). */
35+
editColumnLabel?: string;
2836
}
2937

3038
const GridFieldAuthoringContext = React.createContext<GridFieldAuthoring | null>(null);

0 commit comments

Comments
 (0)