From 71949b5a589a739d6951eb1117a068eb496199c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:11:51 +0000 Subject: [PATCH 1/7] fix(plugin-grid,plugin-detail,plugin-dashboard): withhold an untyped column while the object's field types are unknown (objectui#10657) An object-bound table whose rows paint before its object definition is read (ObjectGrid on the host-fetched path, RelatedList, ObjectDataTable) drew an untyped column over a password / secret field as text in that window, and for good when the read failed. Such a column is now WITHHELD: it draws the mask (MaskedCellRenderer, now exported from @object-ui/fields) and is flagged masked, and it stays withheld when the read fails. ListView hands the grid the field catalogue it already holds (objectFields, the ruled host channel), so a list view has no window at all. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- packages/fields/src/index.tsx | 16 +- .../plugin-dashboard/src/ObjectDataTable.tsx | 26 ++- packages/plugin-detail/src/RelatedList.tsx | 28 ++- packages/plugin-grid/src/ObjectGrid.tsx | 177 ++++++++++++++---- packages/plugin-grid/src/maskedColumn.ts | 66 +++++-- packages/plugin-list/src/ListView.tsx | 14 ++ 6 files changed, 269 insertions(+), 58 deletions(-) diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index b899f552bd..b1b5b302ce 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3362,8 +3362,22 @@ function RepeaterCellRenderer({ value }: CellRendererProps): React.ReactElement * states: `EmptyValue` holds a hook, and an inline arrow in the table below * is a new component type on every resolution, so it would tear that hook down * per render. + * + * Exported (objectui#10657) for the cell a producer cannot type yet. While an + * object-bound table is still waiting for its object's field types, or after + * that read failed, a column with no type of its own could be a credential, + * so `ObjectGrid`, `RelatedList` and `ObjectDataTable` draw it WITHHELD: as + * this mask, never as text. It is this component and not the live registry + * entry for `password`, so a host override of a masked type cannot change what + * a withheld cell draws. Registering it for a type + * (`registerFieldRenderer('api_token', MaskedCellRenderer)`) masks that type, + * as {@link isMaskedFieldType} describes. `field` is optional: the mask reads + * only whether the value is present, so a withheld cell has no field metadata + * to invent. */ -function MaskedCellRenderer({ value }: CellRendererProps): React.ReactElement { +export function MaskedCellRenderer({ + value, +}: Omit & { field?: CellRendererProps['field'] }): React.ReactElement { if (isEmptyValue(coerceToSafeValue(value))) return ; return ••••••; } diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 8dcddfd033..402c1b9535 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -17,7 +17,7 @@ import { import type { ObjectDataTableSchema, TableColumn } from '@object-ui/types'; import { normalizeTableColumnType } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; -import { isMaskedFieldType } from '@object-ui/fields'; +import { isMaskedFieldType, MaskedCellRenderer } from '@object-ui/fields'; import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { resolveFilterPlaceholders, humanizeFieldKey } from './utils'; @@ -413,6 +413,15 @@ export type AuthoredColumnOverrides = */ const EMPTY_ROWS = Object.freeze([]) as unknown as any[]; +/** + * The cell a WITHHELD column draws (objectui#10657): the mask, never the + * value. See `enrich` for when a column is withheld. Module-level, so the + * column keeps one `cell` identity across renders. + */ +function withheldCell(value: unknown): React.ReactNode { + return React.createElement(MaskedCellRenderer, { value }); +} + /** * Normalize columns to support both string[] shorthand and object[] formats. * @@ -948,6 +957,21 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo if (typeof col.cell === 'function') return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, ...maskedStamp }; + // ⭐ WITHHELD (objectui#10657, the objectui#10706 class at this + // producer). The flag above withholds; it does not DRAW. `cell` draws + // from `fieldMeta.type`, which is the authored `type` or the object's, + // so while the object's types are unknown a column that authors none has + // no type, and the cell would draw its value as text: a `password` / + // `secret` field in the clear, and for good when the read failed. Such a + // column draws the mask instead until the definition lands, and keeps + // drawing it when the read failed (fail closed, never falling back to + // text). A column that authors its own `type` draws from it, as it does + // once the definition is in hand: `password` masks, `text` draws the + // text it was told to. + if (objectTypesPending && !authored.type) { + return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell: withheldCell, ...maskedStamp }; + } + // Tenant-default currency backstops a currency column with no explicit code. const cell = (value: any): React.ReactNode => renderFieldValue(value, fieldMeta, tenantCurrency, displayLocale); return { ...col, name: fieldMeta.name, type: columnType, align: inferredAlign, cell, ...maskedStamp }; diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index f040a1f558..5b238d3063 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -41,7 +41,7 @@ import { import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; import type { ViewFilterRule } from '@objectstack/spec/ui'; -import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer } from '@object-ui/fields'; import { buildExpandFields, collectPredicateFieldRefs, @@ -349,6 +349,15 @@ type FieldReadPolicy = ReturnType; */ const MOBILE_GALLERY_COVER_FIELD = 'image'; +/** + * The cell a WITHHELD column draws (objectui#10657): the mask, never the + * value. See `tableColumns` for when a column is withheld. Module-level, so + * the column keeps one `cell` identity across renders. + */ +function withheldCell(value: unknown): React.ReactElement { + return React.createElement(MaskedCellRenderer, { value }); +} + /** * The key this component DRAWS a column through: the table library's * `accessorKey` first, then the shared metadata reader. Every column gate below @@ -1979,6 +1988,19 @@ export const RelatedList: React.FC = ({ * one, so every column is stamped. A column the loaded definition does not * declare is judged on its authored `type` alone. * + * WITHHELD, not only stamped (objectui#10657, the objectui#10706 class at + * this producer): in that same window the columns' cells cannot draw a mask + * either. This list draws a cell from the OBJECT's field type + * (`makeCell`), never from the column's authored `type`, so with no + * definition in hand a column has no cell and the table would draw its value + * as text — a `password` / `secret` field in the clear, and for good when + * the read failed. So every column without a `cell` of its own draws the + * mask ({@link withheldCell}) until the definition lands, and keeps drawing + * it when the read failed: fail closed, never falling back to text. A + * `cell` the AUTHOR supplied draws what the author chose (none is attached + * here while the definition is missing, so any `cell` in that window is + * theirs). + * * With nothing to stamp, the list is handed on BY REFERENCE, as * `sortableColumns` hands it on (the data-table re-seed, objectui#4618). */ @@ -1988,7 +2010,9 @@ export const RelatedList: React.FC = ({ const field = col.accessorKey || columnIdentity(col); const fieldDef = field ? objectSchema?.fields?.[field] : undefined; const masked = objectTypesPending || isMaskedDetailFieldType(col.type, fieldDef?.type); - return masked && col.masked !== true ? { ...col, masked: true } : col; + const withheld = objectTypesPending && typeof col.cell !== 'function'; + if (!withheld) return masked && col.masked !== true ? { ...col, masked: true } : col; + return { ...col, masked: true, cell: withheldCell }; }); return stamped.every((col, i) => col === sortableColumns[i]) ? sortableColumns : stamped; }, [sortableColumns, objectSchema, objectTypesPending]); diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5a641f7282..9073ffe623 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -33,8 +33,8 @@ import { createSafeTranslation } from '@object-ui/i18n'; // what dropped a `format`-hinted column's renderer, and one shared owner is // what stops a seventh site picking a convention of its own. import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; -import { isMaskedGridColumn } from './maskedColumn'; -import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields'; +import { isMaskedGridColumn, isWithheldGridColumn } from './maskedColumn'; +import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -613,6 +613,31 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr schema: ObjectGridSchema; dataSource?: DataSource; className?: string; + /** + * [objectui#10657] The object's field catalogue (`fields` of the definition + * `getObjectSchema(objectName)` returns), handed down by a HOST that has + * already read it. `ListView` passes the definition it fetched before its + * rows; the grid answers every field-type question from it until its own + * read of the definition lands, and from its own read after that. + * + * ## Why a host hands it down + * + * A host that fetches the rows hands them down as `data`, so they paint + * before the grid's own schema read settles. Until then an untyped view + * column over a `password` / `secret` field has no type, and the grid can + * only withhold it (drawn as the mask, never as text). With the catalogue in + * hand there is no such window: the column draws, and is flagged, from its + * declared type at first paint. + * + * ## A RUNTIME prop, never authored metadata + * + * The name is the host channel decision batch #70 ruled for the object's + * field catalogue (objectui#7742): `SchemaRenderer` strips an AUTHORED + * `objectFields` on every spelling (objectui#8818), so only a host's React + * prop can reach this slot, and no view author can supply a catalogue that + * types a credential field as text. + */ + objectFields?: Readonly>; /** * [objectui#8674] Narrow ONE row's generic Edit / Delete entries — the layer * that lets a host withhold an operation the record itself cannot accept. @@ -1382,6 +1407,12 @@ function describeNonPositivePageSize( ); } +/** + * The grouping-refusal signature when no entry is refused (objectui#10583, + * objectui#10657): the authored grouping config is then handed on as it is. + */ +const NO_GROUPING_REFUSAL = JSON.stringify({ refused: [], warned: [] }); + export const ObjectGrid: React.FC = ({ schema, dataSource, @@ -1413,6 +1444,7 @@ export const ObjectGrid: React.FC = ({ onSearchChange: hostOnSearchChange, findParams: hostFindParams, onColumnStateChange, + objectFields: hostObjectFields, }) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -1605,6 +1637,32 @@ export const ObjectGrid: React.FC = ({ // (`ViewDataSchema` declares it required). Kept because this name gates the // permission verdicts below — see the note at `inlineEditable`. const objectName = resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName; + /** + * The field catalogue this grid answers field-type questions from + * (objectui#10657): its own object definition once that read has landed, + * the catalogue a host handed down (`objectFields`) until then. + * + * ⚠️ Only the field reads that decide how a column DRAWS and whether it is + * MASKED go through this — the column builders, the masked flag, the client + * export, the grouping, the mobile card. Everything that needs the whole + * definition (actions, `highlightFields`, the default-column policy) still + * waits for `objectSchema`, exactly as before. + */ + const objectFields: Record | undefined = + objectSchema?.fields ?? hostObjectFields ?? undefined; + /** + * Are this grid's OBJECT field types still unknown (objectui#10657, which + * folded objectui#10706)? True while an object definition is expected (an + * object to ask about, and a data source that can answer `getObjectSchema`) + * and no catalogue is in hand: the read is in flight, or it failed, which + * the inline path swallows as non-fatal. Rows a host hands down paint inside + * that window, so every untyped column is WITHHELD until it closes, and for + * good if it never does (`isWithheldGridColumn`). With no `getObjectSchema` + * there is nothing to wait for, and the authored column types are all this + * grid will ever know. + */ + const objectTypesPending = + !!objectName && typeof dataSource?.getObjectSchema === 'function' && !objectFields; // [#3391] Server-resolved effective API operation set for this object // (/me/permissions `apiOperations`). The Export button and handler AND their // gate with this — a missing set (unrestricted object / old backend / no @@ -1949,8 +2007,11 @@ export const ObjectGrid: React.FC = ({ setObjectSchema(schemaData); } } catch (err) { - // Schema fetch failure for inline data is non-fatal; columns will - // still fall back to heuristic inference. + // Schema fetch failure for inline data is non-fatal: the rows still + // draw. An untyped column does NOT fall back to heuristic inference, + // though: with no field types in hand it stays WITHHELD (drawn as the + // mask, handled as masked), because it could be a `password` / + // `secret` field — fail closed (objectui#10657, `objectTypesPending`). console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err); } }; @@ -2611,7 +2672,7 @@ export const ObjectGrid: React.FC = ({ for (const gf of groupingFields) { const fieldName = gf.field; - const objectDefField = objectSchema?.fields?.[fieldName]; + const objectDefField = objectFields?.[fieldName]; // Try to find a column override matching this field for type/options const cols = normalizeColumns(schema.columns) as any[] | undefined; const colOverride = cols?.find?.((c) => typeof c === 'object' && c?.field === fieldName); @@ -2657,11 +2718,9 @@ export const ObjectGrid: React.FC = ({ } return undefined; }; - }, [schema.grouping, schema.columns, schema.objectName, objectSchema, translateOptions, t]); + }, [schema.grouping, schema.columns, schema.objectName, objectFields, translateOptions, t]); - // objectui#10583 — a MASKED field is REFUSED as a grouping key, loudly, once - // `objectSchema` has loaded (until then an untyped column's object-declared - // type is unknown: the host-fetched window, objectui#10657, which folded objectui#10706). + // objectui#10583 — a MASKED field is REFUSED as a grouping key. // Grouping by it printed the raw value as each group's label. Masking the // label would not be enough: the buckets would still show which records // share a credential, ordered by its raw value. So the entry is dropped (the @@ -2669,26 +2728,40 @@ export const ObjectGrid: React.FC = ({ // one) and the drop is reported through the grid's warning channel. The rule // is the column flag's: `isMaskedGridColumn` over the view column's type and // the object-declared type. + // + // objectui#10657 (which folded objectui#10706) — the same rule's third + // argument: while the object's field types are unknown (`objectTypesPending`, + // the host-fetched window, or a failed read), an entry on a field whose view + // column authors no type is WITHHELD and so refused too, since its group + // label would print whatever the field holds. The group label follows the + // cell: a withheld cell draws the mask, and a withheld key labels no group. + // Once the types are known the entry groups again, unless its field is + // masked. The refusal of a withheld entry is not warned: it is not a finding + // about the authored grouping, and a failed read reports itself. const groupingFieldsRaw = schema.grouping?.fields; const maskedGroupingSignature = React.useMemo(() => { const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; const columnTypeOf = (field: string) => cols.find((c): c is ListColumn => typeof c === 'object' && c !== null && c.field === field)?.type; - return JSON.stringify( - usableGroupingFields(groupingFieldsRaw) - .map((gf) => gf.field) - .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectSchema?.fields?.[field]?.type)), - ); - }, [groupingFieldsRaw, schema.columns, objectSchema]); + const refused = usableGroupingFields(groupingFieldsRaw) + .map((gf) => gf.field) + .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, objectTypesPending)); + return JSON.stringify({ + refused, + // The ones refused on a KNOWN masked type, which is what the warning names. + warned: refused.filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, false)), + }); + }, [groupingFieldsRaw, schema.columns, objectFields, objectTypesPending]); // Keyed on the authored array and the signature STRING, never on a memo's // identity (AGENTS.md #10). Read only when something was refused: the // unmasked path below hands `useGroupedData` the authored config itself. const unmaskedGroupingFields = React.useMemo(() => { - const masked: string[] = JSON.parse(maskedGroupingSignature); - return usableGroupingFields(groupingFieldsRaw).filter((gf) => !masked.includes(gf.field)); + const { refused }: { refused: string[] } = JSON.parse(maskedGroupingSignature); + return usableGroupingFields(groupingFieldsRaw).filter((gf) => !refused.includes(gf.field)); }, [groupingFieldsRaw, maskedGroupingSignature]); + const groupingRefusesNothing = maskedGroupingSignature === NO_GROUPING_REFUSAL; useEffect(() => { - const masked: string[] = JSON.parse(maskedGroupingSignature); + const { warned: masked }: { warned: string[] } = JSON.parse(maskedGroupingSignature); if (masked.length === 0) return; console.warn( `[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked ` @@ -2698,7 +2771,7 @@ export const ObjectGrid: React.FC = ({ }, [maskedGroupingSignature, schema.objectName]); const { groups, isGrouped, toggleGroup } = useGroupedData( - maskedGroupingSignature === '[]' || !schema.grouping + groupingRefusesNothing || !schema.grouping ? schema.grouping : { ...schema.grouping, fields: unmaskedGroupingFields }, data, @@ -2955,7 +3028,7 @@ export const ObjectGrid: React.FC = ({ // No readable identity ⇒ nothing to ask the policy about. if (!fieldName) return true; // Undeclared ⇒ host-joined / derived ⇒ not this gate's business. - if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true; + if (!Object.prototype.hasOwnProperty.call(objectFields ?? {}, fieldName)) return true; return perms.checkField(schema.objectName, fieldName, 'read'); }; // ObjectStack's DECLARED column spelling is the only one read @@ -2997,7 +3070,7 @@ export const ObjectGrid: React.FC = ({ // name-derived header (e.g. "Request title") even when the field has a // localized label (e.g. "申请标题") on a non-English app. const rawHeader = resolveColumnLabel(col.label) - || resolveColumnLabel(objectSchema?.fields?.[col.field]?.label) + || resolveColumnLabel(objectFields?.[col.field]?.label) || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' '); const header = schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader; @@ -3008,7 +3081,13 @@ export const ObjectGrid: React.FC = ({ // Format hints (e.g. `text` + `format: 'phone'`) promote to the // richer renderer (PhoneCellRenderer) via the grid's one shared // resolve, `./cellRendererResolution` (objectui#8920). - const objectDefField = objectSchema?.fields?.[col.field]; + const objectDefField = objectFields?.[col.field]; + // objectui#10657 — an untyped column while the object's field types + // are unknown is WITHHELD: drawn as the mask, never as text, and + // flagged masked (`isWithheldGridColumn`). No type is inferred for + // it — a heuristic guess from the name or the values is exactly + // how a credential field ended up drawn as text. + const withheld = isWithheldGridColumn(col.type, objectTypesPending); // ⭐ ANNOTATED, and the annotation is load-bearing (objectui#6004). // `objectSchema` is `useState`, so `objectDefField?.type` is // `any` — and an `any` SPREAD into an object literal collapses the @@ -3016,7 +3095,9 @@ export const ObjectGrid: React.FC = ({ // in it. Measured: without this annotation the emit below infers // `any[]`, and `ObjectGridColumnDraft` cannot bite on any member. Naming // the producer vocabulary here stops `any` at this one boundary. - const baseInferredType: string | null = col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null; + const baseInferredType: string | null = withheld + ? null + : col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null; // objectui#6458 — the column-level `format` read is RETIRED. The // object-field fallback below is now the only road, which is what // every measured author already used. @@ -3025,7 +3106,7 @@ export const ObjectGrid: React.FC = ({ // `baseInferredType` is the DECLARED type the inline editor reads, // `inferredType` the renderer key it promotes to. const { rendererType: inferredType, Renderer } = resolveGridCellRendering({ type: baseInferredType, format: formatHint }); - const CellRenderer = inferredType ? Renderer : null; + const CellRenderer = withheld ? MaskedCellRenderer : inferredType ? Renderer : null; // Build field metadata for cell renderers with objectDef enrichment const fieldMeta: Record = { name: col.field, type: inferredType || 'text' }; @@ -3231,7 +3312,11 @@ export const ObjectGrid: React.FC = ({ .filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0) .filter((fieldName) => passesFieldGate(fieldName)) .map((fieldName, colIndex) => { - const fieldDef = objectSchema?.fields?.[fieldName]; + const fieldDef = objectFields?.[fieldName]; + // objectui#10657 — a bare field name carries no type of its own, so + // while the object's field types are unknown it is WITHHELD (see + // path A): the mask, no inferred type. + const withheld = isWithheldGridColumn(undefined, objectTypesPending); const rawFieldLabel = fieldDef?.label; const rawHeader = rawFieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '); const header = schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, rawHeader) : rawHeader; @@ -3252,10 +3337,10 @@ export const ObjectGrid: React.FC = ({ // members — it moved into the helper's return type, it did not go // away. const { declaredType, rendererType, Renderer } = resolveGridCellRendering({ - type: fieldDef?.type || inferColumnType({ field: fieldName }), + type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }), format: fieldDef?.format, }); - const CellRenderer = rendererType ? Renderer : null; + const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null; // Build field metadata with objectDef enrichment const fieldMeta: Record = { name: fieldName, type: rendererType || 'text' }; @@ -3410,19 +3495,24 @@ export const ObjectGrid: React.FC = ({ // `hasOwnProperty` rather than a truthiness read so an inherited // name (`constructor`, `toString`) cannot be mistaken for a declared // field and dropped. - if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true; + if (!Object.prototype.hasOwnProperty.call(objectFields ?? {}, fieldName)) return true; return perms.checkField(schema.objectName, fieldName, 'read'); }); return fieldsToShow.map((fieldName) => { - const fieldDef = objectSchema?.fields?.[fieldName]; + const fieldDef = objectFields?.[fieldName]; + // objectui#10657 — a row key carries no type of its own, so while + // the object's field types are unknown it is WITHHELD (see path A): + // the mask, no inferred type. This is the path a host-fetched grid + // with no authored columns paints first. + const withheld = isWithheldGridColumn(undefined, objectTypesPending); // The same two resolves as path B, through the same shared owner // (objectui#8920) — and the same objectui#6004 annotation, now // carried by `GridCellRendering`'s `string | null` members. const { declaredType, rendererType, Renderer } = resolveGridCellRendering({ - type: fieldDef?.type || inferColumnType({ field: fieldName }), + type: withheld ? null : fieldDef?.type || inferColumnType({ field: fieldName }), format: fieldDef?.format, }); - const CellRenderer = rendererType ? Renderer : null; + const CellRenderer = withheld ? MaskedCellRenderer : rendererType ? Renderer : null; const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '); // Build field metadata with objectDef enrichment @@ -3571,7 +3661,7 @@ export const ObjectGrid: React.FC = ({ }); return generatedColumns; - }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]); + }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]); // Formats this grid can actually deliver (objectui#2942): the server stream // handles csv/xlsx/json, the client fallback only csv/json. Declared-but-dead @@ -3709,10 +3799,13 @@ export const ObjectGrid: React.FC = ({ // stamps `TableColumn.masked` (`isMaskedGridColumn`, the narrow-only union // of the column's type and the object-declared type), asked per KEY // because the JSON branch writes whole records, including fields that are - // not columns. + // not columns. While the object's field types are unknown (objectui#10657), + // a key with no column type of its own is WITHHELD and leaves both files + // too: a record field no column types is judged by its object type alone, + // and there is none yet. const columnTypeByKey = new Map(generateColumns().map((c) => [c.accessorKey, c.type])); const isMaskedKey = (key: string) => - isMaskedGridColumn(columnTypeByKey.get(key), objectSchema?.fields?.[key]?.type); + isMaskedGridColumn(columnTypeByKey.get(key), objectFields?.[key]?.type, objectTypesPending); if (format === 'csv') { const cols = generateColumns().filter((c) => c.accessorKey !== '_actions' && !isMaskedKey(c.accessorKey)); @@ -3733,7 +3826,7 @@ export const ObjectGrid: React.FC = ({ downloadFile(new Blob([JSON.stringify(unmasked, null, 2)], { type: 'application/json' }), fileNameFor('json')); } setShowExport(false); - }, [data, schema.exportOptions, schema.operations?.export, effectiveApiOps, schema.objectName, objectName, objectSchema, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]); + }, [data, schema.exportOptions, schema.operations?.export, effectiveApiOps, schema.objectName, objectName, objectSchema, objectFields, objectTypesPending, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]); // objectui#9050 step 2 — a refused filter, from EITHER of this component's // two entries into the lowering: `schema.filter` (a render-time `useMemo`, @@ -3834,7 +3927,7 @@ export const ObjectGrid: React.FC = ({ // line converts a tolerated null into a throw, which is how the protection // would be lost a second time for a perfectly good reason. if (!col || col.accessorKey === '_actions') return col; - const fieldDef = (objectSchema as any)?.fields?.[col.accessorKey]; + const fieldDef = objectFields?.[col.accessorKey]; if (!fieldDef) return col; const next: ObjectGridColumnDraft = { ...col }; if (next.type == null && fieldDef.type) next.type = fieldDef.type; @@ -3893,7 +3986,12 @@ export const ObjectGrid: React.FC = ({ // type ahead of the object's, and `type: 'text'` over a `secret` column // must keep the flag. Written only when true, so every unmasked column // reaches the table byte-identical to before. - const rest = isMaskedGridColumn(producerType, objectSchema?.fields?.[col.accessorKey]?.type) + // + // objectui#10657 — the third argument: while the object's field types + // are unknown, a column with no type of its own is WITHHELD, and so + // flagged. `generateColumns()` gave it no inferred type and a mask for a + // cell; this is where every table path learns to withhold it as well. + const rest = isMaskedGridColumn(producerType, objectFields?.[col.accessorKey]?.type, objectTypesPending) ? { ...draft, masked: true } : draft; if (producerType == null) return rest; @@ -5358,8 +5456,11 @@ export const ObjectGrid: React.FC = ({ // below pick amount / stage / date / percent by the field's NAME and print // the raw value, so a masked column is never classified; it lands in the // `col.cell` branch, and the title row routes it through `cell` as well. + // A WITHHELD column (objectui#10657: the object's field types are still + // unknown, and the column has no type of its own) is handled the same way: + // its `cell` draws the mask. const isMaskedCardColumn = (key: string) => - isMaskedGridColumn(colMap.get(key)?.type, objectSchema?.fields?.[key]?.type); + isMaskedGridColumn(colMap.get(key)?.type, objectFields?.[key]?.type, objectTypesPending); const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => { if (isMaskedCardColumn(key)) return 'other'; diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 624977140c..19406bfdc6 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -10,23 +10,17 @@ import { isMaskedFieldType } from '@object-ui/fields'; /** * Is this grid column's cell drawn as a MASK, given the type the column - * carries and the type the object schema declares for its field? - * (objectui#10583) + * carries, the type the object schema declares for its field, and whether the + * object's field types are known yet? (objectui#10583, objectui#10657) * * `ObjectGrid` stamps `TableColumn.masked` from this answer, and `data-table` * obeys the flag: no Ctrl+C / Cmd+C copy, no `title` tooltip, no column in its - * CSV export, no inline edit. `ObjectGrid` also asks it directly where it - * handles values itself: its client export (CSV and JSON) leaves masked fields - * out, its mobile card draws them through `cell`, and its grouping refuses them - * as keys. `@object-ui/components` - * cannot import `@object-ui/fields`, which is why the answer is computed HERE, - * on the producer side, and handed across as a flag. - * - * ⚠️ Every one of those paths asks with the object-declared type, which the grid - * reads from the object schema it fetches after first paint. On the host-fetched - * path (rows handed down as `data`) an untyped view column is therefore - * unmasked until that schema arrives, and for good if the read fails - * (objectui#10657, which folded objectui#10706). + * CSV export, no inline edit, no client search or sort, no auto width from the + * values. `ObjectGrid` also asks it directly where it handles values itself: + * its client export (CSV and JSON) leaves masked fields out, its mobile card + * draws them through `cell`, and its grouping refuses them as keys. + * `@object-ui/components` cannot import `@object-ui/fields`, which is why the + * answer is computed HERE, on the producer side, and handed across as a flag. * * The rule itself is NOT restated here — it is `isMaskedFieldType()` from * `@object-ui/fields`, the one authority for "is this field type's cell drawn @@ -42,9 +36,49 @@ import { isMaskedFieldType } from '@object-ui/fields'; * credential. The declared cost is the mirror case — an object `text` column * a view authors as `password` is masked AND flagged, which is the answer its * cell already gives. + * + * **Fail closed while the object's types are unknown** (the third argument, + * see {@link isWithheldGridColumn}): a column with no type of its own is + * answered as masked until they are known, and for good if their read failed. + * The argument is required so that no caller can forget the window. + */ +export function isMaskedGridColumn( + columnType: unknown, + objectFieldType: unknown, + objectTypesPending: boolean, +): boolean { + return isMaskedType(columnType) + || isMaskedType(objectFieldType) + || isWithheldGridColumn(columnType, objectTypesPending); +} + +/** + * Is this grid column WITHHELD: handled as masked because nothing can say yet + * whether it is? (objectui#10657, which folded objectui#10706) + * + * An untyped view column over an object field takes its type from the object + * schema. While an object-bound grid is still waiting for that schema (its + * rows can paint first: a host hands them down as `data`), or after the read + * failed, the column's field could be a `password` / `secret` one, and + * drawing it as text would print the credential. So the column is withheld: + * its cell draws the mask (`MaskedCellRenderer` from `@object-ui/fields`), + * it carries the masked flag, and every path `isMaskedGridColumn` guards + * treats it as masked. It is never drawn as text in the meantime, and it stays + * withheld when the read fails (fail closed). + * + * A column that authors its own `type` is not withheld: that type decides its + * draw (`type: 'password'` masks, `type: 'text'` draws the text it was told + * to), exactly as it does once the schema has loaded. A host that already + * holds the object's fields hands them to the grid (`objectFields`, as + * `ListView` does), so there is no window to withhold anything in. + * + * `objectTypesPending` is the grid's own reading: an object to ask about, a + * data source that can answer `getObjectSchema`, and no field catalogue in + * hand. With no `getObjectSchema` there is nothing to wait for, and the + * authored column types are all the grid will ever know. */ -export function isMaskedGridColumn(columnType: unknown, objectFieldType: unknown): boolean { - return isMaskedType(columnType) || isMaskedType(objectFieldType); +export function isWithheldGridColumn(columnType: unknown, objectTypesPending: boolean): boolean { + return objectTypesPending && !(typeof columnType === 'string' && columnType.length > 0); } function isMaskedType(fieldType: unknown): boolean { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index dffb7bf592..27af37d8a3 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -5035,6 +5035,20 @@ export const ListView = React.forwardRef(({ // and why it is still the correct value to hand down. ? {} : { data })} + {...(viewComponentSchema.type === 'object-grid' && objectDef?.fields + // objectui#10657 — the grid is handed the rows this component + // fetched, so they paint before the grid's own read of the + // object definition settles; until then an untyped column over a + // `password` / `secret` field has no type, and the grid can only + // withhold it. This component read that definition BEFORE its + // rows (the data fetch waits for `objectDefLoaded`), so it hands + // the field catalogue down with them and the grid has no window + // at all. `objectFields` is the host channel for the catalogue + // (decision batch #70): `SchemaRenderer` refuses an AUTHORED + // one, and this React prop is not authored. Only the grid reads + // it, so only the grid is handed it. + ? { objectFields: objectDef.fields } + : {})} loading={loading} onRowSelect={setSelectedRows} {...(paginate && serverTotal != null From eea6692216254ede992a94a5c7d7693d1f402f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:32:19 +0000 Subject: [PATCH 2/7] test(plugin-grid,plugin-detail,plugin-dashboard,plugin-list): pin the withheld first paint and the host catalogue (objectui#10657) New pins: the grid's held / rejected / settled / host-catalogue arms with a typed text control, the group label, RelatedList's and ObjectDataTable's held / rejected / settled arms, and ListView's handoff of the catalogue with the rows. Existing in-flight pins wait for the mask instead of the raw value they used to print in that window. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- ...bjectDataTable.maskedColumn-10657.test.tsx | 6 +- ...tDataTable.maskedFirstPaint-10657.test.tsx | 175 +++++++++++++ .../RelatedList.maskedColumn-10657.test.tsx | 28 ++- ...elatedList.maskedFirstPaint-10657.test.tsx | 157 ++++++++++++ .../authoredColumnsFls-6799.test.tsx | 6 +- .../hostFetchedDefaultColumns-6677.test.tsx | 12 +- .../src/__tests__/inlineDataFls-6723.test.tsx | 6 +- .../__tests__/maskedFirstPaint-10657.test.tsx | 238 ++++++++++++++++++ .../ListView.gridObjectFields-10657.test.tsx | 119 +++++++++ 9 files changed, 738 insertions(+), 9 deletions(-) create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx index b1dea64f07..74d6e6ee69 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedColumn-10657.test.tsx @@ -243,7 +243,11 @@ describe('ObjectDataTable — a masked column is withheld on every table path (o }); describe('ObjectDataTable — fail closed while the object types are unknown (objectui#10657)', () => { - const inline = { data: ROWS, columns: [{ field: 'name', label: 'Name' }, { field: 'api_key', label: 'API Key' }] }; + // `Name` AUTHORS `type: 'text'`, so it draws inside the window: an untyped + // column there is withheld from the DRAW too (objectui#10657 PR 2, pinned in + // `ObjectDataTable.maskedFirstPaint-10657`). This file pins the FLAG, which + // still stamps every column, typed or not, until the types are known. + const inline = { data: ROWS, columns: [{ field: 'name', label: 'Name', type: 'text' }, { field: 'api_key', label: 'API Key' }] }; it('inline rows paint before the definition lands: no cell copies until it does', async () => { const ds = makeDataSource('held'); diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx new file mode 100644 index 0000000000..2025252150 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx @@ -0,0 +1,175 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectDataTable` WITHHOLDS an untyped column from the draw while the bound + * object's field types are unknown (objectui#10657 — the objectui#10706 class + * at this producer). + * + * ## The defect + * + * A cell here draws from `fieldMeta.type`: the column's authored `type`, or + * the object's. Bound and inline rows paint before the object definition + * lands, and a failed read settles with none, so in that window a column that + * authors no type drew its value as text: a `password` field in the clear, for + * good when the read failed. PR 1 of this card stamped every column `masked` + * in that window; the stamp withholds copy, tooltip, export, search and sort, + * but it does not draw. + * + * ## The contract pinned here + * + * - HELD and REJECTED: an untyped column draws the mask; the raw value is + * nowhere in the DOM, text or attribute. Rows the widget FETCHED after a + * failed read are withheld the same way. + * - SETTLED: the withholding lifts — the declared `text` field draws its value + * — and the declared `password` field keeps the mask. + * + * ## Controls + * + * Each mount carries `Name`, a column that AUTHORS `type: 'text'`: its type + * does not wait on the definition, so it draws its value in every arm. The + * auto-derived arm (no authored columns, so no typed control) is read against + * its headers and its drawn masks instead. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-data-table` (and, through its imports, `data-table`). +import '../index'; + +const MASK = '••••••'; +const RAW_KEY = 'RAW-ZULU-10657'; +const RAW_NOTE = 'NOTE-TANGO-10657'; + +const FIELDS = { + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, + note: { type: 'text', label: 'Note' }, +}; + +const ROWS = [{ id: 'v1', name: 'Ada', api_key: RAW_KEY, note: RAW_NOTE }]; + +/** `Name` authors its type (the control); `API Key` and `Note` author none. */ +const COLUMNS = [ + { field: 'name', label: 'Name', type: 'text' }, + { field: 'api_key', label: 'API Key' }, + { field: 'note', label: 'Note' }, +]; + +type SchemaMode = 'held' | 'rejected'; + +function makeDataSource(mode: SchemaMode) { + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + return { + release: () => release(), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => { + if (mode === 'held') await held; + if (mode === 'rejected') throw new Error('metadata unavailable'); + return { name: 'vault', fields: FIELDS }; + }), + }; +} + +function mount(ds: ReturnType, widget: Record) { + const schema = { type: 'object-data-table', objectName: 'vault', ...widget }; + return render( + + + , + ); +} + +const headerTexts = () => + Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); + +/** The body cell under the header `label`, in the first row. */ +function cellUnder(label: string): HTMLElement { + const index = headerTexts().indexOf(label); + expect(index, `CONTROL: a "${label}" header rendered (headers: ${headerTexts().join(' | ')})`).toBeGreaterThanOrEqual(0); + const cell = document.querySelectorAll('tbody tr')[0]?.children[index] as HTMLElement | undefined; + expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD'); + return cell!; +} + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + cleanup(); +}); + +/** CONTROL, then the withheld reading. */ +async function expectWithheld() { + await waitFor(() => expect(cellUnder('Name').textContent, 'CONTROL: the typed text column draws').toContain('Ada')); + expect(cellUnder('API Key').textContent, 'the untyped password column draws the mask').toContain(MASK); + expect(cellUnder('Note').textContent, 'the untyped text column is withheld too: its type is unknown').toContain(MASK); + // Text OR attribute: nowhere in the DOM. + expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY); + expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE); +} + +describe('ObjectDataTable — an untyped column is withheld while the object types are unknown (objectui#10657)', () => { + it('HELD (inline rows): the untyped columns draw the mask; the typed text column draws', async () => { + const ds = makeDataSource('held'); + mount(ds, { data: ROWS, columns: COLUMNS }); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await expectWithheld(); + }); + + it('REJECTED (inline rows): a failed read keeps them withheld — fail closed, never back to text', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ds = makeDataSource('rejected'); + mount(ds, { data: ROWS, columns: COLUMNS }); + await waitFor(() => expect(error).toHaveBeenCalled()); + await act(async () => {}); + await expectWithheld(); + }); + + it('REJECTED (fetched rows): rows fetched after the failed read are withheld the same way', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ds = makeDataSource('rejected'); + mount(ds, { columns: COLUMNS }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(error, 'CONTROL: the definition read failed before the rows were fetched').toHaveBeenCalled(); + await expectWithheld(); + }); + + it('HELD (auto-derived columns): every derived column is withheld', async () => { + const ds = makeDataSource('held'); + mount(ds, { data: ROWS }); + // CONTROL — the derived headers rendered, and the row drew (as masks). + await waitFor(() => expect(headerTexts()).toEqual(expect.arrayContaining(['Name', 'Api Key', 'Note']))); + await waitFor(() => expect(cellUnder('Api Key').textContent).toContain(MASK)); + expect(cellUnder('Name').textContent).toContain(MASK); + expect(document.body.innerHTML).not.toContain(RAW_KEY); + expect(document.body.innerHTML).not.toContain(RAW_NOTE); + }); + + it('SETTLED: the declared text field draws once the definition lands; the declared password keeps the mask', async () => { + const ds = makeDataSource('held'); + mount(ds, { data: ROWS, columns: COLUMNS }); + await expectWithheld(); + await act(async () => { ds.release(); }); + // CONTROL — the withholding lifts for a field the object declares `text`. + await waitFor(() => expect(cellUnder('Note').textContent).toContain(RAW_NOTE)); + expect(cellUnder('API Key').textContent, 'the declared password is masked').toContain(MASK); + expect(document.body.innerHTML).not.toContain(RAW_KEY); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx index 5736a71ba4..631e8fa0c0 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.maskedColumn-10657.test.tsx @@ -135,9 +135,25 @@ const drawnNames = () => const payloads = () => writeText.mock.calls.map((call) => call[0]); -/** Wait until the object definition has settled and the mask is drawn. */ +/** + * Wait until the object definition has settled and the mask is drawn. + * + * The drawn NAME is what says "settled": since objectui#10657 PR 2 every cell + * is withheld (drawn as the mask) while the definition is unknown, so a mask + * alone no longer tells the two windows apart. + */ async function settledWithMask() { + await waitFor(() => { + const body = document.querySelector('tbody')?.textContent ?? ''; + expect(body).toContain(MASK); + expect(body).toContain('Ada'); + }); +} + +/** Wait until the rows have painted WITHHELD: masks, and no name drawn yet. */ +async function withheldWithMask() { await waitFor(() => expect(document.querySelector('tbody')?.textContent ?? '').toContain(MASK)); + expect(document.querySelector('tbody')?.textContent ?? '').not.toContain('Ada'); } const SHAPES: Array<{ shape: string; props: Record }> = [ @@ -224,8 +240,11 @@ describe('RelatedList — fail closed while the object types are unknown (object it('while the definition is in flight no cell copies; once it lands the text cell does', async () => { const ds = makeDataSource('held'); mount(ds, { columns: ['name', 'api_key'] }); - await waitFor(() => expect(screen.getByText('Ada')).toBeInTheDocument()); - const nameCell = () => screen.getByText('Ada').closest('td') as HTMLElement; + // objectui#10657 PR 2: in this window every cell is also WITHHELD from the + // draw (the mask, never the value; `RelatedList.maskedFirstPaint-10657`), + // so the row is found by position, not by the name it no longer prints. + await withheldWithMask(); + const nameCell = () => document.querySelectorAll('tbody tr')[0]!.children[0] as HTMLElement; const keyCell = () => document.querySelectorAll('tbody tr')[0]!.children[1] as HTMLElement; fireEvent.keyDown(nameCell(), { key: 'c', ctrlKey: true }); @@ -244,7 +263,8 @@ describe('RelatedList — fail closed while the object types are unknown (object const error = vi.spyOn(console, 'error').mockImplementation(() => {}); mount(ds, { columns: ['name', 'api_key'] }); await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); - await waitFor(() => expect(screen.getByText('Ada')).toBeInTheDocument()); + // Withheld from the draw as well (objectui#10657 PR 2): wait for the mask. + await withheldWithMask(); await act(async () => {}); const row = document.querySelectorAll('tbody tr')[0]!; fireEvent.keyDown(row.children[0] as HTMLElement, { key: 'c', ctrlKey: true }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx new file mode 100644 index 0000000000..a0c827be73 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.maskedFirstPaint-10657.test.tsx @@ -0,0 +1,157 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `RelatedList` WITHHOLDS every cell it would draw from the object definition + * while that definition is unknown (objectui#10657 — the objectui#10706 class + * at this producer). + * + * ## The defect + * + * This list draws a cell from the OBJECT's field type (`makeCell`), never from + * a column's authored `type`. While `getObjectSchema` was pending, and for good + * after it rejected, no column had a cell, so the table drew every value as + * text: a `password` field in the clear. PR 1 of this card stamped every + * column `masked` in that window (no copy, tooltip, sort), but the stamp + * withholds, it does not draw. + * + * ## The contract pinned here + * + * - HELD and REJECTED: the declared `password` column (and every other column + * with no cell of its own) draws the mask; the raw value is nowhere in the + * DOM, text or attribute. + * - SETTLED: the withholding lifts — the declared `text` field draws its value + * — and the declared `password` field keeps the mask. + * + * ## Controls + * + * Each mount carries `Label`, a column whose `cell` the AUTHOR supplied: the + * list attaches nothing to it, so it draws its value in every arm, which makes + * the absence of the raw credential a reading and not a table that drew + * nothing. Both authored column shapes are driven (bare strings and objects); + * the auto-derived walk derives nothing without a definition, so it has no + * window to pin. + */ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, waitFor, cleanup, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; + +// The real data-table (and its cell renderers) must be registered: this pin +// reads what they DRAW. +import '@object-ui/components'; +import { RelatedList } from '../RelatedList'; + +const MASK = '••••••'; +const RAW_KEY = 'RAW-ZULU-10657'; +const CONTROL_VALUE = 'LABEL-ADA-10657'; + +const FIELDS = { + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, +}; + +const ROWS = [{ id: 'v1', name: 'Ada', label: CONTROL_VALUE, api_key: RAW_KEY }]; + +/** The author's own cell: this list attaches nothing to it. */ +const LABEL_COLUMN = { field: 'label', label: 'Label', cell: (value: unknown) => String(value) }; + +type SchemaMode = 'held' | 'rejected'; + +function makeDataSource(mode: SchemaMode) { + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + return { + release: () => release(), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + getObjectSchema: vi.fn(async () => { + if (mode === 'held') await held; + if (mode === 'rejected') throw new Error('metadata unavailable'); + return { name: 'vault', fields: FIELDS }; + }), + }; +} + +const SHAPES: Array<{ shape: string; columns: unknown[] }> = [ + { shape: 'bare-string columns', columns: ['name', 'api_key', LABEL_COLUMN] }, + { + shape: 'authored object columns', + columns: [{ field: 'name', label: 'Name' }, { field: 'api_key', label: 'API Key' }, LABEL_COLUMN], + }, +]; + +function mount(ds: ReturnType, columns: unknown[]) { + return render( + , + ); +} + +/** The body cell under the column at `index` in the first row. */ +const cellAt = (index: number) => document.querySelectorAll('tbody tr')[0]!.children[index] as HTMLElement; + +beforeEach(() => { + // Desktop: below 768px this list renders an `object-gallery`, not a table. + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +/** CONTROL, then the withheld reading: the author's cell drew, the rest are masks. */ +async function expectWithheld() { + await waitFor(() => expect(cellAt(2)?.textContent, 'CONTROL: the author-supplied cell draws its value').toBe(CONTROL_VALUE)); + expect(cellAt(0).textContent, 'the `name` column is withheld too: its type is unknown').toContain(MASK); + expect(cellAt(1).textContent, 'the `password` column draws the mask').toContain(MASK); + // Text OR attribute: nowhere in the DOM. + expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY); +} + +describe('RelatedList — a cell is withheld while the object definition is unknown (objectui#10657)', () => { + for (const { shape, columns } of SHAPES) { + describe(shape, () => { + it('HELD: every definition-drawn cell is the mask; the author cell draws', async () => { + const ds = makeDataSource('held'); + mount(ds, columns); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await expectWithheld(); + }); + + it('REJECTED: a failed read keeps them withheld — fail closed, never back to text', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ds = makeDataSource('rejected'); + mount(ds, columns); + // `useSettledSchema` reports the failed read: the settle has happened. + await waitFor(() => expect(error).toHaveBeenCalled()); + await act(async () => {}); + await expectWithheld(); + }); + + it('SETTLED: the declared text field draws; the declared password keeps the mask', async () => { + const ds = makeDataSource('held'); + mount(ds, columns); + await expectWithheld(); + await act(async () => { ds.release(); }); + // CONTROL — the withholding lifts for a field the object declares `text`. + await waitFor(() => expect(cellAt(0).textContent).toBe('Ada')); + expect(cellAt(1).textContent, 'the declared password is masked').toContain(MASK); + expect(cellAt(2).textContent).toBe(CONTROL_VALUE); + expect(document.body.innerHTML).not.toContain(RAW_KEY); + }); + }); + } +}); diff --git a/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx index 9149dede98..0db35c5935 100644 --- a/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx +++ b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx @@ -392,7 +392,11 @@ describe('ObjectGrid — FLS on the authored `columns` path (#6799)', () => { , ); - await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // objectui#10657: with the schema in flight an untyped column is WITHHELD + // (drawn as the mask, never as text), so the row is waited for by its mask, + // not by the raw name it used to print. + await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••')); + expect(screen.queryByText('Acme expansion')).toBeNull(); expect(dataHeaders(container)).toEqual(['Name', 'Salary']); }); diff --git a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx index b734bcdcf8..9e631131a6 100644 --- a/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx +++ b/packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx @@ -245,7 +245,11 @@ describe('ObjectGrid — host-fetched rows still get the object-schema policy (# const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); const { container } = renderHostFedGrid({}, ds); - await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // objectui#10657: with the schema in flight an untyped column is WITHHELD + // (drawn as the mask, never as text), so the row is waited for by its mask, + // not by the raw name it used to print. + await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••')); + expect(screen.queryByText('Acme expansion')).toBeNull(); releaseSchema!(OPPORTUNITY_SCHEMA); @@ -300,7 +304,11 @@ describe('ObjectGrid — host-fetched rows still get the object-schema policy (# const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); const { container } = renderHostFedGrid({}, ds); - await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // objectui#10657: with the schema in flight an untyped column is WITHHELD + // (drawn as the mask, never as text), so the row is waited for by its mask, + // not by the raw name it used to print. + await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••')); + expect(screen.queryByText('Acme expansion')).toBeNull(); // `objectSchema` is still `null`. Gating the legacy path on `objectName` // alone would fall through to `if (!objectSchema) return []` and paint an diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx index 0612ede75b..89b9a0707a 100644 --- a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx +++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx @@ -318,7 +318,11 @@ describe('ObjectGrid — FLS on the inline-data path (#6723)', () => { const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); const { container } = renderHostFedGrid({}, ds); - await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // objectui#10657: with the schema in flight an untyped column is WITHHELD + // (drawn as the mask, never as text), so the row is waited for by its mask, + // not by the raw name it used to print. + await waitFor(() => expect(container.querySelector('tbody')?.textContent ?? '').toContain('••••••')); + expect(screen.queryByText('Acme expansion')).toBeNull(); expect(dataHeaders(container)).toEqual(['Id', 'Name', 'Amount', 'Salary', 'Computed score']); }); diff --git a/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx new file mode 100644 index 0000000000..47e327f607 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx @@ -0,0 +1,238 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectGrid`'s FIRST PAINT before its object schema settles — the + * objectui#10706 row of objectui#10657's enumeration. + * + * ## The defect + * + * On the host-fetched path (rows handed down as `data`, as `ListView` does) + * the rows paint before the grid's own `getObjectSchema` read lands. An + * untyped view column takes its type from that schema, so until it arrived an + * untyped column over a `password` field drew the raw credential as text, and + * grouping by it printed the credential as the group label. A failed read was + * swallowed as non-fatal, which left the column drawn in the clear for the + * life of the grid. + * + * ## The contract pinned here + * + * - HELD: an untyped column is WITHHELD — drawn as the mask, never as text, + * no raw value anywhere in the DOM (text or a `title`), nothing copied, and + * no group labelled by it. + * - REJECTED: it stays withheld (fail closed, never back to text). + * - SETTLED: the withholding lifts for a field declared `text`, and the + * declared `password` field keeps the mask. + * - HOST CATALOGUE: handed the object's fields (`objectFields`, what `ListView` + * passes), the grid has no window: first paint draws each column from its + * declared type while its own read is still held. + * + * ## Controls + * + * Every mount carries `Name`, a column that AUTHORS `type: 'text'`: its type + * does not wait on the schema, so it draws, tooltips and copies its value in + * every arm. That is what makes each absence a reading and not a grid that + * drew nothing. The group-label case groups a second mount by that column and + * requires its label. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const MASK = '••••••'; +const RAW_KEY = 'RAW-ZULU-10657'; +const RAW_NOTE = 'NOTE-TANGO-10657'; +const CONTROL_VALUE = 'Ada'; + +const ROWS = [{ id: 'r1', name: CONTROL_VALUE, api_key: RAW_KEY, note: RAW_NOTE }]; + +/** What the object declares: one credential, one ordinary text field. */ +const FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, + note: { type: 'text', label: 'Note' }, +}; + +/** `Name` authors its type (the control); `API Key` and `Note` author none. */ +const COLUMNS = [ + { field: 'name', label: 'Name', type: 'text' }, + { field: 'api_key', label: 'API Key' }, + { field: 'note', label: 'Note' }, +]; + +let writeText: ReturnType; +const ORIGINAL_INNER_WIDTH = window.innerWidth; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + // Desktop: the sub-768px layout is the card list, which has no data-table. + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }); + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); +}); + +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: ORIGINAL_INNER_WIDTH }); + vi.restoreAllMocks(); + cleanup(); +}); + +/** A data source whose schema read the test settles by hand. */ +function heldDataSource() { + let release!: (def: unknown) => void; + let reject!: (err: unknown) => void; + const pending = new Promise((res, rej) => { release = res; reject = rej; }); + const ds = { getObjectSchema: vi.fn(() => pending) } as any; + return { ds, release, reject }; +} + +/** Rows handed down by a host, the way `ListView` hands them. */ +function renderHostFed(ds: unknown, extra: { grouping?: unknown; objectFields?: unknown } = {}) { + const { grouping, objectFields } = extra; + return render( + + + , + ); +} + +/** The body cell under the header whose text is `label`, in the control row. */ +function cellUnder(label: string): HTMLElement { + const row = screen.getByText(CONTROL_VALUE).closest('tr'); + expect(row, 'CONTROL: the data row rendered').not.toBeNull(); + const headers = Array.from(row!.closest('table')!.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" column header rendered`).toBeGreaterThanOrEqual(0); + return row!.children[index] as HTMLElement; +} + +const copied = () => writeText.mock.calls.map((call) => call[0]); + +/** The text control draws, copies and tooltips its value: the grid is live. */ +function expectControlLive() { + const name = cellUnder('Name'); + expect(name.textContent, 'CONTROL: the authored text column draws its value').toContain(CONTROL_VALUE); + fireEvent.keyDown(name, { key: 'c', ctrlKey: true }); + expect(copied(), 'CONTROL: the authored text column copies its value').toEqual([CONTROL_VALUE]); + writeText.mockClear(); +} + +/** A column drawn as the mask: no raw text, no raw attribute, no copy. */ +function expectWithheld(label: string, raw: string) { + const cell = cellUnder(label); + expect(cell.textContent, `${label}: drawn as the mask`).toContain(MASK); + fireEvent.keyDown(cell, { key: 'c', ctrlKey: true }); + expect(copied(), `${label}: Ctrl+C copies nothing`).toEqual([]); + // Text OR attribute: the `title` tooltip is where the raw value used to sit. + expect(document.body.innerHTML, `${label}: the raw value is nowhere in the DOM`).not.toContain(raw); +} + +describe('ObjectGrid — an untyped column is withheld until the object schema settles (objectui#10657)', () => { + it('HELD: the untyped columns draw the mask, never the raw value; the typed text column draws', async () => { + const { ds } = heldDataSource(); + renderHostFed(ds); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + expect(ds.getObjectSchema, 'CONTROL: the grid is waiting on its own schema read').toHaveBeenCalled(); + + expectControlLive(); + expectWithheld('API Key', RAW_KEY); + expectWithheld('Note', RAW_NOTE); + }); + + it('HELD: no group is labelled by an untyped column; grouping by the typed text column still labels', async () => { + const { ds } = heldDataSource(); + renderHostFed(ds, { grouping: { fields: [{ field: 'api_key' }] } }); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + expect(document.querySelectorAll('.group-label'), 'no group is labelled by the withheld key').toHaveLength(0); + expect(document.body.innerHTML, 'the raw value is nowhere in the DOM').not.toContain(RAW_KEY); + cleanup(); + + // CONTROL — the same held window, grouped by the column that authors its + // type: the grouping runs and its label is the value. + const control = heldDataSource(); + renderHostFed(control.ds, { grouping: { fields: [{ field: 'name' }] } }); + await waitFor(() => + expect(Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent)).toEqual([CONTROL_VALUE])); + }); + + it('REJECTED: a failed schema read keeps the untyped columns withheld — fail closed, never back to text', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { ds, reject } = heldDataSource(); + renderHostFed(ds); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + reject(new Error('metadata read refused')); + // The grid swallows the failure with its own warning: that is the signal + // the rejection has been handled, not merely issued. + await waitFor(() => + expect(warn.mock.calls.some((c) => String(c[0]).includes('Failed to fetch objectSchema for inline data'))).toBe(true)); + + expectControlLive(); + expectWithheld('API Key', RAW_KEY); + expectWithheld('Note', RAW_NOTE); + }); + + it('REJECTED: grouping by the untyped column labels no group after the failed read', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { ds, reject } = heldDataSource(); + renderHostFed(ds, { grouping: { fields: [{ field: 'api_key' }] } }); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + reject(new Error('metadata read refused')); + await waitFor(() => expect(console.warn).toHaveBeenCalled()); + expect(document.querySelectorAll('.group-label')).toHaveLength(0); + expect(document.body.innerHTML).not.toContain(RAW_KEY); + }); + + it('SETTLED: the declared text field draws once the schema lands; the declared password keeps the mask', async () => { + const { ds, release } = heldDataSource(); + renderHostFed(ds); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + expect(cellUnder('Note').textContent, 'withheld while held').toContain(MASK); + + release({ name: 'masked_first_paint', fields: FIELDS }); + // CONTROL — the withholding lifts: a field the object declares `text`. + await waitFor(() => expect(cellUnder('Note').textContent).toContain(RAW_NOTE)); + expectControlLive(); + // The declared `password` field is masked from its declaration now. + expectWithheld('API Key', RAW_KEY); + }); + + it('HOST CATALOGUE: handed `objectFields`, first paint draws from the declared types while its own read is held', async () => { + const { ds } = heldDataSource(); + renderHostFed(ds, { objectFields: FIELDS }); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + + // No window: the declared `text` field draws its value at first paint… + expect(cellUnder('Note').textContent, 'the declared text field draws at first paint').toContain(RAW_NOTE); + expectControlLive(); + // …and the declared `password` field is the mask from the same paint. + expectWithheld('API Key', RAW_KEY); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx b/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx new file mode 100644 index 0000000000..0cab8806cf --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.gridObjectFields-10657.test.tsx @@ -0,0 +1,119 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ListView` hands the grid the field catalogue it already holds WITH the + * rows, so the grid has no first-paint window (objectui#10657, which folded + * objectui#10706). + * + * `ListView` fetches the rows and hands them down as `data`, so they paint + * before the grid's own `getObjectSchema` read settles; in that window an + * untyped column over a `password` field has no type, and the grid can only + * withhold it (`maskedFirstPaint-10657.test.tsx` in `@object-ui/plugin-grid` + * pins that side, including what the grid draws once it is handed + * `objectFields`). This list read the object definition BEFORE its rows, so it + * passes `objectFields` beside `data` — the host channel `SchemaRenderer` + * keeps for the catalogue (objectui#8818). + * + * What is pinned is the HANDOFF, read off a probe registered as `object-grid`: + * every props bag that carries rows carries the declared catalogue too, so no + * render ever hands the grid rows without their types. The control is the + * failed definition read: the rows still arrive, and no catalogue with them, + * which is the case the grid withholds on its own. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider, SchemaRendererProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +const OBJECT = 'vault'; + +const FIELDS = { + id: { type: 'text', label: 'Id', hidden: true }, + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, +}; + +const ROWS = [{ id: 'v1', name: 'Ada', api_key: 'RAW-ZULU-10657' }]; + +/** Records every props bag `SchemaRenderer` hands the `object-grid` node. */ +const GridProbe = vi.fn((_props: Record) =>
); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + GridProbe.mockClear(); + ComponentRegistry.register('object-grid', GridProbe as never); +}); + +afterEach(() => { + ComponentRegistry.unregister?.('object-grid'); + vi.restoreAllMocks(); + cleanup(); +}); + +function makeDataSource(schemaRead: 'ok' | 'rejected') { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => { + if (schemaRead === 'rejected') throw new Error('metadata unavailable'); + return { name, label: 'Vault', fields: FIELDS }; + }), + } as any; +} + +function renderList(ds: unknown) { + const schema = { type: 'list-view', objectName: OBJECT, columns: ['name', 'api_key'] } as unknown as ListViewSchema; + return render( + + + + + , + ); +} + +/** Every props bag the grid probe received that carried rows. */ +const bagsWithRows = () => + GridProbe.mock.calls + .map((call) => call[0]) + .filter((props) => Array.isArray(props.data) && (props.data as unknown[]).length > 0); + +describe('ListView → object-grid — the field catalogue travels with the rows (objectui#10657)', () => { + it('every render that hands the grid rows hands it the declared fields too', async () => { + const ds = makeDataSource('ok'); + renderList(ds); + await waitFor(() => expect(bagsWithRows().length).toBeGreaterThan(0)); + + for (const bag of bagsWithRows()) { + expect(bag.objectFields, 'the rows never arrive without their field types').toEqual(FIELDS); + } + }); + + it('CONTROL: after a failed definition read the rows still arrive, with no catalogue', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ds = makeDataSource('rejected'); + renderList(ds); + await waitFor(() => expect(bagsWithRows().length).toBeGreaterThan(0)); + + for (const bag of bagsWithRows()) { + expect('objectFields' in bag, 'nothing is handed down when nothing was read').toBe(false); + } + }); +}); From e742e22d2e6cf66a126e713f5fc801c636089939 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:46:50 +0000 Subject: [PATCH 3/7] docs(types,fields,plugin-grid,plugin-detail,plugin-dashboard): describe withheld columns; changesets (objectui#10657) The TableColumn.masked JSDoc, the data-table docs and four READMEs said an untyped column over a password / secret field still draws as text before the object's types are known; they now describe the withheld draw and the grid's objectFields prop. One changeset per touched published package. Three more existing pins read an untyped cell's raw value inside that window and now read the mask or wait for the settled draw. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- ...0657-fields-masked-cell-renderer-export.md | 19 ++++++++++ .../10657-plugin-dashboard-withheld-draw.md | 25 +++++++++++++ .../10657-plugin-detail-withheld-draw.md | 26 +++++++++++++ .../10657-plugin-grid-withheld-first-paint.md | 35 ++++++++++++++++++ .../10657-plugin-list-grid-object-fields.md | 14 +++++++ .changeset/10657-types-withheld-doc.md | 13 +++++++ .../docs/components/complex/data-table.mdx | 37 ++++++++++--------- packages/fields/README.md | 15 ++++++++ packages/plugin-dashboard/README.md | 4 +- .../ObjectDataTable.fetchGate-10664.test.tsx | 5 ++- packages/plugin-detail/README.md | 6 ++- ...unresolvedIdentityFailClosed-8793.test.tsx | 8 ++-- ...edList.multiValueParentScope-7299.test.tsx | 8 +++- packages/plugin-grid/README.md | 22 ++++++++--- packages/types/src/data-display.ts | 27 +++++++------- 15 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 .changeset/10657-fields-masked-cell-renderer-export.md create mode 100644 .changeset/10657-plugin-dashboard-withheld-draw.md create mode 100644 .changeset/10657-plugin-detail-withheld-draw.md create mode 100644 .changeset/10657-plugin-grid-withheld-first-paint.md create mode 100644 .changeset/10657-plugin-list-grid-object-fields.md create mode 100644 .changeset/10657-types-withheld-doc.md diff --git a/.changeset/10657-fields-masked-cell-renderer-export.md b/.changeset/10657-fields-masked-cell-renderer-export.md new file mode 100644 index 0000000000..21d85361ca --- /dev/null +++ b/.changeset/10657-fields-masked-cell-renderer-export.md @@ -0,0 +1,19 @@ +--- +'@object-ui/fields': minor +--- + +`MaskedCellRenderer`, the mask a `password` / `secret` cell draws, is now exported, so a table that cannot type a column yet draws it WITHHELD instead of as text (objectui#10657, which folded objectui#10706). + +**New export `MaskedCellRenderer`.** It is the component the standard `password` and +`secret` cells already resolve to: `••••••` for a value that is set, the empty-value mark +for one that is not. Its `field` prop is optional, because it reads only whether a value +is present. It is the shipped mask, not whatever is registered for `password` at the +time, so a host override of a masked type does not change what a withheld cell draws. + +`object-grid`, the related list and `object-data-table` use it for a column whose type is +not known yet: while the object's field types are loading, or after their read failed, +they draw such a column as this mask rather than as its value. + +`registerFieldRenderer('api_token', MaskedCellRenderer)` now masks a type the same way +the `getCellRenderer('password')` form does, and `isMaskedFieldType('api_token')` answers +`true` for it. Nothing that already resolves changes. diff --git a/.changeset/10657-plugin-dashboard-withheld-draw.md b/.changeset/10657-plugin-dashboard-withheld-draw.md new file mode 100644 index 0000000000..29e4a2c8fa --- /dev/null +++ b/.changeset/10657-plugin-dashboard-withheld-draw.md @@ -0,0 +1,25 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +`object-data-table` no longer draws an untyped column as text while the object definition is loading or after its read failed: it draws the mask instead (objectui#10657, the objectui#10706 class at this producer). + +A cell here draws from the column's authored `type`, or the object's. Bound and inline +rows are drawn before the object definition arrives, and a failed read never delivers +one, so in that window a column that authors no `type` drew its value as text, a +`password` / `secret` field included. objectui#10657's first change already set +`TableColumn.masked` on every column in that window, but the flag withholds; it does not +draw. + +Now, in that window, a column with no `type` of its own is drawn as the mask +(`MaskedCellRenderer` from `@object-ui/fields`), declared and derived columns alike, and +stays so when the read failed: fail closed, never back to text. A column that authors its +`type` draws from it, and a column with a `cell` of its own draws what that `cell` +returns. Once the definition lands, each column draws from its declared type as before. + +The pending objectui#10657 `object-data-table` changeset says a `password` / `secret` +field is still drawn as text in that window; that no longer holds once this change ships +with it. + +This is defence in depth. The real guard is still the server: ObjectStack's read mask +replaces `secret` and `password` values with its mask before they reach the browser. diff --git a/.changeset/10657-plugin-detail-withheld-draw.md b/.changeset/10657-plugin-detail-withheld-draw.md new file mode 100644 index 0000000000..92c158ab36 --- /dev/null +++ b/.changeset/10657-plugin-detail-withheld-draw.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-detail': patch +--- + +The related list no longer draws a cell as text while its object definition is loading or after its read failed: it draws the mask instead (objectui#10657, the objectui#10706 class at this producer). + +The list draws a cell from the object's field type, never from a column's authored +`type`. While the definition was in flight, and for good after its read failed, no column +had a cell, so the table drew every value as text, a `password` / `secret` field +included. objectui#10657's first change already set `TableColumn.masked` on every column +in that window (no copy, tooltip or header sort), but the flag withholds; it does not +draw. + +Now, in that window, every column without a `cell` of its own is drawn as the mask +(`MaskedCellRenderer` from `@object-ui/fields`), and stays so when the read failed: fail +closed, never back to text. A column whose `cell` the author supplied draws what that +`cell` returns. Once the definition lands, each column draws from its declared type as +before. + +The cost: while the definition is loading, and for good after a failed read, the list +shows masks where it used to show values. The pending objectui#10657 related-list +changeset says a `password` / `secret` field is still drawn as text in that window; that +no longer holds once this change ships with it. + +This is defence in depth. The real guard is still the server: ObjectStack's read mask +replaces `secret` and `password` values with its mask before they reach the browser. diff --git a/.changeset/10657-plugin-grid-withheld-first-paint.md b/.changeset/10657-plugin-grid-withheld-first-paint.md new file mode 100644 index 0000000000..8a600e03ee --- /dev/null +++ b/.changeset/10657-plugin-grid-withheld-first-paint.md @@ -0,0 +1,35 @@ +--- +'@object-ui/plugin-grid': minor +--- + +`object-grid` no longer draws an untyped column as text before its object schema has loaded: the column is WITHHELD until it has, and stays withheld when the read fails; a host can hand the grid the field types it holds (`objectFields`) so there is no such window (objectui#10657, which folded objectui#10706). + +On the host-fetched path (rows handed down as `data`, as `ListView` does) the rows paint +before the grid's own `getObjectSchema` read lands. An untyped view column takes its type +from that schema, so until it arrived a column over a `password` / `secret` field drew +the raw value as text and handed it out (copy, tooltip, the grid's client export, the +mobile card), and grouping by it printed the value as the group label. A failed read was +swallowed as non-fatal, leaving the column in the clear for the life of the grid. + +- **Withheld.** While an object-bound grid (an object name, and a data source that can + answer `getObjectSchema`) has no field types in hand, a column with no `type` of its + own is drawn as the mask (`MaskedCellRenderer` from `@object-ui/fields`), carries + `TableColumn.masked`, is left out of the grid's client export, is drawn through its cell + on the mobile card, and cannot be a grouping key (the entry is ignored without a + warning until the types are known; a masked field is still warned about). No type is + inferred for it from its name or values meanwhile. It stays withheld when the read + fails: fail closed, never back to text. A column that authors its own `type` draws + from it, as it did. A grid with no `getObjectSchema` to wait for is unchanged. +- **New prop `objectFields` on `ObjectGrid` (`ObjectGridComponentProps`).** The object's + field catalogue, handed down by a host that has already read the definition. The grid + answers every field-type question from it until its own read lands, so the columns + draw from their declared types at first paint and nothing is withheld. It is a runtime + prop only: `SchemaRenderer` strips an `objectFields` key authored in the schema + (objectui#8818), so no view author can hand the grid a catalogue. + +The pending objectui#10583 changeset lists the host-fetched window under "Not covered", +and the pending objectui#10657 grid changeset says that window stays not covered. Neither +holds once this change ships with them. + +This is defence in depth. The real guard is still the server: ObjectStack's read mask +replaces `secret` and `password` values with its mask before they reach the browser. diff --git a/.changeset/10657-plugin-list-grid-object-fields.md b/.changeset/10657-plugin-list-grid-object-fields.md new file mode 100644 index 0000000000..10379f3b47 --- /dev/null +++ b/.changeset/10657-plugin-list-grid-object-fields.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': patch +--- + +`ListView` hands its grid the object's field types together with the rows, so the grid draws a `password` / `secret` column masked from its first paint (objectui#10657, which folded objectui#10706). + +`ListView` fetches the rows and hands them to `object-grid` as `data`, and those rows +painted before the grid's own read of the object definition settled. In that window an +untyped column over a `password` / `secret` field had no type: it drew the raw value, and +since `object-grid` now withholds such a column instead, it would draw the mask for every +untyped column until the read lands. `ListView` reads the object definition before it +fetches the rows, so it now passes the definition's fields to the grid as `objectFields` +with them, and the grid has no window at all. Only the grid view is handed it. When the +definition could not be read, nothing is passed and the grid withholds on its own. diff --git a/.changeset/10657-types-withheld-doc.md b/.changeset/10657-types-withheld-doc.md new file mode 100644 index 0000000000..5363daa072 --- /dev/null +++ b/.changeset/10657-types-withheld-doc.md @@ -0,0 +1,13 @@ +--- +'@object-ui/types': patch +--- + +`TableColumn.masked`'s documentation describes withheld columns: a column whose type is not known yet is drawn as the mask and carries the flag (objectui#10657, which folded objectui#10706). + +The JSDoc said that the related list and `object-data-table` still draw a `password` / +`secret` field as text while their object definition is loading or after its read +failed, and that `object-grid` draws an untyped column's raw value on its host-fetched +path until its object schema arrives. Neither holds any more: each of the three producers +now draws such a column as the mask and sets the flag on it, and `object-grid` takes the +field types from a host that has them (`objectFields`). The JSDoc is rewritten to say so. +No type or schema shape changes. diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index 44ae90b415..05c3ca6f37 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -170,23 +170,26 @@ so a column that shows a `secret` field as `text` keeps the flag once the object types are known. The related list and `object-data-table` fail closed: while the object definition is -still loading, or after its read failed, they set the flag on every column. What they -do not cover: in that same window, their cells draw a field the object declares as -`password` / `secret` as text, because the mask comes from that declaration -(objectui#10657). - -Once the object schema has loaded, `object-grid` also leaves every masked field of the -grid's object out of its own client export (CSV and JSON), draws those fields through -their `cell` on its mobile card, and refuses them as grouping keys (the entry is -ignored with a console warning). What `object-grid` does not cover: on the -host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView` do), the -grid's guards and the cell's own mask depend on the object schema, which the grid -fetches after first paint, so until it arrives, and for good if that read fails, an -untyped view column over a `password` / `secret` field draws and hands out the raw -value (objectui#10657, which folded objectui#10706). The server-streamed export -(`exportDownload`) sends the masked columns as before and relies on the server's -masking, and the client JSON export writes an expanded lookup record whole, so a -credential field of the related object is not pruned. +still loading, or after its read failed, they set the flag on every column. + +A column whose type is not known yet is **withheld** (objectui#10657, which folded +objectui#10706). While the object's field types are loading, or after their read +failed, `object-grid`, the related list and `object-data-table` draw such a column as +the mask, never as text, and set the flag on it; it stays withheld when the read +failed. In `object-grid` and `object-data-table` that is a column with no `type` of its +own. The related list draws its cells from the object's types only, so there it is +every column without a `cell` of its own. A host that already holds the object's fields +hands them to `object-grid` as its `objectFields` prop, as the list view does, and then +there is nothing to withhold. + +`object-grid` also leaves every masked or withheld field of the grid's object out of its +own client export (CSV and JSON), draws those fields through their `cell` on its mobile +card, and refuses them as grouping keys (a masked entry is ignored with a console +warning; a withheld one groups again once the types are known). What `object-grid` does +not cover: the server-streamed export (`exportDownload`) sends the masked columns as +before and relies on the server's masking, and the client JSON export writes an +expanded lookup record whole, so a credential field of the related object is not +pruned. ## Examples diff --git a/packages/fields/README.md b/packages/fields/README.md index 6650ce9492..126fa252c4 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -153,6 +153,21 @@ withholding it. Your own component under an undeclared type answers `false`. Spellings are matched raw, the way `getCellRenderer` looks them up: `field:password` renders in the clear and is not masked. +The mask itself is exported as `MaskedCellRenderer` (objectui#10657). A table +that cannot type a column yet, because its object's field types are still +loading or their read failed, draws that column WITHHELD with it rather than as +text: `object-grid`, the related list and `object-data-table` do. It is the +shipped mask, not whatever is registered for `password` at the time, and its +`field` prop is optional because it reads only whether a value is present. +`registerFieldRenderer('api_token', MaskedCellRenderer)` masks a type the same +way the `getCellRenderer('password')` form above does. + +```tsx +import { MaskedCellRenderer } from '@object-ui/fields'; + +const withheldCell = (value: unknown) => ; +``` + ### File uploads in line-item grids `GridField` (the master-detail line-items grid) supports `type: 'file'` columns: diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index c19def8107..9fe4c0367f 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -624,7 +624,9 @@ an author override does not win: `type: 'text'` over a `secret` field keeps the flag, though the cell then draws the value as text. Rows handed in as `data` or through `bind` can be drawn before the object schema arrives, and a failed schema read never delivers one; in that window the widget flags every column, -and a masked field is still drawn as text. +and a column with no `type` of its own draws as the mask, never as text, until +the schema arrives, or for good when the read failed (objectui#10657). A column +that authors its `type` draws from it meanwhile. ```jsonc { diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx index a1959e718e..c120ac9fe2 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.fetchGate-10664.test.tsx @@ -163,7 +163,10 @@ describe('object-data-table reads once per mount, expanded, and keys on its expa vi.spyOn(console, 'error').mockImplementation(() => {}); const ds = makeDataSource({ schema: 'reject' }); render(); - await waitFor(() => expect(screen.getByText('Acme')).toBeInTheDocument()); + // objectui#10657 PR 2: with no definition the untyped column is WITHHELD + // (drawn as the mask, never as text), so the shown row is read by its mask. + await waitFor(() => expect(document.querySelector('tbody')?.textContent ?? '').toContain('••••••')); + expect(screen.queryByText('Acme')).toBeNull(); await settle(); expect(expands(ds)).toEqual([null]); diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index 4b97251c25..ada79c382b 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -460,8 +460,10 @@ it by its value. The rule is `isMaskedFieldType()` from `@object-ui/fields`, read over the column's authored `type` and the object's field type, so an authored `type: 'text'` over a `secret` field keeps the flag. While the object definition is still loading, or after its read failed, the list cannot tell -which columns are masked and flags every one; in that window a masked field is -still drawn as text. Not covered: the opt-in filter box matches against every +which columns are masked, so it flags every one and draws every cell it would +draw from that definition as the mask, never as text; it keeps doing so when +the read failed (objectui#10657). A column with a `cell` of your own draws what +your `cell` returns. Not covered: the opt-in filter box matches against every field of a row, and the `list` card's sort buttons sort by any column, masked ones included. diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx index 69c355c7b1..89592862bf 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -306,8 +306,10 @@ describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kep , ); - const cells = await paintedCells(); - expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); - expect(cells).not.toContain('90000'); + // objectui#10657 PR 2: the first cells paint WITHHELD (as the mask) while + // the definition is in flight, so wait for the settled draw, not the first + // cell. + await waitFor(() => expect(cellTexts()).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter']))); + expect(cellTexts()).not.toContain('90000'); }); }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx index 6b8ad591a4..5e0ab3c460 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx @@ -455,7 +455,13 @@ describe('RelatedList — the fetch waits for the SETTLED child definition, and }), }; renderList(ds, 'contact', 'account'); - expect(await screen.findByText('Alice')).toBeInTheDocument(); + // objectui#10657 PR 2: after a failed definition read every cell is + // WITHHELD (drawn as the mask), so the shown rows are counted, not read + // by name: the parent-scoped query returns the one matching row. + await waitFor(() => { + expect(document.querySelector('tbody')?.textContent ?? '').toContain('••••••'); + expect(document.querySelectorAll('tbody tr')).toHaveLength(SINGLE_MATCH_COUNT); + }); expect(screen.queryByText('Bob')).toBeNull(); await new Promise((r) => setTimeout(r, 10)); expect(scopedCalls(ds as any, 'contact')).toEqual([{ ...PROJECTION, $filter: { account: PARENT_ID } }]); diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 11c961b81e..bd022980a8 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -393,14 +393,24 @@ The table also leaves such a column out of its client-side search, disables its and sizes it from its header rather than its values (objectui#10657, which folded objectui#10658). +Before the object schema has loaded — on the host-fetched path (rows handed down as +`data`), the rows paint first — an untyped view column is **withheld**: it draws the +mask, never its value as text, and every path above treats it as masked (a grouping +entry on it is ignored, without a warning, until the schema is in). It stays withheld +when the schema read fails. A column that authors its own `type` draws from it meanwhile. A host that +already holds the object's fields passes them as the `objectFields` prop, and the grid +answers from them until its own read lands, so there is no such window; `ListView` +does this (objectui#10657, which folded objectui#10706). + +```tsx + +``` + +`objectFields` is a runtime prop only: an `objectFields` key authored in the schema +never reaches the grid. + Not covered: -- On the host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView` - do), the grid's guards and the cell's own mask depend on the object schema, which - the grid fetches after first paint. Until it arrives, and for good if that read fails - (the grid swallows the failure and keeps its heuristic column types), an untyped view - column over a `password` / `secret` field draws and hands out the raw value - (objectui#10657, which folded objectui#10706). - The server-streamed export (`exportDownload`) sends the masked columns as before and relies on the server's masking. - The client JSON export writes an expanded lookup record whole, so a credential diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index add5c58358..ec74d4d105 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -738,19 +738,20 @@ export interface TableColumn { * * `RelatedList` and `ObjectDataTable` fail closed: while their object * definition is in flight, or after its read failed, they stamp EVERY column. - * Not covered there: in that same window their cells draw a field the object - * declares `password` / `secret` as text, because the mask comes from that - * declaration (objectui#10657). - * - * Once the grid's object schema has loaded, `ObjectGrid` also leaves every - * masked field of its object out of its own client export, draws those - * fields through `cell` on its mobile card, and refuses them as grouping - * keys. Not covered there: on the host-fetched path (rows handed down as - * `data`, as `ListView` and `ObjectView` do), the grid's guards and the - * cell's own mask depend on the object schema, which the grid fetches after - * first paint, so until it arrives, and for good if that read fails, an - * untyped view column over a `password` / `secret` field draws and hands out - * the raw value (objectui#10657, which folded objectui#10706); the + * + * WITHHELD columns (objectui#10657, which folded objectui#10706): while an + * object-bound producer's field types are unknown (the definition is in + * flight, or its read failed), a column it cannot type draws as the mask + * instead of as text, and carries this flag; it stays so when the read + * failed. In `ObjectGrid` and `ObjectDataTable` that is a column with no + * authored `type`; `RelatedList` draws its cells from the object's types + * only, so there it is every column without a `cell` of its own. A host that + * already holds the object's fields hands them to `ObjectGrid` + * (`objectFields`, as `ListView` does), so that grid has no such window. + * + * `ObjectGrid` also leaves every masked or withheld field of its object out + * of its own client export, draws those fields through `cell` on its mobile + * card, and refuses them as grouping keys. Not covered there: the * server-streamed export (`exportDownload`) sends the masked columns as * before and relies on the server's masking; and the client JSON export, and * this table's CSV export of a lookup column, write an expanded lookup From 234ec73e97e19cf00b33a60300bd7ca705a58647 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:38:17 +0000 Subject: [PATCH 4/7] docs(plugin-grid): state the objectFields handoff in prose, not an uncompiled snippet (objectui#10657) Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- packages/plugin-grid/README.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index bd022980a8..58a052a0e2 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -397,17 +397,14 @@ Before the object schema has loaded — on the host-fetched path (rows handed do `data`), the rows paint first — an untyped view column is **withheld**: it draws the mask, never its value as text, and every path above treats it as masked (a grouping entry on it is ignored, without a warning, until the schema is in). It stays withheld -when the schema read fails. A column that authors its own `type` draws from it meanwhile. A host that -already holds the object's fields passes them as the `objectFields` prop, and the grid -answers from them until its own read lands, so there is no such window; `ListView` -does this (objectui#10657, which folded objectui#10706). - -```tsx - -``` - -`objectFields` is a runtime prop only: an `objectFields` key authored in the schema -never reaches the grid. +when the schema read fails. A column that authors its own `type` draws from it +meanwhile. + +A host that already holds the object's definition passes its `fields` to the grid as +the `objectFields` prop, beside the `data` it fetched, and the grid answers from them +until its own read lands, so there is no such window; `ListView` does this +(objectui#10657, which folded objectui#10706). `objectFields` is a runtime prop only: +an `objectFields` key authored in the schema never reaches the grid. Not covered: From e8503d38be17e5dd6d9c6e45ee2d8fd1ebe6a1c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:41:13 +0000 Subject: [PATCH 5/7] fix(plugin-grid,plugin-dashboard): withhold the record panel and drawer values while types are unknown (objectui#10657) The same window, one click further: ObjectGrid's record panel (overlay navigation, no declared fields) and ObjectDataTable's record drill-down drawer drew a clicked row's fields from the unknown types, so after a failed read they printed a password / secret field as text. Both now draw every value as the mask in that window. Pinned beside the column arms. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- .../10657-plugin-dashboard-withheld-draw.md | 4 +++ .../10657-plugin-grid-withheld-first-paint.md | 4 +++ packages/plugin-dashboard/README.md | 3 +- .../plugin-dashboard/src/ObjectDataTable.tsx | 1 + .../src/RecordDetailDrawer.tsx | 20 +++++++++++-- ...tDataTable.maskedFirstPaint-10657.test.tsx | 20 ++++++++++++- packages/plugin-grid/README.md | 3 +- packages/plugin-grid/src/ObjectGrid.tsx | 18 +++++++++--- .../__tests__/maskedFirstPaint-10657.test.tsx | 29 +++++++++++++++++-- 9 files changed, 89 insertions(+), 13 deletions(-) diff --git a/.changeset/10657-plugin-dashboard-withheld-draw.md b/.changeset/10657-plugin-dashboard-withheld-draw.md index 29e4a2c8fa..2ea72f966b 100644 --- a/.changeset/10657-plugin-dashboard-withheld-draw.md +++ b/.changeset/10657-plugin-dashboard-withheld-draw.md @@ -17,6 +17,10 @@ stays so when the read failed: fail closed, never back to text. A column that au `type` draws from it, and a column with a `cell` of its own draws what that `cell` returns. Once the definition lands, each column draws from its declared type as before. +The record drawer a row opens (record drill-down) drew the clicked record's fields from +the same unknown types, so after a failed read it printed a `password` / `secret` field +as text too. In that window it now draws every value as the mask. + The pending objectui#10657 `object-data-table` changeset says a `password` / `secret` field is still drawn as text in that window; that no longer holds once this change ships with it. diff --git a/.changeset/10657-plugin-grid-withheld-first-paint.md b/.changeset/10657-plugin-grid-withheld-first-paint.md index 8a600e03ee..1966c1d38c 100644 --- a/.changeset/10657-plugin-grid-withheld-first-paint.md +++ b/.changeset/10657-plugin-grid-withheld-first-paint.md @@ -20,6 +20,10 @@ swallowed as non-fatal, leaving the column in the clear for the life of the grid inferred for it from its name or values meanwhile. It stays withheld when the read fails: fail closed, never back to text. A column that authors its own `type` draws from it, as it did. A grid with no `getObjectSchema` to wait for is unchanged. +- **The record panel** a row opens under overlay navigation (drawer, modal, split, + popover) with no declared fields in hand printed every value of the record, inferred + from its value and name. While the types are unknown it now draws each value as the + mask as well. - **New prop `objectFields` on `ObjectGrid` (`ObjectGridComponentProps`).** The object's field catalogue, handed down by a host that has already read the definition. The grid answers every field-type question from it until its own read lands, so the columns diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 9fe4c0367f..e79d724e95 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -626,7 +626,8 @@ or through `bind` can be drawn before the object schema arrives, and a failed schema read never delivers one; in that window the widget flags every column, and a column with no `type` of its own draws as the mask, never as text, until the schema arrives, or for good when the read failed (objectui#10657). A column -that authors its `type` draws from it meanwhile. +that authors its `type` draws from it meanwhile. The record drawer a row opens +(record drill-down) draws every value as the mask in that window too. ```jsonc { diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 402c1b9535..6c9560c07c 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -1153,6 +1153,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo title={recordTitle} target={drillDown?.target === 'dialog' ? 'dialog' : 'drawer'} onClose={() => setDrillRecord(null)} + objectTypesPending={objectTypesPending} /> )}
diff --git a/packages/plugin-dashboard/src/RecordDetailDrawer.tsx b/packages/plugin-dashboard/src/RecordDetailDrawer.tsx index 2ead771df5..df9fe69599 100644 --- a/packages/plugin-dashboard/src/RecordDetailDrawer.tsx +++ b/packages/plugin-dashboard/src/RecordDetailDrawer.tsx @@ -27,6 +27,7 @@ import { EmptyValue, } from '@object-ui/components'; import { useSafeFieldLabel, useLocalization, useDisplayLocale } from '@object-ui/i18n'; +import { MaskedCellRenderer } from '@object-ui/fields'; import { indexObjectFields, buildFieldMeta, @@ -55,6 +56,14 @@ export interface RecordDetailDrawerProps { target?: 'drawer' | 'dialog'; /** Called when the drawer requests to close. */ onClose: () => void; + /** + * Are the object's field types still unknown (objectui#10657)? The owning + * table's reading: its definition is in flight, or its read failed. A value + * is drawn from its field type, so with none in hand every value here is + * drawn WITHHELD, as the mask — a `password` / `secret` field would + * otherwise be printed as text — exactly as the table's own cells are. + */ + objectTypesPending?: boolean; } /** Candidate fields, in priority order, used to title the drawer. */ @@ -79,6 +88,7 @@ export const RecordDetailDrawer: React.FC = ({ title, target = 'drawer', onClose, + objectTypesPending = false, }) => { const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel(); const { currency: tenantCurrency } = useLocalization(); @@ -123,11 +133,15 @@ export const RecordDetailDrawer: React.FC = ({ return { key, label, - node: renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale), - numeric: isNumericFieldMeta(fieldMeta), + // Withheld while the field types are unknown (objectui#10657): the + // mask, never the value, and so for good after a failed read. + node: objectTypesPending + ? + : renderFieldValue(record[key], fieldMeta, tenantCurrency, displayLocale), + numeric: !objectTypesPending && isNumericFieldMeta(fieldMeta), }; }); - }, [record, objectSchema, objectName, fields, fieldLabel, fieldOptionLabel, tenantCurrency, displayLocale]); + }, [record, objectSchema, objectName, fields, fieldLabel, fieldOptionLabel, tenantCurrency, displayLocale, objectTypesPending]); if (!record) return null; diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx index 2025252150..c66d247fb0 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx @@ -28,6 +28,8 @@ * failed read are withheld the same way. * - SETTLED: the withholding lifts — the declared `text` field draws its value * — and the declared `password` field keeps the mask. + * - THE RECORD DRAWER (record drill-down) draws a clicked row's fields from the + * same unknown types, so after a failed read it withholds every value too. * * ## Controls * @@ -38,7 +40,7 @@ */ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, act, cleanup, waitFor } from '@testing-library/react'; +import { render, act, cleanup, waitFor, fireEvent, screen, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // Registers `object-data-table` (and, through its imports, `data-table`). @@ -172,4 +174,20 @@ describe('ObjectDataTable — an untyped column is withheld while the object typ expect(cellUnder('API Key').textContent, 'the declared password is masked').toContain(MASK); expect(document.body.innerHTML).not.toContain(RAW_KEY); }); + + it('REJECTED: the record drawer a row opens draws every value withheld', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ds = makeDataSource('rejected'); + mount(ds, { data: ROWS, columns: COLUMNS, drillDown: { enabled: true } }); + await waitFor(() => expect(error).toHaveBeenCalled()); + await expectWithheld(); + + fireEvent.click(cellUnder('Name')); + const body = await screen.findByTestId('record-detail-body'); + // CONTROL — the drawer rendered this record's fields, not an empty shell. + expect(within(body).getByText('Api Key')).toBeInTheDocument(); + expect(body.textContent, 'the drawer draws the values as the mask').toContain(MASK); + expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY); + expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE); + }); }); diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 58a052a0e2..d2dd4e8b11 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -398,7 +398,8 @@ Before the object schema has loaded — on the host-fetched path (rows handed do mask, never its value as text, and every path above treats it as masked (a grouping entry on it is ignored, without a warning, until the schema is in). It stays withheld when the schema read fails. A column that authors its own `type` draws from it -meanwhile. +meanwhile. The record panel a row opens under overlay navigation draws every value as +the mask in that window too. A host that already holds the object's definition passes its `fields` to the grid as the `objectFields` prop, beside the `data` it fetched, and the grid answers from them diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 9073ffe623..64aa5b9781 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -5295,12 +5295,12 @@ export const ObjectGrid: React.FC = ({ // Honor `hidden: true` on the schema field def — internal/system fields // (e.g. database_url, environment_id, is_system) shouldn't leak into the // grid's record-detail drawer just because they're in the record payload. - const isHidden = (key: string) => objectSchema?.fields?.[key]?.hidden === true; + const isHidden = (key: string) => objectFields?.[key]?.hidden === true; // Split business fields from framework-managed system/audit/ownership // columns via the shared classifier (branches on `field.system`), so the // injected `owner_id` and friends land in the muted meta section rather than // the business body — consistent with the grid's default-column derivation. - const isSystem = (key: string) => isSystemManagedField(key, objectSchema?.fields?.[key]); + const isSystem = (key: string) => isSystemManagedField(key, objectFields?.[key]); const regularFields = entries.filter(([key]) => !isSystem(key) && !isHidden(key)); const metaFields = entries.filter(([key]) => isSystem(key) && key !== '_id' && key !== 'id' && !isHidden(key)); @@ -5312,8 +5312,15 @@ export const ObjectGrid: React.FC = ({ return ; } + // objectui#10657 — while the object's field types are unknown (the + // schema read is in flight, or it failed) no value here can be typed, + // and any of them could be a `password` / `secret` field: each is drawn + // WITHHELD, as the mask, like the grid's own untyped cells, never as the + // inferred text below. + if (objectTypesPending) return ; + // Use objectSchema field type for type-aware rendering - const fieldDef = objectSchema?.fields?.[key]; + const fieldDef = objectFields?.[key]; // Through the shared resolve, so the panel honours a `format` hint the // same way the row above it does (objectui#8920). `rendererType` is null // exactly when the key has no declared type, which is the guard this @@ -5384,7 +5391,10 @@ export const ObjectGrid: React.FC = ({ {formatFieldLabel(key)} - {String(coerceToSafeValue(value) ?? '')} + + {/* Withheld while the field types are unknown (objectui#10657). */} + {objectTypesPending ? : String(coerceToSafeValue(value) ?? '')} + ))} diff --git a/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx index 47e327f607..cccee85c3f 100644 --- a/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx +++ b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx @@ -31,6 +31,9 @@ * - HOST CATALOGUE: handed the object's fields (`objectFields`, what `ListView` * passes), the grid has no window: first paint draws each column from its * declared type while its own read is still held. + * - THE RECORD PANEL a row opens under overlay navigation, with no declared + * fields in hand, used to print every value of the record by inference: it + * withholds them too after a failed read. * * ## Controls * @@ -43,7 +46,7 @@ import React from 'react'; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; -import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ActionProvider } from '@object-ui/react'; import { registerAllFields } from '@object-ui/fields'; @@ -105,8 +108,8 @@ function heldDataSource() { } /** Rows handed down by a host, the way `ListView` hands them. */ -function renderHostFed(ds: unknown, extra: { grouping?: unknown; objectFields?: unknown } = {}) { - const { grouping, objectFields } = extra; +function renderHostFed(ds: unknown, extra: { grouping?: unknown; objectFields?: unknown; navigation?: unknown } = {}) { + const { grouping, objectFields, navigation } = extra; return render( { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { ds, reject } = heldDataSource(); + renderHostFed(ds, { navigation: { mode: 'drawer' } }); + await waitFor(() => expect(screen.getByText(CONTROL_VALUE)).toBeInTheDocument()); + reject(new Error('metadata read refused')); + await waitFor(() => + expect(warn.mock.calls.some((c) => String(c[0]).includes('Failed to fetch objectSchema for inline data'))).toBe(true)); + + fireEvent.click(screen.getByText(CONTROL_VALUE)); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + const dialog = screen.getByRole('dialog'); + // CONTROL — the panel rendered this record's fields, not an empty shell. + expect(within(dialog).getByText('Api key')).toBeInTheDocument(); + expect(dialog.textContent, 'the panel draws the values as the mask').toContain(MASK); + expect(document.body.innerHTML, 'the raw credential is nowhere in the DOM').not.toContain(RAW_KEY); + expect(document.body.innerHTML, 'the withheld note is nowhere in the DOM').not.toContain(RAW_NOTE); + }); }); From 9f9b8b79df7e075f7d809221fb8d5e0cb9c640e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:07:00 +0000 Subject: [PATCH 6/7] chore(changeset): name the pending types sentence this change supersedes (objectui#10657) Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- .changeset/10657-types-withheld-doc.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/10657-types-withheld-doc.md b/.changeset/10657-types-withheld-doc.md index 5363daa072..f79b9a38a1 100644 --- a/.changeset/10657-types-withheld-doc.md +++ b/.changeset/10657-types-withheld-doc.md @@ -10,4 +10,6 @@ failed, and that `object-grid` draws an untyped column's raw value on its host-f path until its object schema arrives. Neither holds any more: each of the three producers now draws such a column as the mask and sets the flag on it, and `object-grid` takes the field types from a host that has them (`objectFields`). The JSDoc is rewritten to say so. -No type or schema shape changes. +The pending objectui#10657 `@object-ui/types` changeset says these docs state what that +window does not cover; once this change ships with it, they state what it withholds +instead. No type or schema shape changes. From da23a3342c3f58117ef86e686c2622e1e5c180b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:43:42 +0000 Subject: [PATCH 7/7] test(plugin-grid): readiness no longer reads a withheld cell inside the schema window (objectui#10657) `rowRecordCrudVerdict`'s 250-row case (CI `Test (shard 3/8)`) waited for the raw text of an untyped column. Since this card that column is withheld (drawn as the mask) until the grid's own schema read lands, so the wait covered a re-render of every row and outran waitFor's budget. Measured by a runtime sweep over the 264 test files that render these producers with a schema read: five cases wait across a withheld window on 50 or more rows. Their readiness now counts the rows on screen (rowRecordCrudVerdict x2, serverPagination x2), or, for the ListView paging shape, hands the grid the field catalogue that host now passes (groupedPartialDisclosure-7189). No assertion of the tests' subjects changes; no timeout is raised. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude --- .../groupedPartialDisclosure-7189.test.tsx | 6 ++++++ .../src/__tests__/rowRecordCrudVerdict.test.tsx | 16 ++++++++++++---- .../src/__tests__/serverPagination.test.tsx | 10 ++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx b/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx index acbeea458f..c411cf4cca 100644 --- a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx +++ b/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx @@ -344,6 +344,12 @@ describe('ObjectGrid — a grouped grid discloses that it grouped a page (object page: 1, pageSize: 100, onPageChange: () => {}, + // objectui#10657: the ListView paging shape now hands the grid the + // object's field catalogue with its rows. Without it the grouping key + // (`business_unit`, no column type) is WITHHELD — refused — until the + // grid's own schema read lands, so the notice this case reads would sit + // behind that re-render of 100 rows. + objectFields: OBJECT_FIELDS, }); await vi.waitFor(() => expect(notice()).toBeInTheDocument()); diff --git a/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx b/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx index 69a492a3b3..11aed818c4 100644 --- a/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx +++ b/packages/plugin-grid/src/__tests__/rowRecordCrudVerdict.test.tsx @@ -429,8 +429,12 @@ describe('[#4296] one batched call per (object, operation) per page', () => { it('a 50-row page issues 2 explain calls, not 100', async () => { const rows = bigPage(50); for (const r of rows) server.verdicts.set(r.id, { update: true, delete: true }); - renderGrid({ rows }); - await waitFor(() => expect(screen.getByText('Row 49')).toBeInTheDocument()); + const { container } = renderGrid({ rows }); + // objectui#10657: the fixture's `name` column authors no type, so until the + // grid's own schema read lands it is WITHHELD (drawn as the mask) and its + // text is no readiness signal: on this many rows the settled re-render + // outran the wait. The rows being on screen is: count them. + await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(rows.length)); await settle(2); expect(server.calls.length).toBe(2); @@ -460,8 +464,12 @@ describe('[#4296] one batched call per (object, operation) per page', () => { const cap = EXPLAIN_BATCH_MAX_RECORD_IDS; const rows = bigPage(cap + Math.min(50, cap)); for (const r of rows) server.verdicts.set(r.id, { update: true, delete: true }); - renderGrid({ rows }); - await waitFor(() => expect(screen.getByText(`Row ${rows.length - 1}`)).toBeInTheDocument()); + const { container } = renderGrid({ rows }); + // objectui#10657: the fixture's `name` column authors no type, so until the + // grid's own schema read lands it is WITHHELD (drawn as the mask) and its + // text is no readiness signal: on this many rows the settled re-render + // outran the wait. The rows being on screen is: count them. + await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(rows.length)); await settle(4); expect(server.calls.length).toBe(4); diff --git a/packages/plugin-grid/src/__tests__/serverPagination.test.tsx b/packages/plugin-grid/src/__tests__/serverPagination.test.tsx index 79fa8efc7d..277e64f26d 100644 --- a/packages/plugin-grid/src/__tests__/serverPagination.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverPagination.test.tsx @@ -198,12 +198,18 @@ describe('ObjectGrid — external (host-driven) manual pagination (#2212)', () = // ceil(3125 / 50) = 63 — proves the footer uses the host total, not the // 50-row window it was handed. await waitFor(() => expect(container.textContent).toContain('63')); - await waitFor(() => expect(screen.getByText('Row 0')).toBeInTheDocument()); + // objectui#10657: the fixture's `name` column authors no type, so until the + // grid's own schema read lands it is WITHHELD (drawn as the mask) and its + // text is no readiness signal. The host's window being drawn is: count it. + await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(PAGE_SIZE)); }); it('calls the external onPageChange when the footer turns the page (no client slice)', async () => { const { container, onPageChange } = renderExternal(); - await waitFor(() => expect(screen.getByText('Row 0')).toBeInTheDocument()); + // objectui#10657: the fixture's `name` column authors no type, so until the + // grid's own schema read lands it is WITHHELD (drawn as the mask) and its + // text is no readiness signal. The host's window being drawn is: count it. + await waitFor(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(PAGE_SIZE)); const navButtons = Array.from(container.querySelectorAll('button')).filter( (b) => !(b as HTMLButtonElement).disabled, );