Skip to content

Commit 2034218

Browse files
authored
Merge branch 'main' into copilot/optimize-platform-ui-schema
2 parents 72e8801 + f8450a1 commit 2034218

File tree

5 files changed

+316
-11
lines changed

5 files changed

+316
-11
lines changed

ROADMAP.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,9 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
988988
- [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.
989989
- [x] Date/datetime human-friendly display: `DateCellRenderer` (relative format) and `DateTimeCellRenderer` (split date/time) already registered in field registry for all grid/table views.
990990
- [x] Currency/status/boolean renderers: Already implemented with proper formatting (currency symbol, Badge colors, checkbox display).
991+
- [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.
992+
- [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.
993+
- [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%`.
991994

992995
**Header & Breadcrumb i18n:**
993996
- [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
9971000

9981001
**Tests:**
9991002
- [x] 46 NavigationRenderer tests passing (pin/favorites/search/reorder)
1000-
- [x] 75 field cell renderer tests passing (date/datetime/select/boolean/percent)
1001-
- [x] 263 ObjectGrid tests passing (inference, rendering, accessibility)
1003+
- [x] 86 field cell renderer tests passing (date/datetime/select/boolean/percent/humanizeLabel/progress)
1004+
- [x] 293 ObjectGrid tests passing (inference, rendering, accessibility, accessorKey-format inference)
10021005
- [x] 28 DataTable tests passing
10031006
- [x] 78 Layout tests passing (NavigationRenderer + AppSchemaRenderer)
10041007
- [x] 11 AppSidebar tests passing

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

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

packages/fields/src/index.tsx

Lines changed: 25 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
*/
@@ -210,6 +223,9 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
210223
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
211224
}
212225

226+
// Fields that store percentage values as whole numbers (0-100) rather than fractions (0-1)
227+
const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
228+
213229
/**
214230
* Percent field cell renderer with mini progress bar
215231
*/
@@ -219,9 +235,13 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
219235
const percentField = field as any;
220236
const precision = percentField.precision ?? 0;
221237
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;
238+
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
239+
// Fields like "progress" or "completion" store values as 0-100, not 0-1
240+
const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
241+
const barValue = isWholePercentField
242+
? numValue
243+
: (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
244+
const formatted = isWholePercentField ? `${numValue.toFixed(precision)}%` : formatPercent(numValue, precision);
225245
const clampedBar = Math.max(0, Math.min(100, barValue));
226246

227247
return (
@@ -378,7 +398,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
378398
<div className="flex flex-wrap gap-1">
379399
{value.map((val, idx) => {
380400
const option = options.find(opt => opt.value === val);
381-
const label = option?.label || val;
401+
const label = option?.label || humanizeLabel(String(val));
382402
const colorClasses = getBadgeColorClasses(option?.color, val);
383403

384404
return (
@@ -397,7 +417,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
397417

398418
// Handle single value
399419
const option = options.find(opt => opt.value === value);
400-
const label = option?.label || value;
420+
const label = option?.label || humanizeLabel(String(value));
401421
const colorClasses = getBadgeColorClasses(option?.color, value);
402422

403423
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, useObjectTranslation } 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,
@@ -560,9 +560,29 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
560560
if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) {
561561
const firstCol = cols[0] as any;
562562

563-
// Already in data-table format - use as-is
563+
// Already in data-table format - apply type inference for columns without custom cell renderers
564564
if ('accessorKey' in firstCol) {
565-
return cols;
565+
return (cols as any[]).map((col) => {
566+
if (col.cell) return col; // already has custom renderer
567+
568+
const syntheticCol: ListColumn = { field: col.accessorKey, label: col.header, type: col.type };
569+
const inferredType = inferColumnType(syntheticCol);
570+
if (!inferredType) return col;
571+
572+
const CellRenderer = getCellRenderer(inferredType);
573+
const fieldMeta: Record<string, any> = { name: col.accessorKey, type: inferredType };
574+
575+
if (inferredType === 'select') {
576+
const uniqueValues = Array.from(new Set(data.map(row => row[col.accessorKey]).filter(Boolean)));
577+
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
578+
}
579+
580+
return {
581+
...col,
582+
headerIcon: getTypeIcon(inferredType),
583+
cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} />,
584+
};
585+
});
566586
}
567587

568588
// ListColumn format - convert to data-table format with full feature support
@@ -584,7 +604,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
584604
if (inferredType === 'select' && !(col as any).options) {
585605
// Auto-generate options from unique data values for inferred select fields
586606
const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean)));
587-
fieldMeta.options = uniqueValues.map(v => ({ value: v, label: String(v) }));
607+
fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) }));
588608
}
589609
if ((col as any).options) {
590610
fieldMeta.options = (col as any).options;

0 commit comments

Comments
 (0)