Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit bbf068d

Browse files
os-steveclaude
andauthored
fix(plugin-grid): give ObjectGrid one owner for "declared type + format hint -> renderer" (#9010)
`@object-ui/fields` publishes a two-step resolve — `getCellRenderer(` applied to `resolveCellRendererType(...)` — because a textual base type carrying a `format` hint maps to a richer renderer than its declared type does. ObjectGrid spelled that resolve six times with three conventions: one two-step, four raw declared types, and one fixed registry key. On the four raw sites a `text` + `format: 'phone'` column silently resolved to `TextCellRenderer`, which reads only `value` — the hint was dropped with no error, no warning and no `tel:` link, in the flagship grid. Patching four sites to match one would have left the divergence in place, so the resolution itself moves into `./cellRendererResolution`: five sites take its field entry point, the compound-cell prefix badge takes its named fixed-key entry point (a different contract, documented rather than left silent), and ObjectGrid no longer imports either published resolver. The module names the two answers the word "resolve" used to collide on — `declaredType` (which type the field has) and `rendererType` (which renderer that type maps to). A local called `resolvedType` holding only the first is what made four broken sites read as correct. Alignment and the header type icon now follow `rendererType` on every path, as the object-columns path already did; the type forwarded to the inline editor stays `declaredType`, so a hinted text column still edits as text. Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB Co-authored-by: Claude <noreply@anthropic.com>
1 parent da7c3d9 commit bbf068d

5 files changed

Lines changed: 673 additions & 41 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
---
4+
5+
fix(plugin-grid): honour a column's `format` hint on every `ObjectGrid` render path
6+
7+
`@object-ui/fields` publishes a two-step resolve — `getCellRenderer(resolveCellRendererType(field))` — because a textual base type carrying a `format` hint (`phone`, `email`, `url`, `currency`, `percent`) maps to a richer renderer than its declared type does. `ObjectGrid` spelled that resolve six times with three conventions, and four of them passed the declared type straight to `getCellRenderer`. On those paths a `text` + `format: 'phone'` column silently fell back to plain truncated text: no error, no warning, no `tel:` link.
8+
9+
All six sites now route through one module that owns "declared type + format hint -> renderer", so a hinted column renders the same way whether its columns were declared as objects, as strings, derived from an authored `fields` projection, derived from the object schema, or read in the record-detail panel. Numeric alignment and the header type icon follow the same resolved renderer key on every path, while the type forwarded to the inline editor stays the declared one — a hinted text column still edits as text.

‎packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types
2727
import type { I18nLabel } from '@objectstack/spec/ui';
2828
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions } from '@object-ui/react';
2929
import { createSafeTranslation } from '@object-ui/i18n';
30-
import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields';
30+
// objectui#8920 — the grid reaches a cell renderer through THIS module and
31+
// nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately
32+
// NOT imported here: six sites spelling the resolve three different ways is
33+
// what dropped a `format`-hinted column's renderer, and one shared owner is
34+
// what stops a seventh site picking a convention of its own.
35+
import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution';
36+
import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields';
3137
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
3238
import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options';
3339
import {
@@ -2483,7 +2489,8 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
24832489

24842490
// Type-based cell renderer: explicit col type > objectDef type > heuristic inference.
24852491
// Format hints (e.g. `text` + `format: 'phone'`) promote to the
2486-
// richer renderer (PhoneCellRenderer) via resolveCellRendererType.
2492+
// richer renderer (PhoneCellRenderer) via the grid's one shared
2493+
// resolve, `./cellRendererResolution` (objectui#8920).
24872494
const objectDefField = objectSchema?.fields?.[col.field];
24882495
// ⭐ ANNOTATED, and the annotation is load-bearing (objectui#6004).
24892496
// `objectSchema` is `useState<any>`, so `objectDefField?.type` is
@@ -2497,10 +2504,11 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
24972504
// object-field fallback below is now the only road, which is what
24982505
// every measured author already used.
24992506
const formatHint = objectDefField?.format;
2500-
const inferredType: string | null = baseInferredType
2501-
? resolveCellRendererType({ type: baseInferredType, format: formatHint })
2502-
: null;
2503-
const CellRenderer = inferredType ? getCellRenderer(inferredType) : null;
2507+
// Both answers, from the one shared resolve (objectui#8920):
2508+
// `baseInferredType` is the DECLARED type the inline editor reads,
2509+
// `inferredType` the renderer key it promotes to.
2510+
const { rendererType: inferredType, Renderer } = resolveGridCellRendering({ type: baseInferredType, format: formatHint });
2511+
const CellRenderer = inferredType ? Renderer : null;
25042512

25052513
// Build field metadata for cell renderers with objectDef enrichment
25062514
const fieldMeta: Record<string, any> = { name: col.field, type: inferredType || 'text' };
@@ -2636,12 +2644,17 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
26362644
const prefixConfig = col.prefix;
26372645
if (prefixConfig?.field) {
26382646
const baseCellRenderer = cellRenderer;
2639-
const PrefixRenderer = prefixConfig.type === 'badge' ? getCellRenderer('select') : null;
2647+
// ⭐ The one site whose contract is NOT "declared type + format
2648+
// hint": a FIXED registry key for the badge, owned by this
2649+
// component rather than by the prefixed field (objectui#8920).
2650+
// Named and routed through the same module so it reads as the
2651+
// declared exception it is, not as a fifth silent convention.
2652+
const PrefixRenderer = prefixConfig.type === 'badge' ? gridCellRendererForFixedKey(BADGE_PREFIX_RENDERER_KEY) : null;
26402653
cellRenderer = (value: any, row: any) => {
26412654
const prefixValue = row[prefixConfig.field];
26422655
const prefixEl = prefixValue != null && prefixValue !== ''
26432656
? PrefixRenderer
2644-
? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: 'select' } as any} />
2657+
? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: BADGE_PREFIX_RENDERER_KEY } as any} />
26452658
: <span className="text-muted-foreground text-xs mr-1.5">{String(prefixValue)}</span>
26462659
: null;
26472660
return (
@@ -2698,16 +2711,29 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
26982711
const rawHeader = rawFieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
26992712
const header = schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, rawHeader) : rawHeader;
27002713

2701-
// Resolve type: objectDef type > heuristic inference (consistent with ListColumn path)
2702-
// Annotated for the same reason as path A's `baseInferredType`
2703-
// above: `fieldDef` is `any`, and an `any` reaching the `...(resolvedType
2704-
// && { type: resolvedType })` spread below collapses the emit literal
2705-
// to `any` (objectui#6004).
2706-
const resolvedType: string | null = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
2707-
const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
2714+
// TWO resolves, two names (objectui#8920). "Resolve type" here means
2715+
// objectDef type > heuristic inference — WHICH TYPE THE FIELD HAS,
2716+
// and that is `declaredType`. The published second step, WHICH
2717+
// RENDERER THE TYPE MAPS TO, is `rendererType`; this path used to
2718+
// skip it entirely, so a `text` + `format: 'phone'` column got
2719+
// `TextCellRenderer` and the hint vanished with no diagnostic.
2720+
// A local called `resolvedType` holding only the FIRST answer is the
2721+
// trap that hid that for four of the six sites.
2722+
//
2723+
// The `string | null` annotation path A's `baseInferredType` needs
2724+
// (objectui#6004: `fieldDef` is `any`, and an `any` reaching the
2725+
// `...(declaredType && { type: declaredType })` spread below
2726+
// collapses the emit literal) now lives on `GridCellRendering`'s
2727+
// members — it moved into the helper's return type, it did not go
2728+
// away.
2729+
const { declaredType, rendererType, Renderer } = resolveGridCellRendering({
2730+
type: fieldDef?.type || inferColumnType({ field: fieldName }),
2731+
format: fieldDef?.format,
2732+
});
2733+
const CellRenderer = rendererType ? Renderer : null;
27082734

27092735
// Build field metadata with objectDef enrichment
2710-
const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
2736+
const fieldMeta: Record<string, any> = { name: fieldName, type: rendererType || 'text' };
27112737
if (fieldDef) {
27122738
if (fieldDef.label) fieldMeta.label = fieldDef.label;
27132739
if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
@@ -2721,16 +2747,16 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
27212747
// reads the schema def directly, see `renderCellEditor` (objectui#7154).
27222748
applyRelationalMeta(fieldMeta, fieldDef as any);
27232749
// Auto-generate select options from data when no options defined
2724-
if (resolvedType === 'select' && !fieldMeta.options) {
2750+
if (rendererType === 'select' && !fieldMeta.options) {
27252751
const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
27262752
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
27272753
}
2728-
if ((resolvedType === 'select' || resolvedType === 'status') && (fieldDef as any)?.appearance != null) {
2754+
if ((rendererType === 'select' || rendererType === 'status') && (fieldDef as any)?.appearance != null) {
27292755
fieldMeta.appearance = (fieldDef as any).appearance;
27302756
}
27312757

27322758
const numericTypes = ['number', 'currency', 'percent'];
2733-
const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
2759+
const inferredAlign = rendererType && numericTypes.includes(rendererType) ? 'right' as const : undefined;
27342760

27352761
// Auto-link primary field (first column) to record detail
27362762
const isPrimaryField = colIndex === 0;
@@ -2768,9 +2794,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
27682794
return {
27692795
header,
27702796
accessorKey: fieldName,
2771-
// Forward the resolved field type for the type-aware inline editor.
2772-
...(resolvedType && { type: resolvedType }),
2773-
...(schema.showColumnTypeIcons && resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
2797+
// Forward the DECLARED type for the type-aware inline editor — the
2798+
// renderer key would make a `format`-hinted text column edit as a
2799+
// phone/currency control it never declared. Path A forwards
2800+
// `baseInferredType` for exactly this reason (objectui#8920).
2801+
...(declaredType && { type: declaredType }),
2802+
...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }),
27742803
...(inferredAlign && { align: inferredAlign }),
27752804
...(cellRenderer && { cell: cellRenderer }),
27762805
sortable: fieldDef?.sortable !== false,
@@ -2859,13 +2888,18 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
28592888
});
28602889
return fieldsToShow.map((fieldName) => {
28612890
const fieldDef = objectSchema?.fields?.[fieldName];
2862-
// Annotated for the same reason as paths A and B (objectui#6004).
2863-
const resolvedType: string | null = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
2864-
const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
2891+
// The same two resolves as path B, through the same shared owner
2892+
// (objectui#8920) — and the same objectui#6004 annotation, now
2893+
// carried by `GridCellRendering`'s `string | null` members.
2894+
const { declaredType, rendererType, Renderer } = resolveGridCellRendering({
2895+
type: fieldDef?.type || inferColumnType({ field: fieldName }),
2896+
format: fieldDef?.format,
2897+
});
2898+
const CellRenderer = rendererType ? Renderer : null;
28652899
const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
28662900

28672901
// Build field metadata with objectDef enrichment
2868-
const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
2902+
const fieldMeta: Record<string, any> = { name: fieldName, type: rendererType || 'text' };
28692903
if (fieldDef) {
28702904
if (fieldDef.label) fieldMeta.label = fieldDef.label;
28712905
if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
@@ -2879,23 +2913,26 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
28792913
// reads the schema def directly, see `renderCellEditor` (objectui#7154).
28802914
applyRelationalMeta(fieldMeta, fieldDef as any);
28812915
// Auto-generate select options from data when no options defined
2882-
if (resolvedType === 'select' && !fieldMeta.options) {
2916+
if (rendererType === 'select' && !fieldMeta.options) {
28832917
const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
28842918
fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
28852919
}
2886-
if ((resolvedType === 'select' || resolvedType === 'status') && (fieldDef as any)?.appearance != null) {
2920+
if ((rendererType === 'select' || rendererType === 'status') && (fieldDef as any)?.appearance != null) {
28872921
fieldMeta.appearance = (fieldDef as any).appearance;
28882922
}
28892923

28902924
const numericTypes = ['number', 'currency', 'percent'];
2891-
const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
2925+
const inferredAlign = rendererType && numericTypes.includes(rendererType) ? 'right' as const : undefined;
28922926

28932927
return {
28942928
header,
28952929
accessorKey: fieldName,
2896-
// Forward the resolved field type for the type-aware inline editor.
2897-
...(resolvedType && { type: resolvedType }),
2898-
...(schema.showColumnTypeIcons && resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
2930+
// Forward the DECLARED type for the type-aware inline editor — the
2931+
// renderer key would make a `format`-hinted text column edit as a
2932+
// phone/currency control it never declared. Path A forwards
2933+
// `baseInferredType` for exactly this reason (objectui#8920).
2934+
...(declaredType && { type: declaredType }),
2935+
...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }),
28992936
...(inferredAlign && { align: inferredAlign }),
29002937
...(CellRenderer && { cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} /> }),
29012938
sortable: fieldDef?.sortable !== false,
@@ -2980,9 +3017,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
29803017
&& !perms.checkField(schema.objectName, fieldName, 'read')) return;
29813018

29823019
// Annotated for the same reason as paths A-C (objectui#6004): `field` is
2983-
// `any`, so this value has to be named before it reaches a spread below.
2984-
const fieldType: string | undefined = field.type;
2985-
const CellRenderer = getCellRenderer(field.type);
3020+
// `any`, so these values have to be named before they reach a spread
3021+
// below — the naming now lives on `GridCellRendering`'s `string | null`
3022+
// members. `fieldType` is the DECLARED type the emit forwards to the
3023+
// inline editor; the renderer comes from the `format`-promoted key, which
3024+
// this path used to skip (objectui#8920).
3025+
const { declaredType: fieldType, rendererType, Renderer: CellRenderer } = resolveGridCellRendering(field);
29863026
const numericTypes = ['number', 'currency', 'percent'];
29873027
const translatedField = field.options
29883028
? { ...field, options: translateOptions(schema.objectName, fieldName, field.options) }
@@ -2993,7 +3033,9 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
29933033
accessorKey: fieldName,
29943034
// Forward the field type for the type-aware inline editor.
29953035
...(fieldType && { type: fieldType }),
2996-
...(numericTypes.includes(field.type) && { align: 'right' as const }),
3036+
// Aligned on the RENDERER key, like paths A-C: a `text` column with
3037+
// `format: 'currency'` renders as currency, so it aligns as currency.
3038+
...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }),
29973039
cell: (value: any) => <CellRenderer value={value} field={fieldForCell} />,
29983040
sortable: field.sortable !== false,
29993041
});
@@ -4433,11 +4475,17 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
44334475

44344476
// Use objectSchema field type for type-aware rendering
44354477
const fieldDef = objectSchema?.fields?.[key];
4436-
if (fieldDef?.type) {
4437-
const CellRenderer = getCellRenderer(fieldDef.type);
4438-
if (CellRenderer) {
4439-
return <CellRenderer value={value} field={fieldDef} />;
4440-
}
4478+
// Through the shared resolve, so the panel honours a `format` hint the
4479+
// same way the row above it does (objectui#8920). `rendererType` is null
4480+
// exactly when the key has no declared type, which is the guard this
4481+
// used to spell as `if (fieldDef?.type)`. The old inner
4482+
// `if (CellRenderer)` was DEAD — `getCellRenderer` ends in
4483+
// `standardMap[key] || TextCellRenderer` and never returns anything
4484+
// falsy — and `GridCellRendering.Renderer` states that totality in the
4485+
// type, so the dead branch goes with it.
4486+
const { rendererType, Renderer } = resolveGridCellRendering(fieldDef);
4487+
if (rendererType) {
4488+
return <Renderer value={value} field={fieldDef} />;
44414489
}
44424490

44434491
// Fallback: infer from value and key name

0 commit comments

Comments
 (0)