Skip to content

Commit 822fba5

Browse files
authored
Merge pull request #666 from objectstack-ai/copilot/optimize-opportunity-mobile-view
2 parents d8c2e10 + 8406c7f commit 822fba5

4 files changed

Lines changed: 219 additions & 31 deletions

File tree

ROADMAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ All 11 plugin views (Grid, Kanban, Form, Dashboard, Calendar, Timeline, List, De
7575
- ModalForm: skeleton loading state, sticky action buttons, form grid forced to 1-column on mobile (`md:` breakpoint for multi-column).
7676
- Date/DateTime fields use native HTML5 inputs (`type="date"`, `type="datetime-local"`) for optimal mobile picker UX.
7777
- Form sections supported via `ModalFormSectionConfig` for visual field grouping.
78+
- Mobile card view optimizations for Opportunity list view:
79+
- Stage badge truncation fix: `shrink-0 max-w-[140px] truncate` classes prevent right-edge overflow.
80+
- Percent/probability field classification: auto-detected and rendered with `%` suffix; empty values hidden.
81+
- Compact date format on mobile cards: `Jan 15, '24` (short style) saves horizontal space.
82+
- Compact currency format: `$150K` notation (via `formatCompactCurrency`) replaces `$150,000.00`.
83+
- Left border accent color per stage (green/red/yellow/blue/indigo/purple) for visual differentiation.
84+
- Improved card density: combined date+percent row, reduced padding (`p-2.5`), tighter margins.
7885

7986
### v3.0.0 Spec Integration ✅
8087

packages/fields/src/index.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ export function formatCurrency(value: number, currency: string = 'USD'): string
7474
}
7575
}
7676

77+
/**
78+
* Format currency value in compact form for mobile display.
79+
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
80+
*/
81+
export function formatCompactCurrency(value: number, currency: string = 'USD'): string {
82+
try {
83+
return new Intl.NumberFormat('en-US', {
84+
style: 'currency',
85+
currency,
86+
notation: 'compact',
87+
maximumFractionDigits: 1,
88+
}).format(value);
89+
} catch {
90+
return `${currency} ${value}`;
91+
}
92+
}
93+
7794
/**
7895
* Format percent value
7996
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
@@ -87,10 +104,18 @@ export function formatPercent(value: number, precision: number = 0): string {
87104
/**
88105
* Format date value
89106
*/
90-
export function formatDate(value: string | Date, _format?: string): string {
107+
export function formatDate(value: string | Date, style?: string): string {
91108
if (!value) return '-';
92109
const date = typeof value === 'string' ? new Date(value) : value;
93110
if (isNaN(date.getTime())) return '-';
111+
112+
if (style === 'short') {
113+
// Compact format for mobile: "Jan 15, '24"
114+
const month = date.toLocaleDateString('en-US', { month: 'short' });
115+
const day = date.getDate();
116+
const year = String(date.getFullYear()).slice(-2);
117+
return `${month} ${day}, '${year}`;
118+
}
94119

95120
// Default format: MMM DD, YYYY
96121
return date.toLocaleDateString('en-US', {

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import React, { useEffect, useState, useCallback } from 'react';
2525
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
2626
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
27-
import { getCellRenderer, formatCurrency, formatDate } from '@object-ui/fields';
27+
import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent } from '@object-ui/fields';
2828
import {
2929
Badge, Button, NavigationOverlay,
3030
Popover, PopoverContent, PopoverTrigger,
@@ -859,6 +859,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
859859
const amountKeys = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary'];
860860
const stageKeys = ['stage', 'status', 'priority', 'category', 'severity', 'level'];
861861
const dateKeys = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
862+
const percentKeys = ['probability', 'percent', 'rate', 'ratio', 'confidence', 'score'];
862863

863864
// Stage badge color mapping for common pipeline stages
864865
const stageBadgeColor = (value: string): string => {
@@ -878,11 +879,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
878879
return 'bg-gray-100 text-gray-800 border-gray-300';
879880
};
880881

881-
const classify = (key: string): 'amount' | 'stage' | 'date' | 'other' => {
882+
// Left border color for card accent based on stage
883+
const stageBorderLeft = (value: string): string => {
884+
const v = (value || '').toLowerCase();
885+
if (v.includes('won') || v.includes('completed') || v.includes('done') || v.includes('active'))
886+
return 'border-l-green-500';
887+
if (v.includes('lost') || v.includes('cancelled') || v.includes('rejected'))
888+
return 'border-l-red-500';
889+
if (v.includes('negotiation') || v.includes('review') || v.includes('in progress'))
890+
return 'border-l-yellow-500';
891+
if (v.includes('proposal') || v.includes('pending'))
892+
return 'border-l-blue-500';
893+
if (v.includes('qualification') || v.includes('qualified'))
894+
return 'border-l-indigo-500';
895+
if (v.includes('prospecting') || v.includes('new') || v.includes('open'))
896+
return 'border-l-purple-500';
897+
return 'border-l-gray-300';
898+
};
899+
900+
const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => {
882901
const k = key.toLowerCase();
883902
if (amountKeys.some(p => k.includes(p))) return 'amount';
884903
if (stageKeys.some(p => k.includes(p))) return 'stage';
885904
if (dateKeys.some(p => k.includes(p))) return 'date';
905+
if (percentKeys.some(p => k.includes(p))) return 'percent';
886906
return 'other';
887907
};
888908

@@ -895,63 +915,94 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
895915
const amountCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'amount');
896916
const stageCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'stage');
897917
const dateCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'date');
918+
const percentCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'percent');
898919
const otherCols = secondaryCols.filter(
899-
(c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c)
920+
(c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c) && !percentCols.includes(c)
900921
);
901922

923+
// Determine left border accent color from stage value
924+
const stageValue = stageCol ? String(row[stageCol.accessorKey] ?? '') : '';
925+
const leftBorderClass = stageValue ? stageBorderLeft(stageValue) : '';
926+
const cardClassName = [
927+
'border rounded-lg p-2.5 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation',
928+
leftBorderClass ? `border-l-[3px] ${leftBorderClass}` : '',
929+
].filter(Boolean).join(' ');
930+
902931
return (
903932
<div
904933
key={row.id || row._id || idx}
905-
className="border rounded-lg p-3 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation"
934+
className={cardClassName}
906935
onClick={() => navigation.handleClick(row)}
907936
>
908937
{/* Title row - Name as bold prominent title */}
909938
{titleCol && (
910-
<div className="font-semibold text-sm truncate mb-1.5">
939+
<div className="font-semibold text-sm truncate mb-1">
911940
{row[titleCol.accessorKey] ?? '—'}
912941
</div>
913942
)}
914943

915944
{/* Amount + Stage row - side by side for compact display */}
916945
{(amountCol || stageCol) && (
917-
<div className="flex items-center justify-between gap-2 mb-1.5">
946+
<div className="flex items-center justify-between gap-2 mb-1">
918947
{amountCol && (
919948
<span className="text-sm tabular-nums font-medium">
920949
{typeof row[amountCol.accessorKey] === 'number'
921-
? formatCurrency(row[amountCol.accessorKey])
950+
? formatCompactCurrency(row[amountCol.accessorKey])
922951
: row[amountCol.accessorKey] ?? '—'}
923952
</span>
924953
)}
925954
{stageCol && row[stageCol.accessorKey] && (
926955
<Badge
927956
variant="outline"
928-
className={`text-xs ${stageBadgeColor(String(row[stageCol.accessorKey]))}`}
957+
className={`text-xs shrink-0 max-w-[140px] truncate ${stageBadgeColor(String(row[stageCol.accessorKey]))}`}
929958
>
930959
{row[stageCol.accessorKey]}
931960
</Badge>
932961
)}
933962
</div>
934963
)}
935964

936-
{/* Date fields - formatted short date */}
937-
{dateCols.map((col: any) => (
938-
<div key={col.accessorKey} className="flex justify-between items-center py-0.5">
939-
<span className="text-xs text-muted-foreground">{col.header}</span>
940-
<span className="text-xs text-muted-foreground tabular-nums">
941-
{row[col.accessorKey] ? formatDate(row[col.accessorKey]) : '—'}
942-
</span>
965+
{/* Date + Percent combined row for density */}
966+
{(dateCols.length > 0 || percentCols.length > 0) && (
967+
<div className="flex items-center justify-between py-0.5 text-xs text-muted-foreground">
968+
{dateCols[0] && (
969+
<span className="tabular-nums">
970+
{row[dateCols[0].accessorKey]
971+
? formatDate(row[dateCols[0].accessorKey], 'short')
972+
: '—'}
973+
</span>
974+
)}
975+
{percentCols[0] && row[percentCols[0].accessorKey] != null && (
976+
<span className="tabular-nums">
977+
{formatPercent(Number(row[percentCols[0].accessorKey]))}
978+
</span>
979+
)}
943980
</div>
944-
))}
981+
)}
945982

946-
{/* Other fields - standard key-value rows */}
947-
{otherCols.map((col: any) => (
983+
{/* Additional date fields beyond the first */}
984+
{dateCols.slice(1).map((col: any) => (
948985
<div key={col.accessorKey} className="flex justify-between items-center py-0.5">
949986
<span className="text-xs text-muted-foreground">{col.header}</span>
950-
<span className="text-xs font-medium truncate ml-2 text-right">
951-
{col.cell ? col.cell(row[col.accessorKey], row) : String(row[col.accessorKey] ?? '—')}
987+
<span className="text-xs text-muted-foreground tabular-nums">
988+
{row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short') : '—'}
952989
</span>
953990
</div>
954991
))}
992+
993+
{/* Other fields - hide empty values on mobile */}
994+
{otherCols.map((col: any) => {
995+
const val = row[col.accessorKey];
996+
if (val == null || val === '') return null;
997+
return (
998+
<div key={col.accessorKey} className="flex justify-between items-center py-0.5">
999+
<span className="text-xs text-muted-foreground">{col.header}</span>
1000+
<span className="text-xs font-medium truncate ml-2 text-right">
1001+
{col.cell ? col.cell(val, row) : String(val)}
1002+
</span>
1003+
</div>
1004+
);
1005+
})}
9551006
</div>
9561007
);
9571008
})}

packages/plugin-grid/src/__tests__/mobile-card-view.test.tsx

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,13 @@ describe('Mobile Card View: Title hierarchy', () => {
119119
// 2. Currency Formatting
120120
// =========================================================================
121121
describe('Mobile Card View: Currency formatting', () => {
122-
it('should format Amount as currency ($150,000.00)', async () => {
122+
it('should format Amount as compact currency ($150K)', async () => {
123123
renderGrid(opportunityData, opportunityColumns);
124124

125125
await waitFor(() => {
126-
expect(screen.getByText('$150,000.00')).toBeInTheDocument();
127-
expect(screen.getByText('$45,000.00')).toBeInTheDocument();
128-
expect(screen.getByText('$85,000.00')).toBeInTheDocument();
126+
expect(screen.getByText('$150K')).toBeInTheDocument();
127+
expect(screen.getByText('$45K')).toBeInTheDocument();
128+
expect(screen.getByText('$85K')).toBeInTheDocument();
129129
});
130130
});
131131
});
@@ -185,16 +185,16 @@ describe('Mobile Card View: Stage colored badge', () => {
185185
// 4. Date Formatting
186186
// =========================================================================
187187
describe('Mobile Card View: Date formatting', () => {
188-
it('should format close_date as short date (not ISO string)', async () => {
188+
it('should format close_date as compact short date (not ISO string)', async () => {
189189
renderGrid(opportunityData, opportunityColumns);
190190

191191
await waitFor(() => {
192192
// Should NOT show raw ISO string
193193
expect(screen.queryByText('2024-01-15T00:00:00.000Z')).not.toBeInTheDocument();
194194
expect(screen.queryByText('2024-03-30T00:00:00.000Z')).not.toBeInTheDocument();
195195

196-
// Should show formatted date (en-US short format)
197-
expect(screen.getByText('Jan 15, 2024')).toBeInTheDocument();
196+
// Should show compact date format (e.g. "Jan 15, '24")
197+
expect(screen.getByText("Jan 15, '24")).toBeInTheDocument();
198198
});
199199
});
200200
});
@@ -239,12 +239,117 @@ describe('Mobile Card View: Card structure', () => {
239239
});
240240
});
241241

242-
it('should render Close Date label in the card', async () => {
242+
it('should render compact date and percent in combined row', async () => {
243243
renderGrid(opportunityData, opportunityColumns);
244244

245245
await waitFor(() => {
246-
const labels = screen.getAllByText('Close Date');
247-
expect(labels.length).toBeGreaterThanOrEqual(1);
246+
// Compact date visible
247+
const dateEls = screen.getAllByText("Jan 15, '24");
248+
expect(dateEls.length).toBeGreaterThanOrEqual(1);
249+
// Probability shown as percent
250+
expect(screen.getByText('100%')).toBeInTheDocument();
251+
});
252+
});
253+
});
254+
255+
// =========================================================================
256+
// 7. Percent/Probability Display
257+
// =========================================================================
258+
describe('Mobile Card View: Percent field display', () => {
259+
it('should render probability values with % suffix', async () => {
260+
renderGrid(opportunityData, opportunityColumns);
261+
262+
await waitFor(() => {
263+
expect(screen.getByText('100%')).toBeInTheDocument();
264+
expect(screen.getByText('80%')).toBeInTheDocument();
265+
expect(screen.getByText('60%')).toBeInTheDocument();
266+
});
267+
});
268+
269+
it('should hide empty/null percent fields', async () => {
270+
const dataWithNull = [
271+
{ _id: '201', name: 'Test Deal', amount: 50000, stage: 'Proposal', close_date: '2024-06-01', probability: null },
272+
];
273+
renderGrid(dataWithNull, opportunityColumns);
274+
275+
await waitFor(() => {
276+
expect(screen.getByText('Test Deal')).toBeInTheDocument();
277+
// "Probability" label should NOT appear since value is null
278+
expect(screen.queryByText('Probability')).not.toBeInTheDocument();
279+
});
280+
});
281+
});
282+
283+
// =========================================================================
284+
// 8. Left Border Stage Color
285+
// =========================================================================
286+
describe('Mobile Card View: Left border stage accent', () => {
287+
it('should add green left border for Closed Won stage', async () => {
288+
const { container } = renderGrid(opportunityData, opportunityColumns);
289+
290+
await waitFor(() => {
291+
const cards = container.querySelectorAll('.border.rounded-lg');
292+
expect(cards[0].className).toContain('border-l-green-500');
293+
});
294+
});
295+
296+
it('should add yellow left border for Negotiation stage', async () => {
297+
const { container } = renderGrid(opportunityData, opportunityColumns);
298+
299+
await waitFor(() => {
300+
const cards = container.querySelectorAll('.border.rounded-lg');
301+
expect(cards[1].className).toContain('border-l-yellow-500');
302+
});
303+
});
304+
305+
it('should add blue left border for Proposal stage', async () => {
306+
const { container } = renderGrid(opportunityData, opportunityColumns);
307+
308+
await waitFor(() => {
309+
const cards = container.querySelectorAll('.border.rounded-lg');
310+
expect(cards[2].className).toContain('border-l-blue-500');
311+
});
312+
});
313+
});
314+
315+
// =========================================================================
316+
// 9. Badge Truncation Fix
317+
// =========================================================================
318+
describe('Mobile Card View: Badge truncation handling', () => {
319+
it('should have shrink-0, max-w, and truncate classes on stage badges', async () => {
320+
renderGrid(opportunityData, opportunityColumns);
321+
322+
await waitFor(() => {
323+
const badge = screen.getByText('Closed Won');
324+
expect(badge.className).toContain('shrink-0');
325+
expect(badge.className).toContain('max-w-[140px]');
326+
expect(badge.className).toContain('truncate');
327+
});
328+
});
329+
});
330+
331+
// =========================================================================
332+
// 10. Empty field hiding
333+
// =========================================================================
334+
describe('Mobile Card View: Empty field hiding', () => {
335+
it('should not render "other" fields with null/empty values', async () => {
336+
const columnsWithExtra: ListColumn[] = [
337+
{ field: 'name', label: 'Name' },
338+
{ field: 'amount', label: 'Amount' },
339+
{ field: 'stage', label: 'Stage' },
340+
{ field: 'description', label: 'Description' },
341+
];
342+
const dataWithEmpty = [
343+
{ _id: '301', name: 'Deal A', amount: 10000, stage: 'Proposal', description: null },
344+
{ _id: '302', name: 'Deal B', amount: 20000, stage: 'Proposal', description: 'Has value' },
345+
];
346+
renderGrid(dataWithEmpty, columnsWithExtra);
347+
348+
await waitFor(() => {
349+
// "Description" label should appear only for Deal B (has value), not Deal A (null)
350+
const descLabels = screen.getAllByText('Description');
351+
expect(descLabels.length).toBe(1);
352+
expect(screen.getByText('Has value')).toBeInTheDocument();
248353
});
249354
});
250355
});

0 commit comments

Comments
 (0)