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..976ac95a8 --- /dev/null +++ b/packages/fields/src/__tests__/cell-renderers.test.tsx @@ -0,0 +1,467 @@ +/** + * 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 SelectCellRenderer for status type', () => { + const renderer = getCellRenderer('status'); + expect(renderer).toBeDefined(); + // Verify it renders a badge (same as select) + const { container } = render( + React.createElement(renderer, { + value: 'Active', + field: { name: 'status', type: 'status', 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 "Overdue Xd"', () => { + render( + + ); + expect(screen.getByText('Overdue 4d')).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(); + }); + + it('should render green circle indicator for is_completed=true', () => { + const { container } = render( + + ); + const indicator = container.querySelector('[data-testid="completion-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('bg-green-500'); + }); + + it('should render empty circle indicator for is_completed=false', () => { + const { container } = render( + + ); + const indicator = container.querySelector('[data-testid="completion-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('border-2'); + }); + + it('should render green circle indicator for completed=true', () => { + const { container } = render( + + ); + const indicator = container.querySelector('[data-testid="completion-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('bg-green-500'); + }); + + it('should render standard checkbox for non-completion boolean fields', () => { + render( + + ); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-state', 'checked'); + }); +}); + +// ========================================================================= +// 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 "Overdue Xd" for 2-7 days ago', () => { + expect(formatRelativeDate('2026-02-21T08:00:00Z')).toBe('Overdue 3d'); + }); + + 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..6db95ed9f 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 `Overdue ${absDays}d`; return formatDate(date); } - if (diffDays > 1 && diffDays <= 30) return `In ${diffDays} days`; + if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`; return formatDate(date); } @@ -245,8 +245,33 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React. /** * Boolean field cell renderer (Airtable-style checkbox) + * Supports semantic rendering for completion fields (green indicator). */ -export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactElement { +export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + if (value == null) { + return ; + } + + // Semantic rendering for completion fields (green circle indicator) + // Only match exact field names to avoid false positives + const fieldName = field?.name?.toLowerCase() || ''; + const isCompletionField = fieldName === 'completed' || fieldName === 'is_completed' + || fieldName === 'done' || fieldName === 'is_done'; + + if (isCompletionField) { + return ( +
+ {value ? ( +
+ +
+ ) : ( +
+ )} +
+ ); + } + return (
@@ -258,10 +283,27 @@ 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 isValidDate = date instanceof Date && !isNaN(date.getTime()); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const isOverdue = isValidDate && date < startOfToday; + const isoString = isValidDate ? date.toISOString() : String(value); + + return ( + + {formatted} + + ); } /** @@ -291,6 +333,36 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE ); } +// Priority semantic color mapping (auto-detect from value text) +const PRIORITY_COLOR_MAP: 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 BADGE_COLOR_MAP: Record = { + gray: 'bg-gray-100 text-gray-800 border-gray-300', + red: 'bg-red-100 text-red-800 border-red-300', + orange: 'bg-orange-100 text-orange-800 border-orange-300', + yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300', + green: 'bg-green-100 text-green-800 border-green-300', + blue: 'bg-blue-100 text-blue-800 border-blue-300', + indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300', + purple: 'bg-purple-100 text-purple-800 border-purple-300', + pink: 'bg-pink-100 text-pink-800 border-pink-300', +}; + +function getBadgeColorClasses(color?: string, val?: string): string { + const resolvedColor = color + || (val ? PRIORITY_COLOR_MAP[String(val).toLowerCase()] : undefined); + return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border'; +} + /** * Select field cell renderer (with badges) */ @@ -300,22 +372,6 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R if (!value) return -; - // Color to Tailwind class mapping for custom Badge styling - const getColorClasses = (color?: string) => { - const colorMap: Record = { - gray: 'bg-gray-100 text-gray-800 border-gray-300', - red: 'bg-red-100 text-red-800 border-red-300', - orange: 'bg-orange-100 text-orange-800 border-orange-300', - yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300', - green: 'bg-green-100 text-green-800 border-green-300', - blue: 'bg-blue-100 text-blue-800 border-blue-300', - indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300', - 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; - }; - // Handle multiple values if (Array.isArray(value)) { return ( @@ -323,7 +379,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 = getBadgeColorClasses(option?.color, val); return ( opt.value === value); const label = option?.label || value; - const colorClasses = getColorClasses(option?.color); + const colorClasses = getBadgeColorClasses(option?.color, value); return ( datetime: DateTimeCellRenderer, time: TextCellRenderer, select: SelectCellRenderer, + status: SelectCellRenderer, lookup: SelectCellRenderer, // Default fallback master_detail: SelectCellRenderer, // Default fallback email: EmailCellRenderer, diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 43615bb0c..bfce0739b 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -465,6 +465,17 @@ export const ObjectGrid: React.FC = ({ 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 (