diff --git a/ROADMAP.md b/ROADMAP.md index 9b46362f7..3c3a7664f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -988,6 +988,9 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] ISO datetime fallback: ObjectGrid `inferColumnType()` now detects ISO 8601 datetime strings (`YYYY-MM-DDTHH:MM`) in data values as a catch-all for fields whose names don't match date/datetime patterns. - [x] Date/datetime human-friendly display: `DateCellRenderer` (relative format) and `DateTimeCellRenderer` (split date/time) already registered in field registry for all grid/table views. - [x] Currency/status/boolean renderers: Already implemented with proper formatting (currency symbol, Badge colors, checkbox display). +- [x] **accessorKey-format type inference**: `generateColumns()` in ObjectGrid now applies `inferColumnType()` + `getCellRenderer()` to `accessorKey`-format columns that don't already have a `cell` renderer. Previously, `accessorKey` columns bypassed the entire inference pipeline, showing raw dates, plain text status/priority, and raw numbers for progress. +- [x] **humanizeLabel() utility**: New `humanizeLabel()` export in `@object-ui/fields` converts snake_case/kebab-case values to Title Case (e.g., `in_progress` → `In Progress`). Used as fallback in `SelectCellRenderer` when no explicit `option.label` exists. +- [x] **PercentCellRenderer progress-field normalization**: `PercentCellRenderer` now uses field name to disambiguate 0-1 fraction vs 0-100 whole number — fields matching `/progress|completion/` treat values as already in 0-100 range (e.g., `75` → `75%`), while other fields like `probability` still treat `0.75` → `75%`. **Header & Breadcrumb i18n:** - [x] AppHeader breadcrumb labels (`Dashboards`, `Pages`, `Reports`, `System`) now use `t()` translation via `useObjectTranslation`. @@ -997,8 +1000,8 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th **Tests:** - [x] 46 NavigationRenderer tests passing (pin/favorites/search/reorder) -- [x] 75 field cell renderer tests passing (date/datetime/select/boolean/percent) -- [x] 263 ObjectGrid tests passing (inference, rendering, accessibility) +- [x] 86 field cell renderer tests passing (date/datetime/select/boolean/percent/humanizeLabel/progress) +- [x] 293 ObjectGrid tests passing (inference, rendering, accessibility, accessorKey-format inference) - [x] 28 DataTable tests passing - [x] 78 Layout tests passing (NavigationRenderer + AppSchemaRenderer) - [x] 11 AppSidebar tests passing diff --git a/packages/fields/src/__tests__/cell-renderers.test.tsx b/packages/fields/src/__tests__/cell-renderers.test.tsx index 9cacbe32b..5fff8449b 100644 --- a/packages/fields/src/__tests__/cell-renderers.test.tsx +++ b/packages/fields/src/__tests__/cell-renderers.test.tsx @@ -16,6 +16,8 @@ import { TextCellRenderer, DateCellRenderer, BooleanCellRenderer, + PercentCellRenderer, + humanizeLabel, formatDate, formatRelativeDate, } from '../index'; @@ -734,3 +736,131 @@ describe('UserCellRenderer', () => { expect(screen.getByTitle('Bob')).toBeInTheDocument(); }); }); + +// ========================================================================= +// 9. humanizeLabel +// ========================================================================= + +describe('humanizeLabel', () => { + it('should convert snake_case to Title Case', () => { + expect(humanizeLabel('in_progress')).toBe('In Progress'); + }); + + it('should convert kebab-case to Title Case', () => { + expect(humanizeLabel('high-priority')).toBe('High Priority'); + }); + + it('should handle single word', () => { + expect(humanizeLabel('active')).toBe('Active'); + }); + + it('should handle already Title Case', () => { + expect(humanizeLabel('Active')).toBe('Active'); + }); + + it('should handle multiple underscores', () => { + expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started'); + }); + + it('should handle empty string', () => { + expect(humanizeLabel('')).toBe(''); + }); + + it('should handle mixed separators', () => { + expect(humanizeLabel('in_progress-now')).toBe('In Progress Now'); + }); +}); + +// ========================================================================= +// 10. SelectCellRenderer humanizeLabel fallback +// ========================================================================= +describe('SelectCellRenderer humanizeLabel fallback', () => { + it('should humanize snake_case value when no option label exists', () => { + render( + + ); + expect(screen.getByText('In Progress')).toBeInTheDocument(); + }); + + it('should prefer explicit option.label over humanized fallback', () => { + render( + + ); + expect(screen.getByText('WIP')).toBeInTheDocument(); + expect(screen.queryByText('In Progress')).not.toBeInTheDocument(); + }); + + it('should humanize snake_case values in arrays', () => { + render( + + ); + expect(screen.getByText('Not Started')).toBeInTheDocument(); + expect(screen.getByText('In Progress')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 11. PercentCellRenderer with progress-type fields +// ========================================================================= +describe('PercentCellRenderer progress-type fields', () => { + it('should render progress field value 75 as 75% with correct bar', () => { + const { container } = render( + + ); + expect(screen.getByText('75%')).toBeInTheDocument(); + const bar = container.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '75'); + }); + + it('should render completion field value 50 as 50% with correct bar', () => { + const { container } = render( + + ); + expect(screen.getByText('50%')).toBeInTheDocument(); + const bar = container.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '50'); + }); + + it('should render probability field value 0.75 as 75%', () => { + const { container } = render( + + ); + expect(screen.getByText('75%')).toBeInTheDocument(); + const bar = container.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '75'); + }); + + it('should render rate field value 0.5 as 50%', () => { + const { container } = render( + + ); + expect(screen.getByText('50%')).toBeInTheDocument(); + const bar = container.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '50'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index e53e2ae83..705bc2e45 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -104,6 +104,19 @@ export function formatPercent(value: number, precision: number = 0): string { return `${displayValue.toFixed(precision)}%`; } +/** + * Humanize a snake_case or kebab-case string into Title Case. + * Used as fallback label when no explicit option.label exists. + * + * Examples: + * "in_progress" → "In Progress" + * "high-priority" → "High Priority" + * "active" → "Active" + */ +export function humanizeLabel(value: string): string { + return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + /** * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d") */ @@ -210,6 +223,9 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React return {formatted}; } +// Fields that store percentage values as whole numbers (0-100) rather than fractions (0-1) +const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/; + /** * Percent field cell renderer with mini progress bar */ @@ -219,9 +235,13 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React. const percentField = field as any; const precision = percentField.precision ?? 0; const numValue = Number(value); - const formatted = formatPercent(numValue, precision); - // Normalize to 0-100 range for progress bar - const barValue = (numValue > -1 && numValue < 1) ? numValue * 100 : numValue; + // Use field name to disambiguate 0-1 fraction vs 0-100 whole number: + // Fields like "progress" or "completion" store values as 0-100, not 0-1 + const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || ''); + const barValue = isWholePercentField + ? numValue + : (numValue > -1 && numValue < 1) ? numValue * 100 : numValue; + const formatted = isWholePercentField ? `${numValue.toFixed(precision)}%` : formatPercent(numValue, precision); const clampedBar = Math.max(0, Math.min(100, barValue)); return ( @@ -378,7 +398,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 label = option?.label || humanizeLabel(String(val)); const colorClasses = getBadgeColorClasses(option?.color, val); return ( @@ -397,7 +417,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R // Handle single value const option = options.find(opt => opt.value === value); - const label = option?.label || value; + const label = option?.label || humanizeLabel(String(value)); const colorClasses = getBadgeColorClasses(option?.color, value); return ( diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 1cdfab49b..3033513ab 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -24,7 +24,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types'; import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react'; -import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent } from '@object-ui/fields'; +import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel } from '@object-ui/fields'; import { Badge, Button, NavigationOverlay, Popover, PopoverContent, PopoverTrigger, @@ -508,9 +508,29 @@ export const ObjectGrid: React.FC = ({ if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) { const firstCol = cols[0] as any; - // Already in data-table format - use as-is + // Already in data-table format - apply type inference for columns without custom cell renderers if ('accessorKey' in firstCol) { - return cols; + return (cols as any[]).map((col) => { + if (col.cell) return col; // already has custom renderer + + const syntheticCol: ListColumn = { field: col.accessorKey, label: col.header, type: col.type }; + const inferredType = inferColumnType(syntheticCol); + if (!inferredType) return col; + + const CellRenderer = getCellRenderer(inferredType); + const fieldMeta: Record = { name: col.accessorKey, type: inferredType }; + + if (inferredType === 'select') { + const uniqueValues = Array.from(new Set(data.map(row => row[col.accessorKey]).filter(Boolean))); + fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) })); + } + + return { + ...col, + headerIcon: getTypeIcon(inferredType), + cell: (value: any) => , + }; + }); } // ListColumn format - convert to data-table format with full feature support @@ -532,7 +552,7 @@ export const ObjectGrid: React.FC = ({ 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) })); + fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) })); } if ((col as any).options) { fieldMeta.options = (col as any).options; diff --git a/packages/plugin-grid/src/__tests__/accessorKey-inference.test.tsx b/packages/plugin-grid/src/__tests__/accessorKey-inference.test.tsx new file mode 100644 index 000000000..4c4f63076 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/accessorKey-inference.test.tsx @@ -0,0 +1,132 @@ +/** + * AccessorKey Inference Tests + * + * Tests that accessorKey-format columns receive type inference + * via inferColumnType() + getCellRenderer(), matching the behavior + * of ListColumn (field) format columns. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +// --- Mock Data with various types --- +const mockData = [ + { + _id: '1', + name: 'Project Alpha', + status: 'in_progress', + priority: 'high', + progress: 75, + start_date: '2024-02-01T00:00:00.000Z', + }, + { + _id: '2', + name: 'Project Beta', + status: 'completed', + priority: 'low', + progress: 100, + start_date: '2024-03-15T00:00:00.000Z', + }, +]; + +// Helper: Render ObjectGrid with accessorKey-format columns +function renderAccessorGrid(columns: any[], data?: any[]) { + const schema: any = { + type: 'object-grid' as const, + objectName: 'test_object', + columns, + data: { provider: 'value', items: data || mockData }, + }; + + return render( + + + + ); +} + +// ========================================================================= +// 1. accessorKey columns get type inference +// ========================================================================= +describe('accessorKey-format: type inference', () => { + it('should infer select type for status field and render badges', async () => { + renderAccessorGrid([ + { header: 'Name', accessorKey: 'name' }, + { header: 'Status', accessorKey: 'status' }, + ]); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Status should be inferred as select and render humanized badges + expect(screen.getByText('In Progress')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('should infer date type for date fields', async () => { + renderAccessorGrid([ + { header: 'Name', accessorKey: 'name' }, + { header: 'Start Date', accessorKey: 'start_date' }, + ]); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Date fields should NOT show raw ISO strings + expect(screen.queryByText('2024-02-01T00:00:00.000Z')).not.toBeInTheDocument(); + }); + + it('should infer percent type for progress field and render progress bar', async () => { + renderAccessorGrid([ + { header: 'Name', accessorKey: 'name' }, + { header: 'Progress', accessorKey: 'progress' }, + ]); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Progress should render as percentage with progress bar + expect(screen.getByText('75%')).toBeInTheDocument(); + const bars = screen.getAllByRole('progressbar'); + expect(bars.length).toBeGreaterThan(0); + }); + + it('should NOT override columns that already have a cell renderer', async () => { + const customRenderer = (value: any) => {value}-custom; + renderAccessorGrid([ + { header: 'Name', accessorKey: 'name' }, + { header: 'Status', accessorKey: 'status', cell: customRenderer }, + ]); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Custom renderer should be preserved + expect(screen.getByText('in_progress-custom')).toBeInTheDocument(); + }); + + it('should pass through columns with explicit type', async () => { + renderAccessorGrid([ + { header: 'Name', accessorKey: 'name' }, + { header: 'Priority', accessorKey: 'priority', type: 'select' }, + ]); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Priority with explicit select type should render as humanized badge + expect(screen.getByText('High')).toBeInTheDocument(); + expect(screen.getByText('Low')).toBeInTheDocument(); + }); +});