From fd7b26997427d2bfff53e889aaaafa307ad6f0ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:11:14 +0000 Subject: [PATCH 1/5] Initial plan From 6106b124f4360f22f5e7a04803eefd31f3e64077 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:19:58 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20ObjectGrid=20cell=20type=20renderin?= =?UTF-8?q?g=20=E2=80=94=20select=20Badge=20colors,=20date=20relative=20fo?= =?UTF-8?q?rmat,=20boolean=20null=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SelectCellRenderer: priority semantic color auto-detection (Critical→red, High→orange, Medium→yellow, Low→gray), muted default for unconfigured colors - DateCellRenderer: relative format by default, overdue red text styling, ISO tooltip - BooleanCellRenderer: null/undefined renders as — instead of unchecked checkbox - ObjectGrid: auto-generates field options from data for inferred select columns - formatRelativeDate: threshold tightened from 30 to 7 days - 40 new unit tests for cell renderers and formatRelativeDate edge cases - ROADMAP.md updated with Module 4 completion details Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 3 + .../src/__tests__/boolean-checkbox.test.tsx | 6 +- .../src/__tests__/cell-renderers.test.tsx | 407 ++++++++++++++++++ packages/fields/src/index.tsx | 47 +- packages/plugin-grid/src/ObjectGrid.tsx | 17 +- .../src/__tests__/airtable-style.test.tsx | 7 +- 6 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 packages/fields/src/__tests__/cell-renderers.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 2597e5efb..acaed76e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -810,6 +810,9 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] `formatRelativeDate()` function added for relative time display ("Today", "2 days ago", "Yesterday") - [x] DataTable/VirtualGrid header styling unified: `text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 bg-muted/30` - [x] Remaining hardcoded gray colors in ObjectGrid loading spinner and status badge fallback replaced with theme tokens +- [x] Select/status type Badge rendering — `getCellRenderer()` returns `` with color mapping from `field.options`; auto-generated options from unique data values when type is inferred; priority semantic colors (Critical→red, High→orange, Medium→yellow, Low→gray); muted default style for unconfigured colors +- [x] Date type human-readable formatting — `DateCellRenderer` defaults to relative format ("Today", "Yesterday", "3 days ago"); overdue dates styled with red text; ISO timestamp shown as hover tooltip; `formatRelativeDate()` threshold tightened to 7 days +- [x] Boolean type visual rendering — `BooleanCellRenderer` renders `` for true/false; null/undefined values display as `—` **ConfigPanelRenderer:** - [x] `` added between sections for visual clarity diff --git a/packages/fields/src/__tests__/boolean-checkbox.test.tsx b/packages/fields/src/__tests__/boolean-checkbox.test.tsx index c08acf3eb..637b619bd 100644 --- a/packages/fields/src/__tests__/boolean-checkbox.test.tsx +++ b/packages/fields/src/__tests__/boolean-checkbox.test.tsx @@ -36,7 +36,7 @@ describe('BooleanCellRenderer', () => { expect(checkbox).toHaveAttribute('data-state', 'unchecked'); }); - it('should render an unchecked checkbox for null/undefined values', () => { + it('should render dash for null/undefined values', () => { render( { /> ); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveAttribute('data-state', 'unchecked'); + expect(screen.getByText('—')).toBeInTheDocument(); }); it('should render checkbox as disabled (non-interactive)', () => { diff --git a/packages/fields/src/__tests__/cell-renderers.test.tsx b/packages/fields/src/__tests__/cell-renderers.test.tsx new file mode 100644 index 000000000..d6ff3d23f --- /dev/null +++ b/packages/fields/src/__tests__/cell-renderers.test.tsx @@ -0,0 +1,407 @@ +/** + * Cell Renderer Tests + * + * Tests for getCellRenderer() select/date/boolean type renderers + * and formatRelativeDate() edge cases. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { + getCellRenderer, + SelectCellRenderer, + DateCellRenderer, + BooleanCellRenderer, + formatDate, + formatRelativeDate, +} from '../index'; + +// ========================================================================= +// 1. getCellRenderer type resolution +// ========================================================================= +describe('getCellRenderer', () => { + it('should return SelectCellRenderer for select type', () => { + const renderer = getCellRenderer('select'); + expect(renderer).toBeDefined(); + // Verify it renders a badge + const { container } = render( + React.createElement(renderer, { + value: 'Active', + field: { name: 'status', type: 'select', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any, + }) + ); + expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument(); + }); + + it('should return DateCellRenderer for date type', () => { + const renderer = getCellRenderer('date'); + expect(renderer).toBeDefined(); + }); + + it('should return BooleanCellRenderer for boolean type', () => { + const renderer = getCellRenderer('boolean'); + expect(renderer).toBeDefined(); + }); + + it('should fallback to TextCellRenderer for unknown types', () => { + const renderer = getCellRenderer('unknown-type'); + expect(renderer).toBeDefined(); + }); +}); + +// ========================================================================= +// 2. SelectCellRenderer +// ========================================================================= +describe('SelectCellRenderer', () => { + it('should render badge with explicit color from options', () => { + const { container } = render( + + ); + expect(screen.getByText('In Progress')).toBeInTheDocument(); + expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument(); + }); + + it('should use muted style when no color configured', () => { + const { container } = render( + + ); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(container.querySelector('[class*="bg-muted"]')).toBeInTheDocument(); + }); + + it('should auto-detect priority semantic colors (Critical → red)', () => { + const { container } = render( + + ); + expect(screen.getByText('Critical')).toBeInTheDocument(); + expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument(); + }); + + it('should auto-detect priority semantic colors (High → orange)', () => { + const { container } = render( + + ); + expect(container.querySelector('[class*="bg-orange-100"]')).toBeInTheDocument(); + }); + + it('should auto-detect priority semantic colors (Medium → yellow)', () => { + const { container } = render( + + ); + expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument(); + }); + + it('should auto-detect priority semantic colors (Low → gray)', () => { + const { container } = render( + + ); + expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument(); + }); + + it('should render dash for null/empty value', () => { + render( + + ); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('should render multiple badges for array values', () => { + render( + + ); + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('should prefer explicit option color over auto-detected', () => { + const { container } = render( + + ); + // Explicit purple should override auto-detected orange + expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 3. DateCellRenderer +// ========================================================================= +describe('DateCellRenderer', () => { + let nowSpy: ReturnType; + + beforeEach(() => { + // Fix "now" to a known date: 2026-02-24T12:00:00Z + nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime()); + }); + + afterEach(() => { + nowSpy.mockRestore(); + }); + + it('should render today date as "Today"', () => { + render( + + ); + expect(screen.getByText('Today')).toBeInTheDocument(); + }); + + it('should render yesterday date as "Yesterday"', () => { + render( + + ); + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + }); + + it('should render recent past date as "X days ago"', () => { + render( + + ); + expect(screen.getByText('4 days ago')).toBeInTheDocument(); + }); + + it('should render overdue date with red text styling', () => { + const { container } = render( + + ); + expect(container.querySelector('.text-red-600')).toBeInTheDocument(); + }); + + it('should show ISO string as tooltip', () => { + const { container } = render( + + ); + const span = container.querySelector('span[title]'); + expect(span).toBeInTheDocument(); + expect(span?.getAttribute('title')).toContain('2026-02-20'); + }); + + it('should render dash for null value', () => { + const { container } = render( + + ); + expect(container.textContent).toBe('-'); + }); + + it('should use explicit format when provided', () => { + render( + + ); + // Short format: "Jan 15, '26" + expect(screen.getByText("Jan 15, '26")).toBeInTheDocument(); + }); + + it('should not show red styling for future dates', () => { + const { container } = render( + + ); + expect(container.querySelector('.text-red-600')).not.toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 4. BooleanCellRenderer +// ========================================================================= +describe('BooleanCellRenderer', () => { + it('should render checked checkbox for true', () => { + render( + + ); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-state', 'checked'); + }); + + it('should render unchecked checkbox for false', () => { + render( + + ); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-state', 'unchecked'); + }); + + it('should render dash for null', () => { + render( + + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('should render dash for undefined', () => { + render( + + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 5. formatRelativeDate edge cases +// ========================================================================= +describe('formatRelativeDate', () => { + let nowSpy: ReturnType; + + beforeEach(() => { + nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime()); + }); + + afterEach(() => { + nowSpy.mockRestore(); + }); + + it('should return "-" for null', () => { + expect(formatRelativeDate(null as any)).toBe('-'); + }); + + it('should return "-" for empty string', () => { + expect(formatRelativeDate('')).toBe('-'); + }); + + it('should return "-" for invalid date', () => { + expect(formatRelativeDate('not-a-date')).toBe('-'); + }); + + it('should return "Today" for today', () => { + expect(formatRelativeDate('2026-02-24T08:00:00Z')).toBe('Today'); + }); + + it('should return "Yesterday" for yesterday', () => { + expect(formatRelativeDate('2026-02-23T08:00:00Z')).toBe('Yesterday'); + }); + + it('should return "Tomorrow" for tomorrow', () => { + expect(formatRelativeDate('2026-02-25T08:00:00Z')).toBe('Tomorrow'); + }); + + it('should return "X days ago" for 2-7 days ago', () => { + expect(formatRelativeDate('2026-02-21T08:00:00Z')).toBe('3 days ago'); + }); + + it('should return formatted date for >7 days ago', () => { + const result = formatRelativeDate('2026-02-10T08:00:00Z'); + // Should be a formatted date like "Feb 10, 2026", not "14 days ago" + expect(result).toContain('Feb'); + expect(result).toContain('2026'); + }); + + it('should return "In X days" for 2-7 days in the future', () => { + expect(formatRelativeDate('2026-02-28T08:00:00Z')).toBe('In 4 days'); + }); + + it('should return formatted date for >7 days in the future', () => { + const result = formatRelativeDate('2026-03-15T08:00:00Z'); + expect(result).toContain('Mar'); + expect(result).toContain('2026'); + }); +}); + +// ========================================================================= +// 6. formatDate with relative style +// ========================================================================= +describe('formatDate', () => { + it('should return "-" for null', () => { + expect(formatDate(null as any)).toBe('-'); + }); + + it('should return "-" for invalid date', () => { + expect(formatDate('invalid')).toBe('-'); + }); + + it('should format as relative when style is "relative"', () => { + // Just ensure it delegates to formatRelativeDate + const result = formatDate(new Date(), 'relative'); + expect(result).toBe('Today'); + }); + + it('should format in short style', () => { + const result = formatDate('2026-01-15T08:00:00Z', 'short'); + expect(result).toBe("Jan 15, '26"); + }); + + it('should format in default style (MMM DD, YYYY)', () => { + const result = formatDate('2026-01-15T08:00:00Z'); + expect(result).toContain('Jan'); + expect(result).toContain('15'); + expect(result).toContain('2026'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 7891a65f6..3c98275eb 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -123,10 +123,10 @@ export function formatRelativeDate(value: string | Date): string { if (diffDays === -1) return 'Yesterday'; if (diffDays < -1) { const absDays = Math.abs(diffDays); - if (absDays <= 30) return `${absDays} days ago`; + if (absDays <= 7) return `${absDays} days ago`; return formatDate(date); } - if (diffDays > 1 && diffDays <= 30) return `In ${diffDays} days`; + if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`; return formatDate(date); } @@ -247,6 +247,9 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React. * Boolean field cell renderer (Airtable-style checkbox) */ export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactElement { + if (value == null) { + return ; + } return (
@@ -258,10 +261,24 @@ export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactEl * Date field cell renderer */ export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + if (!value) return -; const dateField = field as any; - const formatted = formatDate(value, dateField.format); + const style = dateField.format || 'relative'; + const formatted = formatDate(value, style); - return {formatted}; + // Determine if date is overdue (in the past) + const date = typeof value === 'string' ? new Date(value) : value; + const isOverdue = date instanceof Date && !isNaN(date.getTime()) && date < new Date(new Date().setHours(0, 0, 0, 0)); + const isoString = date instanceof Date && !isNaN(date.getTime()) ? date.toISOString() : String(value); + + return ( + + {formatted} + + ); } /** @@ -300,8 +317,19 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R if (!value) return -; + // Priority semantic color mapping (auto-detect from value text) + const priorityColorMap: Record = { + critical: 'red', + urgent: 'red', + high: 'orange', + medium: 'yellow', + normal: 'blue', + low: 'gray', + none: 'gray', + }; + // Color to Tailwind class mapping for custom Badge styling - const getColorClasses = (color?: string) => { + const getColorClasses = (color?: string, val?: string) => { const colorMap: Record = { gray: 'bg-gray-100 text-gray-800 border-gray-300', red: 'bg-red-100 text-red-800 border-red-300', @@ -313,7 +341,10 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R purple: 'bg-purple-100 text-purple-800 border-purple-300', pink: 'bg-pink-100 text-pink-800 border-pink-300', }; - return colorMap[color || 'blue'] || colorMap.blue; + // Use explicit color, then try priority semantic color, then default muted + const resolvedColor = color + || (val ? priorityColorMap[String(val).toLowerCase()] : undefined); + return colorMap[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border'; }; // Handle multiple values @@ -323,7 +354,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R {value.map((val, idx) => { const option = options.find(opt => opt.value === val); const label = option?.label || val; - const colorClasses = getColorClasses(option?.color); + const colorClasses = getColorClasses(option?.color, val); return ( opt.value === value); const label = option?.label || value; - const colorClasses = getColorClasses(option?.color); + const colorClasses = getColorClasses(option?.color, value); return ( = ({ const inferredType = inferColumnType(col); const CellRenderer = inferredType ? getCellRenderer(inferredType) : null; + // Build field metadata for cell renderers (includes options for select fields) + const fieldMeta: Record = { name: col.field, type: inferredType || 'text' }; + if (inferredType === 'select' && !(col as any).options) { + // Auto-generate options from unique data values for inferred select fields + const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean))); + fieldMeta.options = uniqueValues.map(v => ({ value: v, label: String(v) })); + } + if ((col as any).options) { + fieldMeta.options = (col as any).options; + } + // Auto-link primary field (first column) to record detail (Airtable-style) const isPrimaryField = colIndex === 0 && !col.link && !col.action; const isLinked = col.link || isPrimaryField; @@ -473,7 +484,7 @@ export const ObjectGrid: React.FC = ({ // Both link and action: link takes priority for navigation, action executes on secondary interaction cellRenderer = (value: any, row: any) => { const displayContent = CellRenderer - ? + ? : (value != null && value !== '' ? String(value) : ); return (