Skip to content

Commit 771cc35

Browse files
refactor: migrate object detail page to PageSchema-driven rendering
- Create self-contained schema widget components for object detail sections - Add PageSchema factory (buildObjectDetailPageSchema) for object type - Update MetadataDetailPage to render PageSchema via SchemaRenderer - Remove hasCustomPage redirect hack from MetadataDetailPage - Add SchemaErrorBoundary for graceful fallback on render failures - Add pageSchemaFactory to MetadataTypeConfig interface - Register custom widget types in ComponentRegistry - Update ObjectManagerPage to use PageSchema for detail view Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/0c570236-7df3-4c11-b21b-0a9ee3d5968a Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
1 parent 76df610 commit 771cc35

File tree

9 files changed

+749
-294
lines changed

9 files changed

+749
-294
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Object Field Designer Widget
3+
*
4+
* Self-contained SchemaNode widget that renders the FieldDesigner with full
5+
* CRUD interactivity (optimistic update → API → rollback). Registered as
6+
* `object-field-designer` in the ComponentRegistry.
7+
*
8+
* Schema: { type: 'object-field-designer', objectName: 'account' }
9+
*
10+
* @module components/schema/ObjectFieldDesignerWidget
11+
*/
12+
13+
import { useState, useCallback, useMemo, useRef } from 'react';
14+
import { Loader2, AlertCircle } from 'lucide-react';
15+
import { FieldDesigner } from '@object-ui/plugin-designer';
16+
import type { DesignerFieldDefinition } from '@object-ui/types';
17+
import type { SchemaNode } from '@object-ui/core';
18+
import { toast } from 'sonner';
19+
import { useMetadata } from '../../context/MetadataProvider';
20+
import { useMetadataService } from '../../hooks/useMetadataService';
21+
import { MetadataService } from '../../services/MetadataService';
22+
import { toFieldDefinition, type MetadataObject } from '../../utils/metadataConverters';
23+
24+
export function ObjectFieldDesignerWidget({ schema }: { schema: SchemaNode }) {
25+
const objectName = (schema as any).objectName as string;
26+
const { objects: metadataObjects, refresh } = useMetadata();
27+
const metadataService = useMetadataService();
28+
29+
// Resolve raw metadata object
30+
const metadataObject: MetadataObject | undefined = useMemo(
31+
() => (metadataObjects || []).find((o: MetadataObject) => o.name === objectName),
32+
[metadataObjects, objectName],
33+
);
34+
35+
// Convert raw fields to DesignerFieldDefinition[]
36+
const fields = useMemo(() => {
37+
if (!metadataObject) return [];
38+
const raw = Array.isArray(metadataObject.fields)
39+
? metadataObject.fields
40+
: Object.values(metadataObject.fields || {});
41+
return raw.map(toFieldDefinition);
42+
}, [metadataObject]);
43+
44+
// Local state for optimistic updates
45+
const [localFields, setLocalFields] = useState<DesignerFieldDefinition[] | null>(null);
46+
const [saving, setSaving] = useState(false);
47+
const displayFields = localFields ?? fields;
48+
const prevFieldsRef = useRef<DesignerFieldDefinition[]>(displayFields);
49+
50+
const handleFieldsChange = useCallback(
51+
async (updated: DesignerFieldDefinition[]) => {
52+
const previous = prevFieldsRef.current;
53+
54+
// Optimistic update
55+
setLocalFields(updated);
56+
prevFieldsRef.current = updated;
57+
58+
if (!metadataService) {
59+
toast.error('Service unavailable — changes saved locally only');
60+
return;
61+
}
62+
63+
const diff = MetadataService.diffFields(previous, updated);
64+
const actionLabel = diff
65+
? diff.type === 'create'
66+
? `Field "${diff.field.label || diff.field.name}" created`
67+
: diff.type === 'update'
68+
? `Field "${diff.field.label || diff.field.name}" updated`
69+
: `Field "${diff.field.label || diff.field.name}" deleted`
70+
: 'Field configuration updated';
71+
72+
setSaving(true);
73+
try {
74+
await metadataService.saveFields(objectName, updated);
75+
await refresh();
76+
toast.success(actionLabel);
77+
} catch (err: any) {
78+
// Rollback on failure
79+
setLocalFields(previous);
80+
prevFieldsRef.current = previous;
81+
toast.error(err?.message || 'Failed to save field changes');
82+
} finally {
83+
setSaving(false);
84+
}
85+
},
86+
[metadataService, objectName, refresh],
87+
);
88+
89+
return (
90+
<div className="space-y-3" data-testid="field-management-section">
91+
{saving && (
92+
<div className="flex items-center gap-2 text-sm text-muted-foreground" data-testid="field-saving-indicator">
93+
<Loader2 className="h-4 w-4 animate-spin" />
94+
Saving field changes…
95+
</div>
96+
)}
97+
{displayFields.some((f) => f.isSystem) && (
98+
<div
99+
className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2"
100+
data-testid="system-field-hint"
101+
>
102+
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
103+
System fields (e.g. id, createdAt, updatedAt) are read-only and cannot be edited or deleted.
104+
</div>
105+
)}
106+
<FieldDesigner
107+
objectName={objectName}
108+
fields={displayFields}
109+
onFieldsChange={handleFieldsChange}
110+
/>
111+
</div>
112+
);
113+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* Object Detail Widgets
3+
*
4+
* Self-contained, schema-driven widget components for the object detail page.
5+
* Each widget receives an `objectName` from its schema props and looks up
6+
* data from React context (MetadataProvider) — no prop drilling needed.
7+
*
8+
* Registered in the ComponentRegistry so they can be composed via PageSchema
9+
* and rendered by SchemaRenderer.
10+
*
11+
* @module components/schema/objectDetailWidgets
12+
*/
13+
14+
import { useMemo } from 'react';
15+
import { Badge } from '@object-ui/components';
16+
import {
17+
Settings2,
18+
Link2,
19+
KeyRound,
20+
LayoutList,
21+
PanelTop,
22+
BarChart3,
23+
Table,
24+
} from 'lucide-react';
25+
import type { SchemaNode } from '@object-ui/core';
26+
import { useMetadata } from '../../context/MetadataProvider';
27+
import { toObjectDefinition, toFieldDefinition, type MetadataObject } from '../../utils/metadataConverters';
28+
29+
// ---------------------------------------------------------------------------
30+
// Shared hook: resolve object definition + fields from metadata context
31+
// ---------------------------------------------------------------------------
32+
33+
function useObjectData(objectName: string) {
34+
const { objects: metadataObjects } = useMetadata();
35+
36+
const metadataObject: MetadataObject | undefined = useMemo(
37+
() => (metadataObjects || []).find((o: MetadataObject) => o.name === objectName),
38+
[metadataObjects, objectName],
39+
);
40+
41+
const object = useMemo(
42+
() => (metadataObject ? toObjectDefinition(metadataObject) : null),
43+
[metadataObject],
44+
);
45+
46+
const fields = useMemo(() => {
47+
if (!metadataObject) return [];
48+
const raw = Array.isArray(metadataObject.fields)
49+
? metadataObject.fields
50+
: Object.values(metadataObject.fields || {});
51+
return raw.map(toFieldDefinition);
52+
}, [metadataObject]);
53+
54+
return { object, fields, metadataObject };
55+
}
56+
57+
// ---------------------------------------------------------------------------
58+
// ObjectPropertiesWidget
59+
// Schema: { type: 'object-properties', objectName: 'account' }
60+
// ---------------------------------------------------------------------------
61+
62+
export function ObjectPropertiesWidget({ schema }: { schema: SchemaNode }) {
63+
const objectName = (schema as any).objectName as string;
64+
const { object, fields } = useObjectData(objectName);
65+
66+
if (!object) return null;
67+
68+
return (
69+
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4" data-testid="object-properties">
70+
<h2 className="text-sm font-semibold flex items-center gap-2">
71+
<Settings2 className="h-4 w-4" />
72+
Object Properties
73+
</h2>
74+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
75+
<div>
76+
<span className="text-muted-foreground">API Name</span>
77+
<p className="font-mono text-xs mt-0.5">{object.name}</p>
78+
</div>
79+
<div>
80+
<span className="text-muted-foreground">Label</span>
81+
<p className="mt-0.5">{object.label}</p>
82+
</div>
83+
{object.pluralLabel && (
84+
<div>
85+
<span className="text-muted-foreground">Plural Label</span>
86+
<p className="mt-0.5">{object.pluralLabel}</p>
87+
</div>
88+
)}
89+
{object.group && (
90+
<div>
91+
<span className="text-muted-foreground">Group</span>
92+
<p className="mt-0.5">{object.group}</p>
93+
</div>
94+
)}
95+
<div>
96+
<span className="text-muted-foreground">Status</span>
97+
<p className="mt-0.5">
98+
<Badge variant={object.enabled !== false ? 'default' : 'secondary'}>
99+
{object.enabled !== false ? 'Enabled' : 'Disabled'}
100+
</Badge>
101+
</p>
102+
</div>
103+
<div className="flex items-center gap-2">
104+
<span className="text-muted-foreground">Fields</span>
105+
<Badge variant="outline">{object.fieldCount ?? fields.length}</Badge>
106+
</div>
107+
{object.isSystem && (
108+
<div>
109+
<span className="text-muted-foreground">Type</span>
110+
<p className="mt-0.5">
111+
<Badge variant="secondary">System Object</Badge>
112+
</p>
113+
</div>
114+
)}
115+
</div>
116+
</div>
117+
);
118+
}
119+
120+
// ---------------------------------------------------------------------------
121+
// ObjectRelationshipsWidget
122+
// Schema: { type: 'object-relationships', objectName: 'account' }
123+
// ---------------------------------------------------------------------------
124+
125+
export function ObjectRelationshipsWidget({ schema }: { schema: SchemaNode }) {
126+
const objectName = (schema as any).objectName as string;
127+
const { object } = useObjectData(objectName);
128+
129+
if (!object) return null;
130+
131+
return (
132+
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4" data-testid="relationships-section">
133+
<h2 className="text-sm font-semibold flex items-center gap-2">
134+
<Link2 className="h-4 w-4" />
135+
Relationships
136+
</h2>
137+
{object.relationships && object.relationships.length > 0 ? (
138+
<div className="space-y-2">
139+
{object.relationships.map((rel, i) => (
140+
<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-muted/40">
141+
<Badge variant="outline" className="text-xs shrink-0">
142+
{rel.type}
143+
</Badge>
144+
<div className="min-w-0 flex-1 text-sm">
145+
<span className="font-medium">{rel.label || rel.relatedObject}</span>
146+
{rel.label && rel.label !== rel.relatedObject && (
147+
<span className="text-muted-foreground ml-1">{rel.relatedObject}</span>
148+
)}
149+
{rel.foreignKey && (
150+
<span className="text-muted-foreground text-xs ml-2">(FK: {rel.foreignKey})</span>
151+
)}
152+
</div>
153+
</div>
154+
))}
155+
</div>
156+
) : (
157+
<p className="text-sm text-muted-foreground">No relationships defined for this object.</p>
158+
)}
159+
</div>
160+
);
161+
}
162+
163+
// ---------------------------------------------------------------------------
164+
// ObjectKeysWidget
165+
// Schema: { type: 'object-keys', objectName: 'account' }
166+
// ---------------------------------------------------------------------------
167+
168+
export function ObjectKeysWidget({ schema }: { schema: SchemaNode }) {
169+
const objectName = (schema as any).objectName as string;
170+
const { fields } = useObjectData(objectName);
171+
172+
const keyFields = useMemo(
173+
() => fields.filter((f) => f.unique || f.name === 'id' || f.externalId),
174+
[fields],
175+
);
176+
177+
return (
178+
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4" data-testid="keys-section">
179+
<h2 className="text-sm font-semibold flex items-center gap-2">
180+
<KeyRound className="h-4 w-4" />
181+
Keys
182+
</h2>
183+
{keyFields.length > 0 ? (
184+
<div className="space-y-2">
185+
{keyFields.map((kf) => (
186+
<div key={kf.name} className="flex items-center gap-3 p-2 rounded-md bg-muted/40">
187+
<Badge variant={kf.name === 'id' ? 'default' : 'outline'} className="text-xs shrink-0">
188+
{kf.name === 'id' ? 'Primary Key' : kf.externalId ? 'External ID' : 'Unique'}
189+
</Badge>
190+
<div className="min-w-0 flex-1 text-sm">
191+
<span className="font-medium">{kf.label || kf.name}</span>
192+
<span className="text-muted-foreground text-xs ml-2">({kf.type})</span>
193+
</div>
194+
</div>
195+
))}
196+
</div>
197+
) : (
198+
<p className="text-sm text-muted-foreground">No unique keys or primary keys found.</p>
199+
)}
200+
</div>
201+
);
202+
}
203+
204+
// ---------------------------------------------------------------------------
205+
// ObjectDataExperienceWidget
206+
// Schema: { type: 'object-data-experience', objectName: 'account' }
207+
// ---------------------------------------------------------------------------
208+
209+
export function ObjectDataExperienceWidget(_props: { schema: SchemaNode }) {
210+
return (
211+
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4" data-testid="data-experience-section">
212+
<h2 className="text-sm font-semibold flex items-center gap-2">
213+
<LayoutList className="h-4 w-4" />
214+
Data Experience
215+
</h2>
216+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
217+
<div className="rounded-md border border-dashed p-4 text-center" data-testid="data-experience-forms">
218+
<PanelTop className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
219+
<p className="text-sm font-medium">Forms</p>
220+
<p className="text-xs text-muted-foreground mt-1">Design forms for data entry</p>
221+
</div>
222+
<div className="rounded-md border border-dashed p-4 text-center" data-testid="data-experience-views">
223+
<LayoutList className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
224+
<p className="text-sm font-medium">Views</p>
225+
<p className="text-xs text-muted-foreground mt-1">Configure list and detail views</p>
226+
</div>
227+
<div className="rounded-md border border-dashed p-4 text-center" data-testid="data-experience-dashboards">
228+
<BarChart3 className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
229+
<p className="text-sm font-medium">Dashboards</p>
230+
<p className="text-xs text-muted-foreground mt-1">Build visual dashboards</p>
231+
</div>
232+
</div>
233+
</div>
234+
);
235+
}
236+
237+
// ---------------------------------------------------------------------------
238+
// ObjectDataPreviewWidget
239+
// Schema: { type: 'object-data-preview', objectName: 'account' }
240+
// ---------------------------------------------------------------------------
241+
242+
export function ObjectDataPreviewWidget({ schema }: { schema: SchemaNode }) {
243+
const objectName = (schema as any).objectName as string;
244+
const { object } = useObjectData(objectName);
245+
246+
return (
247+
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4" data-testid="data-preview-section">
248+
<h2 className="text-sm font-semibold flex items-center gap-2">
249+
<Table className="h-4 w-4" />
250+
Data Preview
251+
</h2>
252+
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
253+
<Table className="h-8 w-8 mx-auto mb-3 opacity-40" />
254+
<p className="text-sm font-medium">Sample Data</p>
255+
<p className="text-xs mt-1">
256+
Live data preview for &ldquo;{object?.label || objectName}&rdquo; will be available here
257+
</p>
258+
</div>
259+
</div>
260+
);
261+
}

0 commit comments

Comments
 (0)