Skip to content

Commit e3c1079

Browse files
Copilothotlong
andcommitted
fix: apply type inference to accessorKey-format columns, add humanizeLabel, fix PercentCellRenderer for progress fields
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 992fb45 commit e3c1079

3 files changed

Lines changed: 175 additions & 9 deletions

File tree

packages/fields/src/__tests__/cell-renderers.test.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,3 +734,132 @@ describe('UserCellRenderer', () => {
734734
expect(screen.getByTitle('Bob')).toBeInTheDocument();
735735
});
736736
});
737+
738+
// =========================================================================
739+
// 9. humanizeLabel
740+
// =========================================================================
741+
import { humanizeLabel, PercentCellRenderer } from '../index';
742+
743+
describe('humanizeLabel', () => {
744+
it('should convert snake_case to Title Case', () => {
745+
expect(humanizeLabel('in_progress')).toBe('In Progress');
746+
});
747+
748+
it('should convert kebab-case to Title Case', () => {
749+
expect(humanizeLabel('high-priority')).toBe('High Priority');
750+
});
751+
752+
it('should handle single word', () => {
753+
expect(humanizeLabel('active')).toBe('Active');
754+
});
755+
756+
it('should handle already Title Case', () => {
757+
expect(humanizeLabel('Active')).toBe('Active');
758+
});
759+
760+
it('should handle multiple underscores', () => {
761+
expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started');
762+
});
763+
764+
it('should handle empty string', () => {
765+
expect(humanizeLabel('')).toBe('');
766+
});
767+
768+
it('should handle mixed separators', () => {
769+
expect(humanizeLabel('in_progress-now')).toBe('In Progress Now');
770+
});
771+
});
772+
773+
// =========================================================================
774+
// 10. SelectCellRenderer humanizeLabel fallback
775+
// =========================================================================
776+
describe('SelectCellRenderer humanizeLabel fallback', () => {
777+
it('should humanize snake_case value when no option label exists', () => {
778+
render(
779+
<SelectCellRenderer
780+
value="in_progress"
781+
field={{ name: 'status', type: 'select', options: [] } as any}
782+
/>
783+
);
784+
expect(screen.getByText('In Progress')).toBeInTheDocument();
785+
});
786+
787+
it('should prefer explicit option.label over humanized fallback', () => {
788+
render(
789+
<SelectCellRenderer
790+
value="in_progress"
791+
field={{
792+
name: 'status',
793+
type: 'select',
794+
options: [{ value: 'in_progress', label: 'WIP' }],
795+
} as any}
796+
/>
797+
);
798+
expect(screen.getByText('WIP')).toBeInTheDocument();
799+
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
800+
});
801+
802+
it('should humanize snake_case values in arrays', () => {
803+
render(
804+
<SelectCellRenderer
805+
value={['not_started', 'in_progress']}
806+
field={{ name: 'status', type: 'select', options: [] } as any}
807+
/>
808+
);
809+
expect(screen.getByText('Not Started')).toBeInTheDocument();
810+
expect(screen.getByText('In Progress')).toBeInTheDocument();
811+
});
812+
});
813+
814+
// =========================================================================
815+
// 11. PercentCellRenderer with progress-type fields
816+
// =========================================================================
817+
describe('PercentCellRenderer progress-type fields', () => {
818+
it('should render progress field value 75 as 75% with correct bar', () => {
819+
const { container } = render(
820+
<PercentCellRenderer
821+
value={75}
822+
field={{ name: 'progress', type: 'percent' } as any}
823+
/>
824+
);
825+
expect(screen.getByText('75%')).toBeInTheDocument();
826+
const bar = container.querySelector('[role="progressbar"]');
827+
expect(bar).toHaveAttribute('aria-valuenow', '75');
828+
});
829+
830+
it('should render completion field value 50 as 50% with correct bar', () => {
831+
const { container } = render(
832+
<PercentCellRenderer
833+
value={50}
834+
field={{ name: 'completion', type: 'percent' } as any}
835+
/>
836+
);
837+
expect(screen.getByText('50%')).toBeInTheDocument();
838+
const bar = container.querySelector('[role="progressbar"]');
839+
expect(bar).toHaveAttribute('aria-valuenow', '50');
840+
});
841+
842+
it('should render probability field value 0.75 as 75%', () => {
843+
const { container } = render(
844+
<PercentCellRenderer
845+
value={0.75}
846+
field={{ name: 'probability', type: 'percent' } as any}
847+
/>
848+
);
849+
expect(screen.getByText('75%')).toBeInTheDocument();
850+
const bar = container.querySelector('[role="progressbar"]');
851+
expect(bar).toHaveAttribute('aria-valuenow', '75');
852+
});
853+
854+
it('should render rate field value 0.5 as 50%', () => {
855+
const { container } = render(
856+
<PercentCellRenderer
857+
value={0.5}
858+
field={{ name: 'rate', type: 'percent' } as any}
859+
/>
860+
);
861+
expect(screen.getByText('50%')).toBeInTheDocument();
862+
const bar = container.querySelector('[role="progressbar"]');
863+
expect(bar).toHaveAttribute('aria-valuenow', '50');
864+
});
865+
});

packages/fields/src/index.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ export function formatPercent(value: number, precision: number = 0): string {
104104
return `${displayValue.toFixed(precision)}%`;
105105
}
106106

107+
/**
108+
* Humanize a snake_case or kebab-case string into Title Case.
109+
* Used as fallback label when no explicit option.label exists.
110+
*
111+
* Examples:
112+
* "in_progress" → "In Progress"
113+
* "high-priority" → "High Priority"
114+
* "active" → "Active"
115+
*/
116+
export function humanizeLabel(value: string): string {
117+
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
118+
}
119+
107120
/**
108121
* Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
109122
*/
@@ -219,9 +232,13 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
219232
const percentField = field as any;
220233
const precision = percentField.precision ?? 0;
221234
const numValue = Number(value);
222-
const formatted = formatPercent(numValue, precision);
223-
// Normalize to 0-100 range for progress bar
224-
const barValue = (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
235+
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
236+
// Fields like "progress" or "completion" store values as 0-100, not 0-1
237+
const isWholePercentField = /progress|completion/.test(field?.name?.toLowerCase() || '');
238+
const barValue = isWholePercentField
239+
? numValue
240+
: (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
241+
const formatted = isWholePercentField ? `${numValue.toFixed(precision)}%` : formatPercent(numValue, precision);
225242
const clampedBar = Math.max(0, Math.min(100, barValue));
226243

227244
return (
@@ -378,7 +395,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
378395
<div className="flex flex-wrap gap-1">
379396
{value.map((val, idx) => {
380397
const option = options.find(opt => opt.value === val);
381-
const label = option?.label || val;
398+
const label = option?.label || humanizeLabel(String(val));
382399
const colorClasses = getBadgeColorClasses(option?.color, val);
383400

384401
return (
@@ -397,7 +414,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
397414

398415
// Handle single value
399416
const option = options.find(opt => opt.value === value);
400-
const label = option?.label || value;
417+
const label = option?.label || humanizeLabel(String(value));
401418
const colorClasses = getBadgeColorClasses(option?.color, value);
402419

403420
return (

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import React, { useEffect, useState, useCallback, useMemo } from 'react';
2525
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
2626
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
27-
import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent } from '@object-ui/fields';
27+
import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel } from '@object-ui/fields';
2828
import {
2929
Badge, Button, NavigationOverlay,
3030
Popover, PopoverContent, PopoverTrigger,
@@ -508,9 +508,29 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
508508
if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) {
509509
const firstCol = cols[0] as any;
510510

511-
// Already in data-table format - use as-is
511+
// Already in data-table format - apply type inference for columns without custom cell renderers
512512
if ('accessorKey' in firstCol) {
513-
return cols;
513+
return (cols as any[]).map((col) => {
514+
if (col.cell) return col; // already has custom renderer
515+
516+
const syntheticCol: ListColumn = { field: col.accessorKey, label: col.header, type: col.type };
517+
const inferredType = inferColumnType(syntheticCol);
518+
if (!inferredType) return col;
519+
520+
const CellRenderer = getCellRenderer(inferredType);
521+
const fieldMeta: Record<string, any> = { name: col.accessorKey, type: inferredType };
522+
523+
if (inferredType === 'select') {
524+
const uniqueValues = Array.from(new Set(data.map(row => row[col.accessorKey]).filter(Boolean)));
525+
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
526+
}
527+
528+
return {
529+
...col,
530+
headerIcon: getTypeIcon(inferredType),
531+
cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} />,
532+
};
533+
});
514534
}
515535

516536
// ListColumn format - convert to data-table format with full feature support
@@ -532,7 +552,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
532552
if (inferredType === 'select' && !(col as any).options) {
533553
// Auto-generate options from unique data values for inferred select fields
534554
const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean)));
535-
fieldMeta.options = uniqueValues.map(v => ({ value: v, label: String(v) }));
555+
fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) }));
536556
}
537557
if ((col as any).options) {
538558
fieldMeta.options = (col as any).options;

0 commit comments

Comments
 (0)