Skip to content

Commit a8392ef

Browse files
authored
Merge pull request #487 from objectstack-ai/copilot/optimize-list-view-formatting
2 parents 5733052 + 01d108c commit a8392ef

4 files changed

Lines changed: 107 additions & 19 deletions

File tree

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
601601
return (
602602
<TableHead
603603
key={col.accessorKey}
604-
className={`${col.className || ''} ${sortable && col.sortable !== false ? 'cursor-pointer select-none' : ''} ${isDragging ? 'opacity-50' : ''} ${isDragOver ? 'border-l-2 border-primary' : ''} relative group bg-background`}
604+
className={cn(
605+
col.className,
606+
sortable && col.sortable !== false && 'cursor-pointer select-none',
607+
isDragging && 'opacity-50',
608+
isDragOver && 'border-l-2 border-primary',
609+
col.align === 'right' && 'text-right',
610+
col.align === 'center' && 'text-center',
611+
'relative group bg-background'
612+
)}
605613
style={{
606614
width: columnWidth,
607615
minWidth: columnWidth
@@ -613,7 +621,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
613621
onDragEnd={handleColumnDragEnd}
614622
onClick={() => sortable && col.sortable !== false && handleSort(col.accessorKey)}
615623
>
616-
<div className="flex items-center justify-between">
624+
<div className={cn(
625+
"flex items-center",
626+
col.align === 'right' ? 'justify-end' : 'justify-between'
627+
)}>
617628
<div className="flex items-center gap-1">
618629
{reorderableColumns && (
619630
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
@@ -701,6 +712,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
701712
key={colIndex}
702713
className={cn(
703714
col.cellClassName,
715+
col.align === 'right' && 'text-right',
716+
col.align === 'center' && 'text-center',
704717
isEditable && !isEditing && "cursor-text hover:bg-muted/50",
705718
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400"
706719
)}

packages/fields/src/index.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ export function formatCurrency(value: number, currency: string = 'USD'): string
7676

7777
/**
7878
* Format percent value
79+
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
7980
*/
80-
export function formatPercent(value: number, precision: number = 2): string {
81-
return `${(value * 100).toFixed(precision)}%`;
81+
export function formatPercent(value: number, precision: number = 0): string {
82+
// If value is between -1 and 1 (exclusive), treat as decimal (e.g. 0.8 → 80%)
83+
const displayValue = (value > -1 && value < 1) ? value * 100 : value;
84+
return `${displayValue.toFixed(precision)}%`;
8285
}
8386

8487
/**
@@ -125,11 +128,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
125128
* Number field cell renderer
126129
*/
127130
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
128-
if (value == null) return <span>-</span>;
131+
if (value == null) return <span className="text-muted-foreground">-</span>;
129132

130133
const numField = field as any;
131134
const precision = numField.precision ?? 0;
132-
const formatted = typeof value === 'number' ? value.toFixed(precision) : value;
135+
const formatted = typeof value === 'number'
136+
? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value)
137+
: value;
133138

134139
return <span className="tabular-nums">{formatted}</span>;
135140
}
@@ -138,26 +143,46 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
138143
* Currency field cell renderer
139144
*/
140145
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
141-
if (value == null) return <span>-</span>;
146+
if (value == null) return <span className="text-muted-foreground">-</span>;
142147

143148
const currencyField = field as any;
144149
const currency = currencyField.currency || 'USD';
145150
const formatted = formatCurrency(Number(value), currency);
146151

147-
return <span className="tabular-nums font-medium">{formatted}</span>;
152+
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
148153
}
149154

150155
/**
151-
* Percent field cell renderer
156+
* Percent field cell renderer with mini progress bar
152157
*/
153158
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
154-
if (value == null) return <span>-</span>;
159+
if (value == null) return <span className="text-muted-foreground">-</span>;
155160

156161
const percentField = field as any;
157-
const precision = percentField.precision ?? 2;
158-
const formatted = formatPercent(Number(value), precision);
162+
const precision = percentField.precision ?? 0;
163+
const numValue = Number(value);
164+
const formatted = formatPercent(numValue, precision);
165+
// Normalize to 0-100 range for progress bar
166+
const barValue = (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
167+
const clampedBar = Math.max(0, Math.min(100, barValue));
159168

160-
return <span className="tabular-nums">{formatted}</span>;
169+
return (
170+
<div className="flex items-center gap-2">
171+
<div
172+
className="h-1.5 w-16 rounded-full bg-muted overflow-hidden flex-shrink-0"
173+
role="progressbar"
174+
aria-valuenow={clampedBar}
175+
aria-valuemin={0}
176+
aria-valuemax={100}
177+
>
178+
<div
179+
className="h-full rounded-full bg-primary transition-all"
180+
style={{ width: `${clampedBar}%` }}
181+
/>
182+
</div>
183+
<span className="tabular-nums whitespace-nowrap">{formatted}</span>
184+
</div>
185+
);
161186
}
162187

163188
/**

packages/plugin-grid/src/ObjectGrid.stories.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,43 @@ export const WithRowActions: Story = {
7979
} as any,
8080
};
8181

82+
/**
83+
* CRM Deals Pipeline — demonstrates professional data formatting:
84+
* - Currency with thousand separators (Amount column, right-aligned)
85+
* - Percentage with progress bar (Probability column, right-aligned)
86+
* - Formatted dates (Close Date column)
87+
* - Colored badges for stage/status (Stage column)
88+
* - Bold clickable name as primary link (Name column)
89+
* - Empty value placeholder (Account column)
90+
*/
91+
export const CRMDeals: Story = {
92+
name: 'CRM Deals Pipeline',
93+
render: renderStory,
94+
args: {
95+
type: 'object-grid',
96+
objectName: 'Deal',
97+
columns: [
98+
{ field: 'name', label: 'Name', link: true, sortable: true },
99+
{ field: 'account', label: 'Account' },
100+
{ field: 'stage', label: 'Stage', type: 'select', sortable: true },
101+
{ field: 'amount', label: 'Amount', type: 'currency', sortable: true },
102+
{ field: 'probability', label: 'Probability', type: 'percent', sortable: true },
103+
{ field: 'close_date', label: 'Close Date', type: 'date', sortable: true },
104+
],
105+
data: [
106+
{ _id: '1', name: 'ObjectStack Enterprise License', account: '', stage: 'Closed Won', amount: 150000, probability: 100, close_date: '2024-01-15T00:00:00.000Z' },
107+
{ _id: '2', name: 'Cloud Migration Project', account: 'Acme Corp', stage: 'Negotiation', amount: 85000, probability: 60, close_date: '2024-03-20T00:00:00.000Z' },
108+
{ _id: '3', name: 'Annual Support Renewal', account: '', stage: 'Proposal', amount: 42000, probability: 80, close_date: '2024-02-28T00:00:00.000Z' },
109+
{ _id: '4', name: 'Custom Integration Development', account: 'TechFlow Inc', stage: 'Qualification', amount: 230000, probability: 30, close_date: '2024-06-15T00:00:00.000Z' },
110+
{ _id: '5', name: 'Data Analytics Platform', account: '', stage: 'Closed Lost', amount: 95000, probability: 0, close_date: '2024-01-10T00:00:00.000Z' },
111+
{ _id: '6', name: 'Security Audit Contract', account: 'SecureNet', stage: 'Closed Won', amount: 67500, probability: 100, close_date: '2024-02-01T00:00:00.000Z' },
112+
{ _id: '7', name: 'Mobile App Development', account: '', stage: 'Discovery', amount: 180000, probability: 15, close_date: '2024-08-30T00:00:00.000Z' },
113+
],
114+
pagination: false,
115+
className: 'w-full',
116+
} as any,
117+
};
118+
82119
export const EditableGrid: Story = {
83120
render: renderStory,
84121
args: {

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
327327
cellRenderer = (value: any, row: any) => {
328328
const displayContent = CellRenderer
329329
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
330-
: String(value ?? '');
330+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
331331
return (
332332
<button
333333
type="button"
334-
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
334+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
335335
onClick={(e) => {
336336
e.stopPropagation();
337337
navigation.handleClick(row);
@@ -346,11 +346,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
346346
cellRenderer = (value: any, row: any) => {
347347
const displayContent = CellRenderer
348348
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
349-
: String(value ?? '');
349+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
350350
return (
351351
<button
352352
type="button"
353-
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
353+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
354354
onClick={(e) => {
355355
e.stopPropagation();
356356
navigation.handleClick(row);
@@ -365,7 +365,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
365365
cellRenderer = (value: any, row: any) => {
366366
const displayContent = CellRenderer
367367
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
368-
: String(value ?? '');
368+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
369369
return (
370370
<button
371371
type="button"
@@ -387,13 +387,24 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
387387
cellRenderer = (value: any) => (
388388
<CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
389389
);
390+
} else {
391+
// Default renderer with empty value handling
392+
cellRenderer = (value: any) => (
393+
value != null && value !== ''
394+
? <span>{String(value)}</span>
395+
: <span className="text-muted-foreground">-</span>
396+
);
390397
}
391398

399+
// Auto-infer alignment from field type if not explicitly set
400+
const numericTypes = ['number', 'currency', 'percent'];
401+
const inferredAlign = col.align || (col.type && numericTypes.includes(col.type) ? 'right' as const : undefined);
402+
392403
return {
393404
header,
394405
accessorKey: col.field,
395406
...(col.width && { width: col.width }),
396-
...(col.align && { align: col.align }),
407+
...(inferredAlign && { align: inferredAlign }),
397408
sortable: col.sortable !== false,
398409
...(col.resizable !== undefined && { resizable: col.resizable }),
399410
...(col.wrap !== undefined && { wrap: col.wrap }),
@@ -439,9 +450,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
439450
if (field.permissions && field.permissions.read === false) return;
440451

441452
const CellRenderer = getCellRenderer(field.type);
453+
const numericTypes = ['number', 'currency', 'percent'];
442454
generatedColumns.push({
443455
header: field.label || fieldName,
444456
accessorKey: fieldName,
457+
...(numericTypes.includes(field.type) && { align: 'right' }),
445458
cell: (value: any) => <CellRenderer value={value} field={field} />,
446459
sortable: field.sortable !== false,
447460
});

0 commit comments

Comments
 (0)