Skip to content

Commit be5563d

Browse files
os-zhuangclaude
andauthored
refactor(grid): inline cell editor reuses the @object-ui/fields widgets (#2100)
Per review, the inline editor should not hand-roll controls — the system already has dedicated field widgets (the form renders through them). Replace the hand-rolled select/checkbox from #2097 with the real widgets: - @object-ui/fields: add FieldEditWidget — a type→widget resolver rendering the dedicated widget (SelectField/BooleanField/DateField/MultiSelect/…) as a controlled {value,onChange,field} input, + hasFieldEditWidget + DISCRETE_EDIT_TYPES. - data-table: add a renderCellEditor injection point and drop the hand-rolled select/checkbox; stageEdit stages into pendingChanges without closing (multi-value/free-text). The component layer stays free of @object-ui/fields (which depends on it). - ObjectGrid (depends on @object-ui/fields): provide renderCellEditor rendering FieldEditWidget by field type — discrete pickers commit-on-choose, others stage. Same controls as the form. - Studio: wrap the grid in SchemaRendererProvider so the object-grid has a write-capable dataSource (the ListView only fetches + passes data inline). Verified: 行业 select cell edits via the dedicated SelectField dropdown; data-API write persists (admin, 200); type-check + lint clean on all four packages. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9cf3f47 commit be5563d

5 files changed

Lines changed: 190 additions & 65 deletions

File tree

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

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import * as React from 'react';
1818
import { useParams, Link } from 'react-router-dom';
19-
import { SchemaRenderer, useAdapter } from '@object-ui/react';
19+
import { SchemaRenderer, useAdapter, SchemaRendererProvider } from '@object-ui/react';
2020
import { GridFieldAuthoringProvider } from '@object-ui/components';
2121
import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
2222
import { ListView } from '@object-ui/plugin-list';
@@ -934,26 +934,31 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
934934
onReorderFields: doReorderFields,
935935
}}
936936
>
937-
<PluginObjectView
938-
key={`${current.name}:${gridVer}`}
939-
schema={
940-
{
941-
type: 'object-view',
942-
objectName: current.name,
943-
// No saved view exists in design mode, so show the object's
944-
// own fields as columns (in metadata order), dropping
945-
// framework-managed/audit fields so the grid opens on the
946-
// meaningful columns first — the way Airtable does.
947-
table: {
948-
fields: readFields(objDraft.fields)
949-
.entries.map((e) => e.name)
950-
.filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
951-
},
952-
} as never
953-
}
954-
dataSource={adapter as never}
955-
renderListView={renderStudioGridList}
956-
/>
937+
{/* Provide the adapter as the dataSource context so the object-grid's
938+
* inline-edit save can write back: the ListView only fetches and
939+
* passes data inline, leaving the grid itself without a write dataSource. */}
940+
<SchemaRendererProvider dataSource={adapter as never}>
941+
<PluginObjectView
942+
key={`${current.name}:${gridVer}`}
943+
schema={
944+
{
945+
type: 'object-view',
946+
objectName: current.name,
947+
// No saved view exists in design mode, so show the object's
948+
// own fields as columns (in metadata order), dropping
949+
// framework-managed/audit fields so the grid opens on the
950+
// meaningful columns first — the way Airtable does.
951+
table: {
952+
fields: readFields(objDraft.fields)
953+
.entries.map((e) => e.name)
954+
.filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
955+
},
956+
} as never
957+
}
958+
dataSource={adapter as never}
959+
renderListView={renderStudioGridList}
960+
/>
961+
</SchemaRendererProvider>
957962
</GridFieldAuthoringProvider>
958963
</div>
959964
<p className="mt-2 flex items-center gap-1 text-[11px] text-muted-foreground">

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

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,23 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
662662
setEditValue('');
663663
};
664664

665+
// Stage an in-flight edit into pendingChanges WITHOUT closing the editor —
666+
// used by injected widget editors (multi-value pickers, free text) that
667+
// commit when the user moves on rather than on each keystroke/toggle. Mirrors
668+
// what saveEdit stages, minus the close.
669+
const stageEdit = (value: any) => {
670+
if (!editingCell) return;
671+
const { rowIndex, columnKey } = editingCell;
672+
setEditValue(value);
673+
setPendingChanges((prev) => {
674+
const next = new Map(prev);
675+
const rowChanges = { ...(next.get(rowIndex) || {}) };
676+
rowChanges[columnKey] = value;
677+
next.set(rowIndex, rowChanges);
678+
return next;
679+
});
680+
};
681+
665682
// Commit the in-flight edit when the input loses focus (e.g. the user clicks
666683
// another cell). Without this, switching cells discards the typed value.
667684
const handleEditBlur = () => {
@@ -1190,6 +1207,34 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
11901207
// readable switch that's easy to extend.
11911208
const editType = (col as any).type as string | undefined;
11921209

1210+
// Host-injected editor: a higher layer (ObjectGrid) renders
1211+
// the dedicated @object-ui/fields widget for this field's
1212+
// type — the SAME control the form uses — so we don't
1213+
// re-implement select/boolean/etc. down here in the
1214+
// (fields-free) component layer. Returning null means "no
1215+
// widget for this type" → fall through to the built-ins.
1216+
const injectEditor = (schema as any).renderCellEditor as
1217+
| ((ctx: {
1218+
column: any;
1219+
row: any;
1220+
value: any;
1221+
stage: (v: any) => void;
1222+
commit: (v?: any) => void;
1223+
cancel: () => void;
1224+
}) => React.ReactNode)
1225+
| undefined;
1226+
if (typeof injectEditor === 'function') {
1227+
const node = injectEditor({
1228+
column: col,
1229+
row,
1230+
value: editValue,
1231+
stage: stageEdit,
1232+
commit: (v?: any) => saveEdit(true, v),
1233+
cancel: cancelEdit,
1234+
});
1235+
if (node != null) return node;
1236+
}
1237+
11931238
if (editType === 'date') {
11941239
return (
11951240
<Input
@@ -1241,50 +1286,13 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
12411286
);
12421287
}
12431288

1244-
// Select / single-choice: a dropdown of the field's options
1245-
// (forwarded as `col.options` from ObjectGrid), matching the
1246-
// form's select control. Opens immediately and commits on pick.
1247-
const editOptions = (col as any).options as
1248-
| Array<{ value: unknown; label: string }>
1249-
| undefined;
1250-
if (
1251-
(editType === 'select' || editType === 'status') &&
1252-
Array.isArray(editOptions) &&
1253-
editOptions.length > 0
1254-
) {
1255-
return (
1256-
<Select
1257-
defaultOpen
1258-
value={editValue == null ? '' : String(editValue)}
1259-
onValueChange={(v) => saveEdit(true, v)}
1260-
>
1261-
<SelectTrigger className="h-8 px-2 py-1">
1262-
<SelectValue placeholder="—" />
1263-
</SelectTrigger>
1264-
<SelectContent>
1265-
{editOptions.map((o) => (
1266-
<SelectItem key={String(o.value)} value={String(o.value)}>
1267-
{o.label}
1268-
</SelectItem>
1269-
))}
1270-
</SelectContent>
1271-
</Select>
1272-
);
1273-
}
1274-
1275-
// Boolean: a checkbox, like the form's boolean control.
1276-
if (editType === 'boolean') {
1277-
return (
1278-
<div className="flex h-8 items-center px-2">
1279-
<Checkbox
1280-
checked={editValue === true || editValue === 'true' || editValue === 1}
1281-
onCheckedChange={(c) => saveEdit(true, !!c)}
1282-
/>
1283-
</div>
1284-
);
1285-
}
1289+
// Select / boolean / multi-select / etc. are NOT hand-rolled
1290+
// here — the host (ObjectGrid) provides them via
1291+
// `renderCellEditor` using the dedicated @object-ui/fields
1292+
// widgets, so they exactly match the form's controls.
12861293

1287-
// Fallback: plain text input (original behavior).
1294+
// Fallback: plain text input (when no host editor matched and
1295+
// the type isn't date/datetime/number).
12881296
return (
12891297
<Input
12901298
ref={editInputRef}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
import React from 'react';
10+
import type { FieldWidgetProps } from './widgets/types';
11+
12+
// The SAME dedicated widgets the form renders — reused for in-place editing
13+
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
14+
// boolean as a checkbox, a date as a date picker, etc. — never a bare text box.
15+
import { TextField } from './widgets/TextField';
16+
import { TextAreaField } from './widgets/TextAreaField';
17+
import { NumberField } from './widgets/NumberField';
18+
import { CurrencyField } from './widgets/CurrencyField';
19+
import { PercentField } from './widgets/PercentField';
20+
import { SliderField } from './widgets/SliderField';
21+
import { RatingField } from './widgets/RatingField';
22+
import { BooleanField } from './widgets/BooleanField';
23+
import { SelectField } from './widgets/SelectField';
24+
import { MultiSelectField } from './widgets/MultiSelectField';
25+
import { RadioField } from './widgets/RadioField';
26+
import { CheckboxesField } from './widgets/CheckboxesField';
27+
import { TagsField } from './widgets/TagsField';
28+
import { DateField } from './widgets/DateField';
29+
import { DateTimeField } from './widgets/DateTimeField';
30+
import { TimeField } from './widgets/TimeField';
31+
import { EmailField } from './widgets/EmailField';
32+
import { PhoneField } from './widgets/PhoneField';
33+
import { UrlField } from './widgets/UrlField';
34+
35+
/**
36+
* Field types that edit in place with a dedicated widget. Keyed by the raw
37+
* field `type` (the widget map mirrors the form's `mapFieldTypeToFormType`).
38+
* Rich/heavy types (file, image, lookup, richtext, …) are intentionally absent
39+
* so callers fall back to their own simpler editor.
40+
*/
41+
const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetProps<any>>> = {
42+
text: TextField,
43+
textarea: TextAreaField,
44+
number: NumberField,
45+
currency: CurrencyField,
46+
percent: PercentField,
47+
slider: SliderField,
48+
progress: SliderField,
49+
rating: RatingField,
50+
boolean: BooleanField,
51+
toggle: BooleanField,
52+
select: SelectField,
53+
status: SelectField,
54+
multiselect: MultiSelectField,
55+
radio: RadioField,
56+
checkboxes: CheckboxesField,
57+
tags: TagsField,
58+
date: DateField,
59+
datetime: DateTimeField,
60+
time: TimeField,
61+
email: EmailField,
62+
phone: PhoneField,
63+
url: UrlField,
64+
};
65+
66+
/** Field types whose value is chosen in one discrete gesture (no free typing). */
67+
export const DISCRETE_EDIT_TYPES = new Set<string>([
68+
'boolean', 'toggle', 'select', 'status', 'radio', 'rating',
69+
]);
70+
71+
/** True when a field type has a dedicated in-place edit widget. */
72+
export function hasFieldEditWidget(type: string | undefined): boolean {
73+
return !!type && type in EDIT_WIDGETS;
74+
}
75+
76+
/**
77+
* Render the dedicated edit widget for a field's type — the SAME control the
78+
* form uses — as a controlled `{ value, onChange, field }` input. Returns
79+
* `null` for types without a registered widget so the caller can fall back to
80+
* a plain editor.
81+
*/
82+
export function FieldEditWidget({
83+
field,
84+
value,
85+
onChange,
86+
readonly,
87+
}: FieldWidgetProps<any>): React.ReactElement | null {
88+
const Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined;
89+
if (!Widget) return null;
90+
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} />;
91+
}

packages/fields/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,7 @@ export function registerFields() {
21302130
}
21312131

21322132
export * from './widgets/types';
2133+
export * from './FieldEditWidget';
21332134
export * from './widgets/TextField';
21342135
export * from './widgets/NumberField';
21352136
export * from './widgets/BooleanField';

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react';
2525
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
2626
import type { I18nLabel } from '@objectstack/spec/ui';
2727
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useObjectTranslation, useSafeFieldLabel } from '@object-ui/react';
28-
import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses } from '@object-ui/fields';
28+
import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES } from '@object-ui/fields';
2929
import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
3030
import {
3131
Badge, Button, NavigationOverlay, EmptyValue,
@@ -1693,6 +1693,26 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
16931693
resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
16941694
reorderableColumns: schema.reorderableColumns ?? false,
16951695
editable: schema.editable ?? false,
1696+
// In-place cell editor: render the dedicated @object-ui/fields widget for
1697+
// the field's type — the SAME control the form uses (select→dropdown,
1698+
// boolean→checkbox, date→date picker, multi-select, …). Returning null lets
1699+
// DataTable fall back to its built-in text/number/date inputs. Discrete
1700+
// pickers commit-and-close on choose; everything else stages and closes when
1701+
// the user moves on.
1702+
renderCellEditor: schema.editable
1703+
? (ctx: { column: any; value: any; stage: (v: any) => void; commit: (v?: any) => void }) => {
1704+
const fieldDef = (objectSchema as any)?.fields?.[ctx.column?.accessorKey];
1705+
if (!fieldDef || !hasFieldEditWidget(fieldDef.type)) return null;
1706+
const discrete = DISCRETE_EDIT_TYPES.has(fieldDef.type);
1707+
return (
1708+
<FieldEditWidget
1709+
field={{ name: ctx.column.accessorKey, ...fieldDef }}
1710+
value={ctx.value}
1711+
onChange={(v: any) => (discrete ? ctx.commit(v) : ctx.stage(v))}
1712+
/>
1713+
);
1714+
}
1715+
: undefined,
16961716
singleClickEdit: schema.singleClickEdit ?? true,
16971717
className: schema.className,
16981718
cellClassName: rowHeightMode === 'compact'

0 commit comments

Comments
 (0)