Skip to content

Commit 6205fc4

Browse files
authored
Merge pull request #930 from objectstack-ai/copilot/optimize-opportunity-list-view
2 parents 942aab8 + 751f0bb commit 6205fc4

4 files changed

Lines changed: 455 additions & 16 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
11441144
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `DebugCollector` (perf/expr/event data collection, tree-shakeable), `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`; `SchemaRenderer` injects `data-debug-type`/`data-debug-id` attrs + reports render perf to `DebugCollector` when debug enabled. `@object-ui/components`: floating `DebugPanel` with 7 built-in tabs (Schema, Data, Perf, Expr, Events, Registry, Flags), plugin-extensible via `extraTabs`. Console `MetadataInspector` auto-opens when `?__debug` detected. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 48 new tests.
11451145
- [x] **P1: Chart Widget Server-Side Aggregation** — Fixed chart widgets (bar/line/area/pie/donut/scatter) downloading all raw data and aggregating client-side. Added optional `aggregate()` method to `DataSource` interface (`AggregateParams`, `AggregateResult` types) enabling server-side grouping/aggregation via analytics API (e.g. `GET /api/v1/analytics/{resource}?category=…&metric=…&agg=…`). `ObjectChart` now prefers `dataSource.aggregate()` when available, falling back to `dataSource.find()` + client-side aggregation for backward compatibility. Implemented `aggregate()` in `ValueDataSource` (in-memory), `ApiDataSource` (HTTP), and `ObjectStackAdapter` (analytics API with client-side fallback). Only detail widgets (grid/table/list) continue to fetch full data. 9 new tests.
11461146
- [x] **P1: Spec-Aligned CRM I18n** — Fixed CRM internationalization not taking effect on the console. Root cause: CRM metadata used plain string labels instead of spec-aligned `I18nLabel` objects. Fix: (1) Updated CRM app/dashboard/navigation metadata to use `I18nLabel` objects (`{ key, defaultValue }`) per spec. (2) Updated `NavigationItem` and `NavigationArea` types to support I18nLabel. (3) Added `resolveLabel()` helper in NavigationRenderer. (4) Updated `resolveI18nLabel()` to accept `t()` function for translation. (5) Added `loadLanguage` callback in I18nProvider for API-based translation loading. (6) Added `/api/v1/i18n/:lang` endpoint to mock server. Console contains zero CRM-specific code.
1147+
- [x] **P0: Opportunity List View & ObjectDef Column Enrichment** — Fixed ObjectGrid not using objectDef field metadata for type-aware rendering when columns are `string[]` or `ListColumn[]` without full options. (1) Schema resolution always fetches full schema from DataSource for field type metadata. (2) String[] column path enriched with objectDef types, options (with colors), currency, precision for proper CurrencyCellRenderer, SelectCellRenderer (colored badges), PercentCellRenderer, DateCellRenderer. (3) ListColumn[] fieldMeta deep-merged with objectDef field properties (select options with colors, currency code, precision). (4) Opportunity view columns upgraded from bare `string[]` to `ListColumn[]` with explicit types, alignment, and summary aggregation. 9 new tests.
11471148

11481149
### Ecosystem & Marketplace
11491150
- Plugin marketplace website with search, ratings, and install count

examples/crm/src/views/opportunity.view.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ export const OpportunityView = {
55
label: 'All Opportunities',
66
type: 'grid' as const,
77
data: { provider: 'object' as const, object: 'opportunity' },
8-
columns: ['name', 'amount', 'stage', 'close_date', 'probability', 'forecast_category'],
8+
columns: [
9+
{ field: 'name', label: 'Opportunity Name', link: true },
10+
{ field: 'amount', label: 'Amount', type: 'currency' as const, align: 'right' as const, summary: 'sum' as const },
11+
{ field: 'stage', label: 'Stage', type: 'select' as const },
12+
{ field: 'close_date', label: 'Close Date', type: 'date' as const },
13+
{ field: 'probability', label: 'Probability', type: 'percent' as const, align: 'right' as const, summary: 'avg' as const },
14+
{ field: 'forecast_category', label: 'Forecast Category', type: 'select' as const },
15+
],
916
sort: [{ field: 'close_date', order: 'asc' as const }],
1017
},
1118
pipeline: {
@@ -24,7 +31,13 @@ export const OpportunityView = {
2431
label: 'Closing This Month',
2532
type: 'grid' as const,
2633
data: { provider: 'object' as const, object: 'opportunity' },
27-
columns: ['name', 'amount', 'stage', 'close_date', 'probability'],
34+
columns: [
35+
{ field: 'name', label: 'Opportunity Name', link: true },
36+
{ field: 'amount', label: 'Amount', type: 'currency' as const, align: 'right' as const },
37+
{ field: 'stage', label: 'Stage', type: 'select' as const },
38+
{ field: 'close_date', label: 'Close Date', type: 'date' as const },
39+
{ field: 'probability', label: 'Probability', type: 'percent' as const, align: 'right' as const },
40+
],
2841
sort: [{ field: 'close_date', order: 'asc' as const }],
2942
},
3043
},

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -305,14 +305,14 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
305305
let resolvedSchema: any = null;
306306
const cols = normalizeColumns(schemaColumns) || schemaFields;
307307

308-
if (cols && objectName) {
309-
// We have explicit columns — use a minimal schema stub
310-
resolvedSchema = { name: objectName, fields: {} };
311-
} else if (objectName && dataSource) {
312-
// Fetch full schema from DataSource
308+
if (objectName && dataSource) {
309+
// Always fetch full schema for field type metadata (enables rich type-aware rendering)
313310
const schemaData = await dataSource.getObjectSchema(objectName);
314311
if (cancelled) return;
315312
resolvedSchema = schemaData;
313+
} else if (cols && objectName) {
314+
// Fallback: minimal schema stub when no dataSource available
315+
resolvedSchema = { name: objectName, fields: {} };
316316
} else if (!objectName) {
317317
throw new Error('Object name required for data fetching');
318318
} else {
@@ -595,14 +595,23 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
595595
// Build custom cell renderer based on column configuration
596596
let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined;
597597

598-
// Type-based cell renderer with auto-inference (e.g., "currency", "date", "boolean")
599-
const inferredType = inferColumnType(col);
598+
// Type-based cell renderer: explicit col type > objectDef type > heuristic inference
599+
const objectDefField = objectSchema?.fields?.[col.field];
600+
const inferredType = col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null;
600601
const CellRenderer = inferredType ? getCellRenderer(inferredType) : null;
601602

602-
// Build field metadata for cell renderers (includes options for select fields)
603+
// Build field metadata for cell renderers with objectDef enrichment
603604
const fieldMeta: Record<string, any> = { name: col.field, type: inferredType || 'text' };
604-
if (inferredType === 'select' && !(col as any).options) {
605-
// Auto-generate options from unique data values for inferred select fields
605+
// Merge objectDef field properties (options with colors, currency, precision, etc.)
606+
if (objectDefField) {
607+
if (objectDefField.label) fieldMeta.label = objectDefField.label;
608+
if (objectDefField.currency) fieldMeta.currency = objectDefField.currency;
609+
if (objectDefField.precision !== undefined) fieldMeta.precision = objectDefField.precision;
610+
if (objectDefField.format) fieldMeta.format = objectDefField.format;
611+
if (objectDefField.options) fieldMeta.options = objectDefField.options;
612+
}
613+
// Auto-generate options from data for inferred select without existing options
614+
if (inferredType === 'select' && !fieldMeta.options) {
606615
const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean)));
607616
fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) }));
608617
}
@@ -735,14 +744,76 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
735744
}
736745
}
737746

738-
// String array format - filter out invalid entries
747+
// String array format - enrich with objectDef field metadata for type-aware rendering
739748
return (cols as string[])
740749
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
741-
.map((fieldName) => {
742-
const fieldLabel = objectSchema?.fields?.[fieldName]?.label;
750+
.map((fieldName, colIndex) => {
751+
const fieldDef = objectSchema?.fields?.[fieldName];
752+
const fieldLabel = fieldDef?.label;
753+
const header = fieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
754+
755+
// Resolve type: objectDef type > heuristic inference (consistent with ListColumn path)
756+
const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
757+
const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
758+
759+
// Build field metadata with objectDef enrichment
760+
const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
761+
if (fieldDef) {
762+
if (fieldDef.label) fieldMeta.label = fieldDef.label;
763+
if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
764+
if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
765+
if (fieldDef.format) fieldMeta.format = fieldDef.format;
766+
if (fieldDef.options) fieldMeta.options = fieldDef.options;
767+
}
768+
// Auto-generate select options from data when no options defined
769+
if (resolvedType === 'select' && !fieldMeta.options) {
770+
const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
771+
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
772+
}
773+
774+
const numericTypes = ['number', 'currency', 'percent'];
775+
const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
776+
777+
// Auto-link primary field (first column) to record detail
778+
const isPrimaryField = colIndex === 0;
779+
let cellRenderer: ((value: any, row?: any) => React.ReactNode) | undefined;
780+
781+
if (isPrimaryField && CellRenderer) {
782+
cellRenderer = (value: any, row: any) => {
783+
const displayContent = <CellRenderer value={value} field={fieldMeta as any} />;
784+
return (
785+
<button
786+
type="button"
787+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
788+
data-testid="primary-field-link"
789+
onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
790+
>
791+
{displayContent}
792+
</button>
793+
);
794+
};
795+
} else if (isPrimaryField) {
796+
cellRenderer = (value: any, row: any) => (
797+
<button
798+
type="button"
799+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
800+
data-testid="primary-field-link"
801+
onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
802+
>
803+
{value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic"></span>}
804+
</button>
805+
);
806+
} else if (CellRenderer) {
807+
cellRenderer = (value: any) => <CellRenderer value={value} field={fieldMeta as any} />;
808+
}
809+
743810
return {
744-
header: fieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
811+
header,
745812
accessorKey: fieldName,
813+
...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
814+
...(inferredAlign && { align: inferredAlign }),
815+
...(cellRenderer && { cell: cellRenderer }),
816+
sortable: fieldDef?.sortable !== false,
746817
};
747818
});
748819
}

0 commit comments

Comments
 (0)