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();
+ });
+});