Skip to content

Commit d31049e

Browse files
authored
Merge pull request #939 from objectstack-ai/copilot/fix-inline-data-schema-loading
2 parents 3e4a0d8 + e2c3cb0 commit d31049e

File tree

2 files changed

+273
-4
lines changed

2 files changed

+273
-4
lines changed

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,34 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
287287
}
288288
}, [hasInlineData, dataConfig]);
289289

290+
// --- Inline data: still fetch objectSchema for type-aware rendering ---
291+
// When data is inline (provider: 'value'), we skip the data fetch but still need
292+
// the object schema to resolve field types (lookup, select, currency, etc.) and
293+
// enable proper CellRenderer selection.
294+
useEffect(() => {
295+
if (!hasInlineData) return;
296+
if (!objectName || !dataSource) return;
297+
298+
let cancelled = false;
299+
300+
const fetchSchema = async () => {
301+
try {
302+
const schemaData = await dataSource.getObjectSchema(objectName);
303+
if (!cancelled) {
304+
setObjectSchema(schemaData);
305+
}
306+
} catch (err) {
307+
// Schema fetch failure for inline data is non-fatal; columns will
308+
// still fall back to heuristic inference.
309+
console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
310+
}
311+
};
312+
313+
fetchSchema();
314+
315+
return () => { cancelled = true; };
316+
}, [hasInlineData, objectName, dataSource]);
317+
290318
// --- Unified async data loading effect ---
291319
// Combines schema fetch + data fetch into a single async flow with AbortController.
292320
// This avoids the fragile "chained effects" pattern where Effect 1 sets objectSchema,
@@ -823,10 +851,39 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
823851
const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
824852
if (inlineData.length > 0) {
825853
const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
826-
return fieldsToShow.map((fieldName) => ({
827-
header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
828-
accessorKey: fieldName,
829-
}));
854+
return fieldsToShow.map((fieldName) => {
855+
const fieldDef = objectSchema?.fields?.[fieldName];
856+
const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
857+
const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
858+
const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
859+
860+
// Build field metadata with objectDef enrichment
861+
const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
862+
if (fieldDef) {
863+
if (fieldDef.label) fieldMeta.label = fieldDef.label;
864+
if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
865+
if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
866+
if (fieldDef.format) fieldMeta.format = fieldDef.format;
867+
if (fieldDef.options) fieldMeta.options = fieldDef.options;
868+
}
869+
// Auto-generate select options from data when no options defined
870+
if (resolvedType === 'select' && !fieldMeta.options) {
871+
const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
872+
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
873+
}
874+
875+
const numericTypes = ['number', 'currency', 'percent'];
876+
const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
877+
878+
return {
879+
header,
880+
accessorKey: fieldName,
881+
...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
882+
...(inferredAlign && { align: inferredAlign }),
883+
...(CellRenderer && { cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} /> }),
884+
sortable: fieldDef?.sortable !== false,
885+
};
886+
});
830887
}
831888
}
832889

packages/plugin-grid/src/__tests__/objectdef-enrichment.test.tsx

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,215 @@ describe('ListColumn[] with DataSource objectDef merge', () => {
352352
expect(wonBadge).toHaveClass('bg-blue-100');
353353
});
354354
});
355+
356+
// =========================================================================
357+
// Inline data + DataSource: schema should be fetched for type-aware rendering
358+
// (Regression test for: inline data skipping objectSchema load)
359+
// =========================================================================
360+
describe('Inline data with DataSource schema fetch', () => {
361+
it('should fetch objectSchema even when data is inline (provider: value)', async () => {
362+
const mockDataSource = createMockDataSource(opportunitySchema, []);
363+
364+
const schema: any = {
365+
type: 'object-grid' as const,
366+
objectName: 'opportunity',
367+
data: { provider: 'value', items: opportunityData },
368+
columns: ['name', 'amount', 'stage', 'close_date', 'probability'],
369+
};
370+
371+
render(
372+
<ActionProvider>
373+
<ObjectGrid schema={schema} dataSource={mockDataSource} />
374+
</ActionProvider>
375+
);
376+
377+
// Schema should be fetched even with inline data
378+
await waitFor(() => {
379+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('opportunity');
380+
});
381+
382+
// Data should render
383+
await waitFor(() => {
384+
expect(screen.getByText('Enterprise License')).toBeInTheDocument();
385+
});
386+
387+
// Amount should be formatted as currency using objectSchema field type
388+
await waitFor(() => {
389+
expect(screen.getByText('$150,000.00')).toBeInTheDocument();
390+
});
391+
392+
// Stage should render with labels from objectSchema options
393+
await waitFor(() => {
394+
expect(screen.getByText('Closed Won')).toBeInTheDocument();
395+
});
396+
397+
// Stage badge should have color from objectSchema
398+
const closedWonBadge = screen.getByText('Closed Won');
399+
expect(closedWonBadge).toHaveClass('bg-green-100');
400+
401+
// find() should NOT have been called (data is inline)
402+
expect(mockDataSource.find).not.toHaveBeenCalled();
403+
});
404+
405+
it('should use objectSchema labels for column headers with inline data', async () => {
406+
const mockDataSource = createMockDataSource(opportunitySchema, []);
407+
408+
const schema: any = {
409+
type: 'object-grid' as const,
410+
objectName: 'opportunity',
411+
data: { provider: 'value', items: opportunityData },
412+
columns: ['name', 'amount', 'close_date'],
413+
};
414+
415+
render(
416+
<ActionProvider>
417+
<ObjectGrid schema={schema} dataSource={mockDataSource} />
418+
</ActionProvider>
419+
);
420+
421+
await waitFor(() => {
422+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('opportunity');
423+
});
424+
425+
// Headers should use objectSchema labels (not raw field names)
426+
await waitFor(() => {
427+
expect(screen.getByText('Opportunity Name')).toBeInTheDocument();
428+
});
429+
expect(screen.getByText('Amount')).toBeInTheDocument();
430+
expect(screen.getByText('Close Date')).toBeInTheDocument();
431+
});
432+
433+
it('should render lookup/select fields with CellRenderers when inline data + objectSchema', async () => {
434+
const lookupSchema = {
435+
name: 'order',
436+
fields: {
437+
name: { name: 'name', type: 'text', label: 'Order' },
438+
status: {
439+
name: 'status', type: 'select', label: 'Status',
440+
options: [
441+
{ value: 'pending', label: 'Pending', color: 'yellow' },
442+
{ value: 'shipped', label: 'Shipped', color: 'blue' },
443+
{ value: 'delivered', label: 'Delivered', color: 'green' },
444+
],
445+
},
446+
assigned_to: { name: 'assigned_to', type: 'lookup', label: 'Assigned To' },
447+
},
448+
};
449+
450+
const inlineOrderData = [
451+
{ _id: 'o1', name: 'Order 001', status: 'pending', assigned_to: 'Alice' },
452+
{ _id: 'o2', name: 'Order 002', status: 'shipped', assigned_to: 'Bob' },
453+
{ _id: 'o3', name: 'Order 003', status: 'delivered', assigned_to: 'Charlie' },
454+
];
455+
456+
const mockDataSource = createMockDataSource(lookupSchema, []);
457+
458+
const schema: any = {
459+
type: 'object-grid' as const,
460+
objectName: 'order',
461+
data: { provider: 'value', items: inlineOrderData },
462+
columns: ['name', 'status', 'assigned_to'],
463+
};
464+
465+
render(
466+
<ActionProvider>
467+
<ObjectGrid schema={schema} dataSource={mockDataSource} />
468+
</ActionProvider>
469+
);
470+
471+
// Schema should be fetched
472+
await waitFor(() => {
473+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order');
474+
});
475+
476+
// Status should render with select renderer (colored badges)
477+
await waitFor(() => {
478+
expect(screen.getByText('Pending')).toBeInTheDocument();
479+
});
480+
481+
const pendingBadge = screen.getByText('Pending');
482+
expect(pendingBadge).toHaveClass('bg-yellow-100');
483+
484+
const shippedBadge = screen.getByText('Shipped');
485+
expect(shippedBadge).toHaveClass('bg-blue-100');
486+
487+
// find() should NOT be called
488+
expect(mockDataSource.find).not.toHaveBeenCalled();
489+
});
490+
491+
it('should enrich legacy inline data fallback (no columns) with objectSchema', async () => {
492+
const mockDataSource = createMockDataSource(opportunitySchema, []);
493+
494+
const schema: any = {
495+
type: 'object-grid' as const,
496+
objectName: 'opportunity',
497+
data: { provider: 'value', items: opportunityData },
498+
// No columns specified — should auto-derive from data keys + objectSchema
499+
};
500+
501+
render(
502+
<ActionProvider>
503+
<ObjectGrid schema={schema} dataSource={mockDataSource} />
504+
</ActionProvider>
505+
);
506+
507+
// Schema should still be fetched
508+
await waitFor(() => {
509+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('opportunity');
510+
});
511+
512+
// Data should render with objectSchema-enriched columns
513+
await waitFor(() => {
514+
expect(screen.getByText('Enterprise License')).toBeInTheDocument();
515+
});
516+
517+
// Amount should be formatted (objectSchema type: currency)
518+
await waitFor(() => {
519+
expect(screen.getByText('$150,000.00')).toBeInTheDocument();
520+
});
521+
522+
// find() should NOT be called
523+
expect(mockDataSource.find).not.toHaveBeenCalled();
524+
});
525+
526+
it('should enrich ListColumn[] with objectSchema types when inline data + dataSource', async () => {
527+
const mockDataSource = createMockDataSource(opportunitySchema, []);
528+
529+
const schema: any = {
530+
type: 'object-grid' as const,
531+
objectName: 'opportunity',
532+
data: { provider: 'value', items: opportunityData },
533+
// ListColumn[] without explicit type — objectSchema should provide types
534+
columns: [
535+
{ field: 'name', label: 'Name' },
536+
{ field: 'stage', label: 'Stage' },
537+
{ field: 'amount', label: 'Amount' },
538+
],
539+
};
540+
541+
render(
542+
<ActionProvider>
543+
<ObjectGrid schema={schema} dataSource={mockDataSource} />
544+
</ActionProvider>
545+
);
546+
547+
// Schema should be fetched
548+
await waitFor(() => {
549+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('opportunity');
550+
});
551+
552+
// Stage should render with colored badge from objectSchema select options
553+
await waitFor(() => {
554+
const closedWonBadge = screen.getByText('Closed Won');
555+
expect(closedWonBadge).toHaveClass('bg-green-100');
556+
});
557+
558+
// Amount should be formatted as currency from objectSchema type
559+
await waitFor(() => {
560+
expect(screen.getByText('$150,000.00')).toBeInTheDocument();
561+
});
562+
563+
// find() should NOT be called
564+
expect(mockDataSource.find).not.toHaveBeenCalled();
565+
});
566+
});

0 commit comments

Comments
 (0)