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..2ea72f966b --- /dev/null +++ b/.changeset/10657-plugin-dashboard-withheld-draw.md @@ -0,0 +1,29 @@ +--- +'@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 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. + +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..1966c1d38c --- /dev/null +++ b/.changeset/10657-plugin-grid-withheld-first-paint.md @@ -0,0 +1,39 @@ +--- +'@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. +- **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 + 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..f79b9a38a1 --- /dev/null +++ b/.changeset/10657-types-withheld-doc.md @@ -0,0 +1,15 @@ +--- +'@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. +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. 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/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/README.md b/packages/plugin-dashboard/README.md index c19def8107..e79d724e95 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -624,7 +624,10 @@ 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. 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 8dcddfd033..6c9560c07c 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 }; @@ -1129,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.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-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..c66d247fb0 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.maskedFirstPaint-10657.test.tsx @@ -0,0 +1,193 @@ +/** + * 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. + * - 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 + * + * 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, 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`). +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); + }); + + 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-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/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-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.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-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..d2dd4e8b11 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -393,14 +393,22 @@ 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. 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 +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: -- 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/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5a641f7282..64aa5b9781 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; @@ -5197,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)); @@ -5214,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 @@ -5286,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) ?? '')} + ))} @@ -5358,8 +5466,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/__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__/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__/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..cccee85c3f --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedFirstPaint-10657.test.tsx @@ -0,0 +1,261 @@ +/** + * 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. + * - 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 + * + * 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, within } 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; navigation?: unknown } = {}) { + const { grouping, objectFields, navigation } = 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); + }); + + it('REJECTED: the record panel a row opens draws every value withheld', async () => { + 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); + }); +}); 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, ); 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 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); + } + }); +}); 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