Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down
130 changes: 130 additions & 0 deletions packages/fields/src/__tests__/cell-renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
TextCellRenderer,
DateCellRenderer,
BooleanCellRenderer,
PercentCellRenderer,
humanizeLabel,
formatDate,
formatRelativeDate,
} from '../index';
Expand Down Expand Up @@ -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(
<SelectCellRenderer
value="in_progress"
field={{ name: 'status', type: 'select', options: [] } as any}
/>
);
expect(screen.getByText('In Progress')).toBeInTheDocument();
});

it('should prefer explicit option.label over humanized fallback', () => {
render(
<SelectCellRenderer
value="in_progress"
field={{
name: 'status',
type: 'select',
options: [{ value: 'in_progress', label: 'WIP' }],
} as any}
/>
);
expect(screen.getByText('WIP')).toBeInTheDocument();
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
});

it('should humanize snake_case values in arrays', () => {
render(
<SelectCellRenderer
value={['not_started', 'in_progress']}
field={{ name: 'status', type: 'select', options: [] } as any}
/>
);
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(
<PercentCellRenderer
value={75}
field={{ name: 'progress', type: 'percent' } as any}
/>
);
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(
<PercentCellRenderer
value={50}
field={{ name: 'completion', type: 'percent' } as any}
/>
);
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(
<PercentCellRenderer
value={0.75}
field={{ name: 'probability', type: 'percent' } as any}
/>
);
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(
<PercentCellRenderer
value={0.5}
field={{ name: 'rate', type: 'percent' } as any}
/>
);
expect(screen.getByText('50%')).toBeInTheDocument();
const bar = container.querySelector('[role="progressbar"]');
expect(bar).toHaveAttribute('aria-valuenow', '50');
});
});
30 changes: 25 additions & 5 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
*/
Expand Down Expand Up @@ -210,6 +223,9 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
}

// 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
*/
Expand All @@ -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 (
Expand Down Expand Up @@ -378,7 +398,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
<div className="flex flex-wrap gap-1">
{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 (
Expand All @@ -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 (
Expand Down
28 changes: 24 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -508,9 +508,29 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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<string, any> = { 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),
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the accessorKey branch, inferred columns always overwrite any existing headerIcon on the incoming column object. If a consumer provided a custom headerIcon (but no custom cell), it will be lost. Consider only setting headerIcon when it’s not already present (e.g., prefer col.headerIcon if defined).

Suggested change
headerIcon: getTypeIcon(inferredType),
headerIcon: col.headerIcon ?? getTypeIcon(inferredType),

Copilot uses AI. Check for mistakes.
Comment on lines +527 to +530
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the accessorKey inference path, consider setting a default align: 'right' for inferred numeric types (number/currency/percent) when the incoming column doesn’t specify align. The ListColumn branch does this inference, so without it the two column formats render numeric fields with different alignment.

Suggested change
return {
...col,
headerIcon: getTypeIcon(inferredType),
const isNumericType =
inferredType === 'number' ||
inferredType === 'currency' ||
inferredType === 'percent';
return {
...col,
headerIcon: getTypeIcon(inferredType),
// Respect explicit align, otherwise default right for numeric-like types
align: col.align ?? (isNumericType ? 'right' : undefined),

Copilot uses AI. Check for mistakes.
cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} />,
};
});
}

// ListColumn format - convert to data-table format with full feature support
Expand All @@ -532,7 +552,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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;
Expand Down
Loading