Skip to content

Commit 48f25e1

Browse files
os-zhuangclaude
andauthored
feat(components,app-shell): Airtable-style "+ add field" on the data-table column header (#2069)
Adds an opt-in GridFieldAuthoring context to @object-ui/components: the data-table renders a trailing "+" header cell (and matching body cell) ONLY when a host provides the context, so every runtime table renders byte-identically. Studio's Data pillar wraps the object grid in the provider, opening an add-field form (label + type) that patches the object's `fields`, saves a draft + publishes the env overlay, busts the data-layer schema cache (adapter.clearCache) and remounts the grid so the new column appears live. Note: packaged object schema is `allowOrgOverride: false` by default (objects are locked — schema changes imply migrations). Adding a field requires the env to permit object overlays via OS_METADATA_WRITABLE, which a Studio/design environment enables. When it isn't permitted, the backend's clear "not_overridable" error surfaces in the form. Verified live (showcase_account, OS_METADATA_WRITABLE=object): + → form → publish writes the field to the env overlay (effective fields 16→18), the new column appears without a reload; non-latin labels slug to field_N. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ca4a795 commit 48f25e1

4 files changed

Lines changed: 222 additions & 5 deletions

File tree

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

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
import * as React from 'react';
1818
import { useParams, Link } from 'react-router-dom';
19-
import { SchemaRenderer } from '@object-ui/react';
19+
import { SchemaRenderer, useAdapter } from '@object-ui/react';
20+
import { GridFieldAuthoringProvider } from '@object-ui/components';
2021
import {
2122
Boxes,
2223
FileText,
@@ -587,14 +588,54 @@ function InterfacesPillar({ packageId }: { packageId: string }): React.ReactElem
587588
);
588589
}
589590

591+
const NEW_FIELD_TYPES: Array<{ value: string; label: string }> = [
592+
{ value: 'text', label: '文本' },
593+
{ value: 'textarea', label: '长文本' },
594+
{ value: 'number', label: '数字' },
595+
{ value: 'boolean', label: '勾选' },
596+
{ value: 'date', label: '日期' },
597+
{ value: 'datetime', label: '日期时间' },
598+
];
599+
600+
/** Slugify a label into a safe field name, unique against existing names. */
601+
function toFieldName(label: string, existing: string[]): string {
602+
const base =
603+
label
604+
.trim()
605+
.toLowerCase()
606+
.replace(/[^a-z0-9]+/g, '_')
607+
.replace(/^_+|_+$/g, '') || `field_${existing.length + 1}`;
608+
let name = base;
609+
let i = 2;
610+
while (existing.includes(name)) name = `${base}_${i++}`;
611+
return name;
612+
}
613+
614+
/** Append a field to an object body, honoring keyed-map or array `fields`. */
615+
function appendField(
616+
body: Record<string, unknown>,
617+
field: { name: string; type: string; label: string },
618+
): Record<string, unknown> {
619+
const raw = body.fields;
620+
if (Array.isArray(raw)) return { ...body, fields: [...raw, field] };
621+
return { ...body, fields: { ...((raw as object) ?? {}), [field.name]: field } };
622+
}
623+
590624
/** Data pillar — the package's objects: list → fields + record grid. */
591625
function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
592626
const client = useMetadataClient();
627+
const adapter = useAdapter();
593628
const [objects, setObjects] = React.useState<Surface[]>([]);
594629
const [current, setCurrent] = React.useState<Surface | null>(null);
595630
const [obj, setObj] = React.useState<Record<string, unknown> | null>(null);
596631
const [loading, setLoading] = React.useState(false);
597632
const [error, setError] = React.useState<string | null>(null);
633+
// add-field (Airtable-style "+" on the grid column header)
634+
const [addOpen, setAddOpen] = React.useState(false);
635+
const [fLabel, setFLabel] = React.useState('');
636+
const [fType, setFType] = React.useState('text');
637+
const [saving, setSaving] = React.useState(false);
638+
const [gridVer, setGridVer] = React.useState(0);
598639

599640
React.useEffect(() => {
600641
let cancelled = false;
@@ -646,6 +687,34 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
646687
return [];
647688
}, [obj]);
648689

690+
const doAddField = React.useCallback(async () => {
691+
if (!current || !obj || !fLabel.trim()) return;
692+
const name = toFieldName(
693+
fLabel,
694+
fields.map((f) => f.name),
695+
);
696+
const body = appendField(obj, { name, type: fType, label: fLabel.trim() });
697+
setSaving(true);
698+
setError(null);
699+
try {
700+
await client.save('object', current.name, body, { mode: 'draft' });
701+
await client.publish('object', current.name);
702+
// Bust the data-layer object-schema cache so the remounted grid re-fetches
703+
// the new column without a full page reload (the grid reads its columns from
704+
// dataSource.getObjectSchema, a separate cache from the metadata client).
705+
(adapter as { clearCache?: () => void } | null)?.clearCache?.();
706+
setObj(body); // optimistic
707+
setGridVer((v) => v + 1);
708+
setAddOpen(false);
709+
setFLabel('');
710+
setFType('text');
711+
} catch (e) {
712+
setError(e instanceof Error ? e.message : String(e));
713+
} finally {
714+
setSaving(false);
715+
}
716+
}, [adapter, client, current, obj, fLabel, fType, fields]);
717+
649718
return (
650719
<div className="flex h-full">
651720
<nav className="w-52 shrink-0 overflow-auto border-r p-2">
@@ -680,13 +749,80 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
680749
<span className="text-[11px] text-muted-foreground">{fields.length} 字段</span>
681750
</div>
682751
{/* Data mode = the records themselves, as a directly-viewable grid
683-
* (Airtable parity). Fields are the columns — no separate table. */}
752+
* (Airtable parity). Fields are the columns; the trailing "+" column
753+
* header (via GridFieldAuthoringProvider) adds a new field. */}
684754
<div className="min-h-0 flex-1 overflow-auto rounded-lg border bg-background">
685-
<SchemaRenderer schema={{ type: 'object-view', objectName: current.name } as never} />
755+
<GridFieldAuthoringProvider
756+
value={{ onAddColumn: () => setAddOpen(true), addColumnLabel: '添加字段' }}
757+
>
758+
<SchemaRenderer
759+
key={`${current.name}:${gridVer}`}
760+
schema={{ type: 'object-view', objectName: current.name } as never}
761+
/>
762+
</GridFieldAuthoringProvider>
686763
</div>
687764
</>
688765
)}
689766
</main>
767+
{addOpen && (
768+
<div
769+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
770+
onClick={() => !saving && setAddOpen(false)}
771+
>
772+
<div
773+
className="w-80 rounded-lg border bg-background p-4 shadow-lg"
774+
onClick={(e) => e.stopPropagation()}
775+
>
776+
<p className="mb-3 text-sm font-medium">添加字段 · {current?.label}</p>
777+
{error && (
778+
<div className="mb-2 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
779+
{error}
780+
</div>
781+
)}
782+
<label className="mb-1 block text-[11px] text-muted-foreground">字段名称</label>
783+
<input
784+
autoFocus
785+
value={fLabel}
786+
onChange={(e) => setFLabel(e.target.value)}
787+
onKeyDown={(e) => {
788+
if (e.key === 'Enter' && fLabel.trim() && !saving) doAddField();
789+
if (e.key === 'Escape') setAddOpen(false);
790+
}}
791+
placeholder="例如 备注"
792+
className="mb-3 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
793+
/>
794+
<label className="mb-1 block text-[11px] text-muted-foreground">类型</label>
795+
<select
796+
value={fType}
797+
onChange={(e) => setFType(e.target.value)}
798+
className="mb-4 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
799+
>
800+
{NEW_FIELD_TYPES.map((ft) => (
801+
<option key={ft.value} value={ft.value}>
802+
{ft.label}
803+
</option>
804+
))}
805+
</select>
806+
<div className="flex justify-end gap-2">
807+
<button
808+
onClick={() => setAddOpen(false)}
809+
disabled={saving}
810+
className="rounded-md border px-3 py-1 text-xs hover:bg-muted disabled:opacity-50"
811+
>
812+
取消
813+
</button>
814+
<button
815+
onClick={doAddField}
816+
disabled={!fLabel.trim() || saving}
817+
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"
818+
>
819+
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
820+
添加并发布
821+
</button>
822+
</div>
823+
</div>
824+
</div>
825+
)}
690826
</div>
691827
);
692828
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* GridFieldAuthoring — an ambient, opt-in affordance that lets a *design*
11+
* surface (Studio) add an "+ add field" control to a data-table's column
12+
* header without coupling the runtime table renderer to design concerns.
13+
*
14+
* The data-table reads this context via {@link useGridFieldAuthoring}. With no
15+
* provider it returns `null`, so every runtime table renders byte-identically —
16+
* the trailing "+" column only appears when a host wraps the table in
17+
* {@link GridFieldAuthoringProvider} (e.g. the Studio Data pillar), which owns
18+
* the add-field form + metadata save/publish.
19+
*/
20+
21+
import React from 'react';
22+
23+
export interface GridFieldAuthoring {
24+
/** Invoked when the user clicks the trailing "+" add-column header affordance. */
25+
onAddColumn: () => void;
26+
/** Optional tooltip/aria-label for the add-column button (defaults to "Add field"). */
27+
addColumnLabel?: string;
28+
}
29+
30+
const GridFieldAuthoringContext = React.createContext<GridFieldAuthoring | null>(null);
31+
32+
export function GridFieldAuthoringProvider({
33+
value,
34+
children,
35+
}: {
36+
value: GridFieldAuthoring | null;
37+
children: React.ReactNode;
38+
}): React.ReactElement {
39+
return (
40+
<GridFieldAuthoringContext.Provider value={value}>{children}</GridFieldAuthoringContext.Provider>
41+
);
42+
}
43+
44+
/**
45+
* Read the ambient grid field-authoring affordances. Returns `null` outside a
46+
* provider — design surfaces opt in by wrapping the table tree in
47+
* {@link GridFieldAuthoringProvider}.
48+
*/
49+
export function useGridFieldAuthoring(): GridFieldAuthoring | null {
50+
return React.useContext(GridFieldAuthoringContext);
51+
}

packages/components/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export { getLazyIcon, LazyIcon, toKebabIconName } from './lib/lazy-icon';
4444
// Export placeholder registration
4545
export { registerPlaceholders } from './renderers/placeholders';
4646

47+
// Export grid field-authoring context — a design surface (Studio) opts into the
48+
// data-table "+ add field" column header by wrapping the table in the provider.
49+
export {
50+
GridFieldAuthoringProvider,
51+
useGridFieldAuthoring,
52+
type GridFieldAuthoring,
53+
} from './context/gridFieldAuthoring';
54+
4755
// Export raw Shadcn UI components
4856
export * from './ui';
4957
export * from './custom';

packages/components/src/renderers/complex/data-table.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// Enterprise-level DataTable Component (Airtable-like)
1010
import React, { useState, useMemo, useRef, useEffect } from 'react';
1111
import { cn } from '../../lib/utils';
12+
import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
1213
import { ComponentRegistry } from '@object-ui/core';
1314
import type { DataTableSchema } from '@object-ui/types';
1415
import { useObjectTranslation } from '@object-ui/react';
@@ -231,6 +232,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
231232
disableInnerScroll = false,
232233
} = schema;
233234

235+
// Ambient design-surface affordance: when a host (Studio) provides it, render
236+
// a trailing "+ add field" column header. `null` for every runtime table, so
237+
// existing tables render unchanged.
238+
const fieldAuthoring = useGridFieldAuthoring();
239+
const addColumnEnabled = !!fieldAuthoring?.onAddColumn;
240+
234241
// i18n support for pagination labels
235242
const { t, language } = useTableTranslation();
236243

@@ -955,13 +962,27 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
955962
{rowActions && (
956963
<TableHead className="w-24 text-right bg-background">{t('common.actions')}</TableHead>
957964
)}
965+
{addColumnEnabled && (
966+
<TableHead className="w-10 bg-background px-1 text-center">
967+
<button
968+
type="button"
969+
onClick={fieldAuthoring!.onAddColumn}
970+
title={fieldAuthoring!.addColumnLabel ?? 'Add field'}
971+
aria-label={fieldAuthoring!.addColumnLabel ?? 'Add field'}
972+
data-testid="grid-add-column"
973+
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
974+
>
975+
<Plus className="h-4 w-4" />
976+
</button>
977+
</TableHead>
978+
)}
958979
</TableRow>
959980
</TableHeader>
960981
<TableBody>
961982
{paginatedData.length === 0 ? (
962983
<TableRow className="hover:bg-transparent">
963984
<TableCell
964-
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
985+
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0) + (addColumnEnabled ? 1 : 0)}
965986
className="h-48 text-center text-muted-foreground border-0"
966987
>
967988
<div className="flex flex-col items-center justify-center gap-3">
@@ -1284,6 +1305,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
12841305
</div>
12851306
</TableCell>
12861307
)}
1308+
{addColumnEnabled && <TableCell aria-hidden className="w-10" />}
12871309
</TableRow>
12881310
);
12891311
})}
@@ -1295,7 +1317,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
12951317
onClick={() => schema.onAddRecord?.()}
12961318
>
12971319
<TableCell
1298-
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
1320+
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0) + (addColumnEnabled ? 1 : 0)}
12991321
className="h-9 px-3 py-1.5"
13001322
>
13011323
<span className="flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground transition-colors">

0 commit comments

Comments
 (0)