From bcec471b85d4dff4aa9400226dd32dc37317957f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:37:03 +0000 Subject: [PATCH 01/11] fix(components,plugin-grid,types): a masked grid cell hands its raw value to nobody Ctrl+C / Cmd+C on a focused password or secret cell wrote String(row[accessorKey]) to the clipboard while the cell drew the mask, and the cell wrapper carried the raw value as its title tooltip. - types: TableColumn declares `masked?: boolean`, mirrored as z.boolean() on TableColumnSchema and tombstoned on the static table column (lockstep rule). - plugin-grid: the emit seam stamps `masked: true` from isMaskedFieldType() (via isMaskedGridColumn), as the narrow-only union of the column's type and the object-declared type, before the type fold erases it. - components: data-table obeys the flag - the keyboard copy writes nothing, no title tooltip is drawn, and the CSV export omits the column. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../data-table-masked-column-10583.test.tsx | 162 +++++++++++++++ .../src/renderers/complex/data-table.tsx | 22 +- packages/plugin-grid/src/ObjectGrid.tsx | 22 +- .../maskedCellCopyRefusal-10583.test.tsx | 193 ++++++++++++++++++ packages/plugin-grid/src/maskedColumn.ts | 43 ++++ .../static-table-narrow-surface.test.ts | 26 ++- packages/types/src/data-display.ts | 33 +++ packages/types/src/zod/data-display.zod.ts | 20 +- 8 files changed, 502 insertions(+), 19 deletions(-) create mode 100644 packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx create mode 100644 packages/plugin-grid/src/maskedColumn.ts diff --git a/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx new file mode 100644 index 0000000000..d7dddeeeb8 --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx @@ -0,0 +1,162 @@ +/** + * 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. + */ + +/** + * `TableColumn.masked` — the table hands a masked column's raw value to nobody + * (objectui#10583). + * + * The producer (`ObjectGrid`, from `isMaskedFieldType()`) draws the mask + * through `cell` and sets the flag; this table cannot import + * `@object-ui/fields`, so it obeys the flag on every path IT owns that put the + * raw value somewhere else: + * + * 1. Ctrl+C / Cmd+C on a focused cell — wrote `String(row[accessorKey])`; + * 2. the cell wrapper's `title` tooltip — carried the raw value into the DOM; + * 3. the toolbar's CSV export — wrote every column's raw value. + * + * Each path is pinned three ways in ONE file, so an absence can never pass by + * never running: the masked column refuses; an ordinary column in the same + * table behaves as before; and the SAME column with the flag ABSENT hands the + * value out, which proves the flag — not the test's shape — is what refuses. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { TableColumnSchema } from '@object-ui/types/zod'; +import '../data-table'; + +const RAW = 'RAW-KEY-10583'; +const MASK = '••••••'; +const CONTROL = 'Ada'; +const ROWS = [{ id: '1', name: CONTROL, key: RAW }]; + +let writeText: ReturnType; +let createObjectURL: ReturnType; + +beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + createObjectURL = vi.fn(() => 'blob:objectui-10583'); + Object.defineProperty(window.URL, 'createObjectURL', { configurable: true, value: createObjectURL }); + Object.defineProperty(window.URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +/** `masked` undefined ⇒ the key is ABSENT from the column, not `false`. */ +function renderTable(masked: boolean | undefined) { + const DataTable = ComponentRegistry.get('data-table') as any; + if (!DataTable) throw new Error('data-table not registered'); + return render( + MASK, ...(masked === undefined ? {} : { masked }) }, + ], + pagination: false, + searchable: false, + exportable: true, + }} + />, + ); +} + +/** The body cell under the header `label`. */ +function cellUnder(label: string): HTMLElement { + const headers = Array.from(document.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" header rendered`).toBeGreaterThanOrEqual(0); + const cell = document.querySelector('tbody tr')?.children[index] as HTMLElement | undefined; + expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD'); + return cell!; +} + +const payloads = () => writeText.mock.calls.map((call) => call[0]); + +async function exportedCsv(): Promise { + fireEvent.click(screen.getByRole('button', { name: /Export CSV/ })); + expect(createObjectURL, 'CONTROL: the export produced a file').toHaveBeenCalledTimes(1); + return (createObjectURL.mock.calls[0]![0] as Blob).text(); +} + +describe('data-table — `masked: true` withholds the raw value (objectui#10583)', () => { + for (const [chord, init] of [ + ['Ctrl+C', { key: 'c', ctrlKey: true }], + ['Cmd+C', { key: 'c', metaKey: true }], + ] as const) { + it(`${chord} on a masked cell writes nothing; the ordinary cell copies its value`, () => { + renderTable(true); + fireEvent.keyDown(cellUnder('Name'), init); + expect(payloads(), 'CONTROL: the ordinary cell copies').toEqual([CONTROL]); + writeText.mockClear(); + + const masked = cellUnder('Key'); + expect(masked.textContent, 'CONTROL: the producer drew the mask').toContain(MASK); + fireEvent.keyDown(masked, init); + expect(payloads(), 'the masked cell writes nothing').toEqual([]); + }); + } + + it('draws no `title` tooltip on a masked cell — the raw value is nowhere in the DOM', () => { + renderTable(true); + expect( + cellUnder('Name').querySelector('[title]')?.getAttribute('title'), + 'CONTROL: an ordinary cell keeps its tooltip', + ).toBe(CONTROL); + expect(cellUnder('Key').querySelector('[title]'), 'the masked cell carries no tooltip').toBeNull(); + expect(document.body.innerHTML).not.toContain(RAW); + }); + + it('omits a masked column from the CSV export, header and all', async () => { + renderTable(true); + const csv = await exportedCsv(); + expect(csv, 'CONTROL: the ordinary column is exported').toContain(`"${CONTROL}"`); + expect(csv.split('\n')[0], 'the header row names only the ordinary column').toBe('Name'); + expect(csv).not.toContain(RAW); + }); +}); + +describe('data-table — the flag ABSENT behaves exactly as before (objectui#10583 control)', () => { + it('Ctrl+C copies the raw value, the tooltip carries it, the export writes it', async () => { + renderTable(undefined); + fireEvent.keyDown(cellUnder('Key'), { key: 'c', ctrlKey: true }); + expect(payloads()).toEqual([RAW]); + expect(cellUnder('Key').querySelector('[title]')?.getAttribute('title')).toBe(RAW); + const csv = await exportedCsv(); + expect(csv.split('\n')[0]).toBe('Name,Key'); + expect(csv).toContain(`"${RAW}"`); + }); + + it('`masked: false` is the same as absent', () => { + renderTable(false); + fireEvent.keyDown(cellUnder('Key'), { key: 'c', ctrlKey: true }); + expect(payloads()).toEqual([RAW]); + }); +}); + +describe('`masked` is declared on the rich column mirror (objectui#10583)', () => { + it('SURVIVES the zod parse — a non-strict object would otherwise strip it silently', () => { + const parsed = TableColumnSchema.safeParse({ header: 'Key', accessorKey: 'key', masked: true }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.masked).toBe(true); + // A value of the wrong type is refused by name, not coerced. + const wrong = TableColumnSchema.safeParse({ header: 'Key', accessorKey: 'key', masked: 'yes' }); + expect(wrong.success).toBe(false); + if (!wrong.success) expect(wrong.error.issues.map((i) => String(i.path[0]))).toContain('masked'); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 2070b13612..04709adfb2 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -1431,10 +1431,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }; const handleExport = () => { + // A MASKED column is OMITTED from the export (objectui#10583), header and + // all: its cells draw a mask, so the file must not carry the raw value. + // Omitted rather than blanked — a column of empty strings would assert + // the records hold nothing, and a re-import of it would write that. + const exportColumns = columns.filter((col) => !col.masked); const csvContent = [ - columns.map(col => col.header).join(','), + exportColumns.map(col => col.header).join(','), ...sortedData.map(row => - columns.map(col => JSON.stringify(row[col.accessorKey] || '')).join(',') + exportColumns.map(col => JSON.stringify(row[col.accessorKey] || '')).join(',') ) ].join('\n'); @@ -1814,6 +1819,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // Copy cell value with Ctrl+C / Cmd+C if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) { e.preventDefault(); + // A MASKED column copies NOTHING (objectui#10583) — the producer drew a + // mask, so the keyboard must not hand out what the cell hides. The + // detail page's house shape (objectui#8440, option A): no copy at all, + // ⛔ not the bullets — which is also why `preventDefault()` above stays: + // measured in Chromium, letting the default run copies a selected mask + // as `••••••`, the payload that ruling refused. Unmasked cells are + // untouched below. + if (columns.find((col) => col.accessorKey === columnKey)?.masked) return; const globalIdx = (effectivePage - 1) * pageSize + rowIndex; const row = sortedData[manualPagination ? rowIndex : globalIdx]; if (row) { @@ -2675,7 +2688,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ? 'w-full whitespace-normal break-words' : 'truncate w-full' } - title={!isFit && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined} + // No tooltip on a MASKED column (objectui#10583): + // the title carried the raw value, so a hover + // showed what the cell's mask hides. + title={!isFit && !col.masked && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined} > {typeof col.cell === 'function' ? col.cell(cellValue, row) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index c400e286c5..33192160a1 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -33,6 +33,7 @@ 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 { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the @@ -3699,7 +3700,26 @@ export const ObjectGrid: React.FC = ({ // producer's types have not held in practice. Destructuring a null below // would throw where the pre-#6004 code passed it through. if (!col) return col; - const { type: producerType, ...rest } = col; + const { type: producerType, ...draft } = col; + // ⭐ THE MASKED FLAG (objectui#10583) — stamped HERE, before the fold, + // because the fold is exactly what erases the answer: `password` and + // `secret` are not `TableColumnType` members, so `normalizeTableColumnType` + // drops them and `data-table` could never tell a masked column from a + // text one. It also must not ask the question itself — it cannot import + // `@object-ui/fields` — so this producer asks `isMaskedFieldType()` (via + // `isMaskedGridColumn`) and the table obeys the flag: no raw value to the + // clipboard, a `title` tooltip or its CSV export. + // + // Every path that writes `type` is covered for the same reason the fold + // is: all four `generateColumns()` literals and the enrichment map above + // pass through this pass. The object-declared type is read beside the + // producer's for the narrow-only union — path A forwards a VIEW-authored + // 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) + ? { ...draft, masked: true } + : draft; if (producerType == null) return rest; const normalized = normalizeTableColumnType(producerType); if (normalized === undefined) return rest; diff --git a/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx new file mode 100644 index 0000000000..608eeffe3f --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.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. + */ + +/** + * A MASKED grid cell hands its raw value to nobody (objectui#10583). + * + * ## The defect + * + * `@object-ui/fields` draws `password` and `secret` cells as `••••••`. The + * grid drew the mask, and `data-table`'s Ctrl+C / Cmd+C handler copied + * `String(row[columnKey])` for EVERY focused cell anyway — so the keyboard + * handed the raw credential to `navigator.clipboard.writeText`, silently. The + * same cell's wrapper also carried the raw value as its `title` tooltip, so a + * hover showed it and the DOM held it. + * + * The detail page closed the same class on its own surface (objectui#8440, + * maintainer ruling 2026-09-08, option A: no copy affordance on masked field + * types; copying the bullets was refused as a second silent wrong answer). The + * grid follows that house shape: + * + * - `ObjectGrid` (the producer) asks `isMaskedFieldType()` from + * `@object-ui/fields` (objectui#8686) and stamps `masked: true` on the column; + * - `data-table` (which cannot import `@object-ui/fields`) obeys the flag — + * nothing is written to the clipboard, and no raw `title` is drawn. + * + * ## Narrow-only, like the detail page + * + * The flag is the UNION of the view-authored type and the object-declared type + * (`isMaskedDetailFieldType`'s shape, objectui#3355): a view authoring + * `type: 'text'` over a `secret` column never restores the copy. + * + * ## Every case carries a CONTROL in the same mounted tree + * + * The pin is absence-shaped ("the spy was not called"), which also passes when + * the cell never rendered or the event never landed. So each case first fires + * the SAME keystroke on an ordinary `text` cell and requires the spy to + * receive that cell's value, and only then requires silence from the masked + * cell. Absence is counted AT THE SPY. + * + * All three column-emit shapes are exercised (configured `ListColumn[]`, a + * string array, and the object-schema default), because the flag is stamped at + * the one emit seam every one of them passes through. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const RAW_PASSWORD = 'RAW-PASSWORD-10583'; +const RAW_SECRET = 'RAW-SECRET-10583'; +const RAW_VAULT = 'RAW-VAULT-10583'; +const CONTROL_VALUE = 'Row one'; +const MASK = '••••••'; + +const ROWS = [ + { id: 'r1', name: CONTROL_VALUE, api_key: RAW_PASSWORD, token: RAW_SECRET, vault_key: RAW_VAULT }, +]; + +const FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, + token: { type: 'secret', label: 'Token' }, + vault_key: { type: 'secret', label: 'Vault Key' }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: FIELDS })), + } as any; +} + +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 }); + cleanup(); +}); + +function renderGrid(columns: unknown) { + const ds = makeDataSource(); + 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 table = row!.closest('table')!; + const headers = Array.from(table.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" column header rendered`).toBeGreaterThanOrEqual(0); + const cell = row!.children[index] as HTMLElement | undefined; + expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD'); + return cell!; +} + +const payloads = () => writeText.mock.calls.map((call) => call[0]); + +const COPY_KEYS = [ + { chord: 'Ctrl+C', init: { key: 'c', ctrlKey: true } }, + { chord: 'Cmd+C', init: { key: 'c', metaKey: true } }, +]; + +const SHAPES: Array<{ shape: string; columns: unknown; authoredTextOverSecret: boolean }> = [ + { + shape: 'configured ListColumn[]', + columns: [ + { field: 'name', label: 'Name' }, + { field: 'api_key', label: 'API Key' }, + { field: 'token', label: 'Token' }, + // The narrow-only case: a PRESENTATION override over a `secret` column. + { field: 'vault_key', label: 'Vault Key', type: 'text' }, + ], + authoredTextOverSecret: true, + }, + { shape: 'string array', columns: ['name', 'api_key', 'token'], authoredTextOverSecret: false }, + { shape: 'object-schema default (no columns)', columns: undefined, authoredTextOverSecret: false }, +]; + +describe('ObjectGrid — a masked cell writes nothing to the clipboard (objectui#10583)', () => { + for (const { shape, columns, authoredTextOverSecret } of SHAPES) { + for (const { chord, init } of COPY_KEYS) { + it(`${shape} — ${chord} on a password / secret cell copies nothing; the text control copies its value`, async () => { + renderGrid(columns); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + + // CONTROL — the SAME chord on an ordinary text cell reaches the spy, + // in this same mounted tree. + fireEvent.keyDown(cellUnder('Name'), init); + expect(payloads(), 'CONTROL: the text cell copies its own value').toEqual([CONTROL_VALUE]); + writeText.mockClear(); + + for (const [label, raw] of [['API Key', RAW_PASSWORD], ['Token', RAW_SECRET]] as const) { + const cell = cellUnder(label); + // CONTROL — the masked cell rendered, and rendered the MASK. + expect(cell.textContent, `CONTROL: the "${label}" cell drew the mask`).toContain(MASK); + fireEvent.keyDown(cell, init); + expect(payloads(), `${label}: ${chord} must write nothing`).toEqual([]); + // Not in the DOM at all — text OR attribute (the `title` tooltip). + expect(document.body.innerHTML, `${label}: the raw value is nowhere in the DOM`).not.toContain(raw); + } + + if (authoredTextOverSecret) { + // Narrow-only: `type: 'text'` authored over a `secret` column never + // restores the copy. + fireEvent.keyDown(cellUnder('Vault Key'), init); + expect(payloads(), 'Vault Key (text over secret): the copy stays refused').toEqual([]); + } + }); + } + } +}); diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts new file mode 100644 index 0000000000..b3152bea5f --- /dev/null +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +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) + * + * `ObjectGrid` stamps `TableColumn.masked` from this answer, and `data-table` + * obeys the flag: a masked column's raw value never reaches the clipboard, a + * `title` tooltip or the table's CSV export. `@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 + * as a mask" (objectui#8686). A type the fields package masks, declared + * (`password`, `secret`) or registered at runtime, sets the flag with no edit + * to this file. The detail page asks the same authority through the same shape + * (`isMaskedDetailFieldType` in `@object-ui/plugin-detail`, objectui#8440). + * + * **Narrow-only**: the answer is the UNION of the two types, so an authored + * column `type` can add the refusal but never withdraw it (objectui#3355). A + * view authoring `type: 'text'` over an object column declared `secret` keeps + * the flag: a PRESENTATION override has no business widening access to a + * 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. + */ +export function isMaskedGridColumn(columnType: unknown, objectFieldType: unknown): boolean { + return isMaskedType(columnType) || isMaskedType(objectFieldType); +} + +function isMaskedType(fieldType: unknown): boolean { + return typeof fieldType === 'string' && isMaskedFieldType(fieldType); +} diff --git a/packages/types/src/__tests__/static-table-narrow-surface.test.ts b/packages/types/src/__tests__/static-table-narrow-surface.test.ts index c3f1e1537d..34bdbb2f2a 100644 --- a/packages/types/src/__tests__/static-table-narrow-surface.test.ts +++ b/packages/types/src/__tests__/static-table-narrow-surface.test.ts @@ -62,12 +62,13 @@ type Equal = type Expect = T; // Side 3, type level: the narrow shape declares exactly the rich shape's key -// set — five live, fifteen tombstoned, none invented, none forgotten. If a +// set — five live, the rest tombstoned, none invented, none forgotten. If a // key is ever added to `TableColumn` without a deliberate decision on the // static side (live or tombstone), this line goes red. (`headerIcon` was the // first key to arrive through that gate — added rich by objectui#6424, // tombstoned here — then #6425's three declared field-meta overrides, -// `fitContent` (objectui#6424's second key), and now `wrap`, objectui#6650.) +// `fitContent` (objectui#6424's second key), `wrap` (objectui#6650), and now +// `masked`, objectui#10583.) type _SameKeySet = Expect>; // The DECLARATION itself, read directly off the interface — no object literal @@ -94,15 +95,16 @@ const LIVE_COLUMN = { width: 120, }; -/** The fifteen keys the narrow surface refuses, with the value an author - * would plausibly write for each: nine the #5474 split retired, plus the six - * that joined the RICH shape later and are tombstoned here under the lockstep +/** The keys the narrow surface refuses, with the value an author would + * plausibly write for each: nine the #5474 split retired, plus those that + * joined the RICH shape later and are tombstoned here under the lockstep * rule — `headerIcon` and `fitContent` (objectui#6424's two keys), the three * field-meta overrides objectui#6425 declared (`format` / `options` / - * `currency`), and `wrap` (objectui#6650). The static renderer reads none of - * them: its measured read set is the five live keys, it has no auto-width - * pass for `fitContent` to opt out of, and no truncation for `wrap` to - * switch off. */ + * `currency`), `wrap` (objectui#6650) and `masked` (objectui#10583). The + * static renderer reads none of them: its measured read set is the five live + * keys, it has no auto-width pass for `fitContent` to opt out of, no + * truncation for `wrap` to switch off, and no copy, tooltip or export for + * `masked` to withhold. */ const RETIRED_COLUMN_KEYS: Record = { minWidth: 80, align: 'right', @@ -119,6 +121,7 @@ const RETIRED_COLUMN_KEYS: Record = { options: [{ value: 'tech', label: 'Technology' }], currency: 'EUR', wrap: true, + masked: true, }; /** Every key the rich `TableColumn` interface declares. `satisfies` keeps the @@ -146,6 +149,7 @@ const RICH_COLUMN_KEYS = [ 'options', 'currency', 'wrap', + 'masked', ] as const satisfies readonly (keyof TableColumn)[]; type _RichKeyListExhaustive = Expect>; @@ -305,7 +309,7 @@ describe('the tombstone refusal reaches the author with its remediation text (ob } }); - it('the SCOPE BOUNDARY of #6105 is closed — the later eight answer with their guidance too (objectui#6931)', () => { + it('the SCOPE BOUNDARY of #6105 is closed — every later arrival answers with its guidance too (objectui#6931)', () => { // This assertion is the #6105 scope-boundary pin, FLIPPED deliberately. // It used to assert the opposite: that these seven — the five rich-shape // arrivals tombstoned here under the lockstep rule (#6424 / #6425) and the @@ -322,7 +326,7 @@ describe('the tombstone refusal reaches the author with its remediation text (ob expect(StaticTableColumnSchema.safeParse(LIVE_COLUMN).success).toBe(true); expect(TableZod.safeParse(STATIC_TABLE).success).toBe(true); - for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap'] as const) { + for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap', 'masked'] as const) { const result = StaticTableColumnSchema.safeParse({ header: 'Amount', accessorKey: 'amount', diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 3fb83f9142..bb2dc2ce5e 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -702,6 +702,31 @@ export interface TableColumn { * this slot and `data-table` reads it here — declared, forwarded, rendered. */ wrap?: boolean; + /** + * The column holds a MASKED value — a credential whose cell is drawn as a + * mask — so `data-table` hands the raw value to nobody: Ctrl+C / Cmd+C on + * one of its cells writes nothing to the clipboard, the cell carries no + * `title` tooltip, and the CSV export omits the column. Absent or `false` + * leaves every one of those paths exactly as it was. + * + * ⚠️ The flag withholds; it does not DRAW. The mask a reader sees comes from + * the column's {@link TableColumn.cell} renderer, which the producer that + * sets this flag supplies. A column with no `cell` still draws its value. + * + * ⭐ The producer decides, and the rule is not restated here: `ObjectGrid` + * stamps this flag from `isMaskedFieldType()` in `@object-ui/fields` + * (objectui#8686), the one authority for "is this field type's cell drawn as + * a mask", reading the view-authored type and the object-declared type as a + * narrow-only UNION — a view authoring `type: 'text'` over a `secret` + * column keeps the flag. `@object-ui/components` cannot import + * `@object-ui/fields`, so the table obeys the flag instead of asking the + * question itself. + * + * Declared by objectui#10583: the grid drew the mask while the table's + * keyboard copy wrote `String(row[accessorKey])` for every cell — the grid + * face of the disclosure objectui#8440 closed on the detail page. + */ + masked?: boolean; } /** @@ -761,6 +786,14 @@ export interface StaticTableColumn { * @deprecated Not part of the static `table` renderer's contract. */ wrap?: never; + /** + * NOT on the static `table` surface (objectui#10583) — declared on the rich + * {@link TableColumn} only, where `data-table` reads it. The static renderer + * has no keyboard copy, tooltip or export for it to withhold. Use + * `data-table` for the interactive set. + * @deprecated Not part of the static `table` renderer's contract. + */ + masked?: never; /** * RETIRED from the static `table` surface (objectui#5474, ADR-0049) — the * static renderer never read it; a right-aligned column authored here was diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 2d1974dffa..e5d159a44c 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -280,6 +280,16 @@ export const TableColumnSchema = z.object({ // declaration refuses. The `.describe()` text is the spec's own wording // for `ListColumn.wrap`, so the two authoring surfaces read alike. wrap: z.boolean().optional().describe('Allow text wrapping'), + // objectui#10583. Serializable metadata, so the mirror TYPES it — + // `z.boolean()`, like `fitContent` and `wrap`. Without this line the + // non-strict object would silently STRIP an authored `masked`, and the + // table would hand the raw value out again: the same second de-facto + // contract #6424 closed for `headerIcon`. The producer that sets it is + // `ObjectGrid`, from `isMaskedFieldType()` (`@object-ui/fields`). + masked: z + .boolean() + .optional() + .describe('Masked column: the table never hands the raw value out (no Ctrl+C / Cmd+C copy, no title tooltip, omitted from CSV export). Withholds only; the cell renderer draws the mask'), }); /** @@ -306,10 +316,11 @@ export const TableColumnSchema = z.object({ * `success`, same issue `path`, same issue `code` (`invalid_type`); only the * message differs. * - * The six later arrivals below (`headerIcon` / `fitContent`, objectui#6424; - * `format` / `options` / `currency`, objectui#6425; `wrap`, objectui#6650) - * were outside #6105's reviewed scope and carried the bare spelling until - * objectui#6931 converted them here (`wrap` was born converted). That + * The later arrivals below (`headerIcon` / `fitContent`, objectui#6424; + * `format` / `options` / `currency`, objectui#6425; `wrap`, objectui#6650; + * `masked`, objectui#10583) were outside #6105's reviewed scope; the first + * five carried the bare spelling until objectui#6931 converted them here + * (`wrap` and `masked` were born converted). That * mattered because a half-converted shape teaches worse than a uniform one: * an author reading guidance on nine keys and zod's generic on five learns the * message means something, then has it withheld. @@ -335,6 +346,7 @@ export const StaticTableColumnSchema = z.object({ options: retirementTombstone('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'), currency: retirementTombstone('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'), wrap: retirementTombstone('NOT on the static table surface (objectui#6650) — declared on the rich TableColumn only; use data-table'), + masked: retirementTombstone('NOT on the static table surface (objectui#10583) — declared on the rich TableColumn only; use data-table'), }); /** From 6bad2d4ff1713ca46f053d3fe950f8fec52ae593 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:49:03 +0000 Subject: [PATCH 02/11] docs(components,plugin-grid): document TableColumn.masked; changeset for the masked-cell refusal Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 30 +++++++++++++++++++ .../docs/components/complex/data-table.mdx | 16 ++++++++++ packages/plugin-grid/README.md | 7 +++++ 3 files changed, 53 insertions(+) create mode 100644 .changeset/10583-masked-cell-copy.md diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md new file mode 100644 index 0000000000..14d06335be --- /dev/null +++ b/.changeset/10583-masked-cell-copy.md @@ -0,0 +1,30 @@ +--- +'@object-ui/types': minor +'@object-ui/components': patch +'@object-ui/plugin-grid': patch +--- + +A masked grid cell no longer hands its raw value out (objectui#10583). + +`@object-ui/fields` draws `password` and `secret` cells as `••••••`. In `object-grid` the +cell drew the mask, but `data-table`'s Ctrl+C / Cmd+C handler copied +`String(row[accessorKey])` for every focused cell, so the keyboard wrote the **raw** +credential to the clipboard, silently. The same cell also carried the raw value as its +`title` tooltip, so a hover showed it and the DOM held it. This is the grid face of the +disclosure the detail page closed in objectui#8440. + +- **`@object-ui/types`: new declared key `TableColumn.masked?: boolean`**, mirrored as a + typed `z.boolean()` on `TableColumnSchema` and refused on the static `table` column + (`StaticTableColumn`), like the other rich-only keys. Additive. +- **`@object-ui/components`: `data-table` obeys the flag.** On a `masked` column, + Ctrl+C / Cmd+C writes nothing to the clipboard (and still prevents the browser's own + copy, which would put a selected mask on the clipboard as bullets), the cell has no + `title` tooltip, and the built-in CSV export leaves the column out. It is left out, not + blanked: a column of empty strings would claim the records hold nothing. The flag + withholds; it does not draw. The mask comes from the column's `cell` renderer. Columns + without the flag copy, show and export exactly as before. +- **`@object-ui/plugin-grid`: `ObjectGrid` sets the flag** at its column emit seam, from + `isMaskedFieldType()` (objectui#8686). It reads the column's type and the + object-declared type as a narrow-only union, the same shape as the detail page's + `isMaskedDetailFieldType`. So a view that authors `type: 'text'` over a `secret` field + keeps the refusal. Unmasked columns reach the table unchanged. diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index b3baf2c874..a15d3d4501 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -21,6 +21,7 @@ interface TableColumn { accessorKey: string; // Data property key width?: string; // Column width (e.g., '100px', '20%') sortable?: boolean; // Enable sorting for this column + masked?: boolean; // Never hand the raw value out (see Masked columns) } interface DataTableSchema { @@ -132,6 +133,21 @@ a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a pare edited in the same row re-scopes the child before anything is saved; `row` stays the place to read what the data source last returned. +## Masked columns + +A column with `masked: true` holds a credential: the table hands its raw value to +nobody. Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard, the cell +carries no `title` tooltip, and the CSV export leaves the column out. Every other +column copies, shows and exports exactly as before. + +The flag withholds; it does not draw. What a reader sees in the cell comes from the +column's `cell` renderer, and a column without one still draws its value. You rarely +set the flag by hand: `object-grid` sets it for every column whose field type is +drawn as a mask (`password`, `secret`, or a type registered with the mask), from +`isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column +`type` and the object's field type, so a view that shows a `secret` field as +`text` keeps the flag. + ## Examples ### Product Inventory diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 619cac6c84..88b6bac9d0 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -378,6 +378,13 @@ a render function used to be written for. A genuinely custom cell **renderer** i a component-layer concern: `VirtualGridColumn.cell` on `VirtualGrid`, a React prop, not an authoring key. +A `password` or `secret` field draws a mask (`••••••`), and the cell does not hand +the raw value out: Ctrl+C / Cmd+C on it copies nothing, and it has no tooltip. The +grid decides this from `isMaskedFieldType()` in `@object-ui/fields` and passes it to +the table as the column's `masked` flag (see the data table's "Masked columns"). A +column `type` authored over such a field (`type: 'text'` on a `secret` field) cannot +lift the refusal. + ### Selectable Grid ```typescript From 51ef48d1a27d21571d5155df9c33b9719c237836 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:14:03 +0000 Subject: [PATCH 03/11] fix(components,plugin-grid,types): withhold a masked field from inline edit, the grid's client export and the mobile card title Round 2 of the masked-cell refusal, from the contract review: - components: data-table's startEdit refuses a masked column (Enter, click, double-click), and a masked cell no longer reads as editable (no edit cursor, the row click is not swallowed). - plugin-grid: the client export fallback (CSV and JSON) leaves every masked field out, asked per key through isMaskedGridColumn; the mobile card draws a masked title through its cell and never classifies a masked field by name into the raw amount / stage / date / percent rows. - docs, JSDoc, zod describe, README, changeset: list exactly the paths withheld, and state what is not covered (client search and sort, the other column producers). Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 49 ++- .../docs/components/complex/data-table.mdx | 35 ++- .../data-table-masked-column-10583.test.tsx | 57 +++- .../src/renderers/complex/data-table.tsx | 12 +- packages/plugin-grid/README.md | 16 +- packages/plugin-grid/src/ObjectGrid.tsx | 33 +- .../maskedCellCopyRefusal-10583.test.tsx | 4 +- .../maskedColumnSurfaces-10583.test.tsx | 284 ++++++++++++++++++ packages/plugin-grid/src/maskedColumn.ts | 10 +- packages/types/src/data-display.ts | 30 +- packages/types/src/zod/data-display.zod.ts | 8 +- 11 files changed, 477 insertions(+), 61 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index 14d06335be..a4330933a8 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -4,27 +4,48 @@ '@object-ui/plugin-grid': patch --- -A masked grid cell no longer hands its raw value out (objectui#10583). +A masked grid field's raw value is withheld from copy, tooltip, inline edit, export and the mobile card title (objectui#10583). `@object-ui/fields` draws `password` and `secret` cells as `••••••`. In `object-grid` the -cell drew the mask, but `data-table`'s Ctrl+C / Cmd+C handler copied -`String(row[accessorKey])` for every focused cell, so the keyboard wrote the **raw** -credential to the clipboard, silently. The same cell also carried the raw value as its -`title` tooltip, so a hover showed it and the DOM held it. This is the grid face of the -disclosure the detail page closed in objectui#8440. +cell drew the mask, but the raw value still left through other paths: + +- `data-table`'s Ctrl+C / Cmd+C handler copied `String(row[accessorKey])` for every + focused cell. +- The cell's `title` tooltip carried the raw value, so a hover showed it and the DOM + held it. +- Inline edit seeded its editor with the raw value. +- The grid's client export wrote it: the CSV per column, the JSON as whole records. +- The mobile card printed the first column, and fields named like an amount or a + stage, raw. + +This is the grid face of the disclosure the detail page closed in objectui#8440. - **`@object-ui/types`: new declared key `TableColumn.masked?: boolean`**, mirrored as a typed `z.boolean()` on `TableColumnSchema` and refused on the static `table` column (`StaticTableColumn`), like the other rich-only keys. Additive. -- **`@object-ui/components`: `data-table` obeys the flag.** On a `masked` column, - Ctrl+C / Cmd+C writes nothing to the clipboard (and still prevents the browser's own - copy, which would put a selected mask on the clipboard as bullets), the cell has no - `title` tooltip, and the built-in CSV export leaves the column out. It is left out, not - blanked: a column of empty strings would claim the records hold nothing. The flag - withholds; it does not draw. The mask comes from the column's `cell` renderer. Columns - without the flag copy, show and export exactly as before. +- **`@object-ui/components`: `data-table` obeys the flag on four paths.** On a `masked` + column: + - Ctrl+C / Cmd+C writes nothing to the clipboard. It still blocks the browser's own + copy, which would put a selected mask on the clipboard as bullets. + - The cell has no `title` tooltip. + - The built-in CSV export leaves the column out. It is left out, not blanked: a + column of empty strings would claim the records hold nothing. + - The column never enters inline edit, by Enter, click or double-click, and its cell + no longer takes the row's click as an edit. + + Columns without the flag copy, show, export and edit exactly as before. - **`@object-ui/plugin-grid`: `ObjectGrid` sets the flag** at its column emit seam, from `isMaskedFieldType()` (objectui#8686). It reads the column's type and the object-declared type as a narrow-only union, the same shape as the detail page's `isMaskedDetailFieldType`. So a view that authors `type: 'text'` over a `secret` field - keeps the refusal. Unmasked columns reach the table unchanged. + keeps the refusal, and a view that authors `type: 'password'` masks a field the object + declares as text. The same rule leaves every masked field out of the grid's client + export (CSV and JSON, used when the data source has no server export) and draws a + masked field through its cell on the mobile card. Unmasked columns and files are + unchanged. + +**Not covered.** The flag withholds; it does not draw. The mask comes from the +producer's `cell` renderer, and `data-table` draws a column with no `cell` as its +value. The table's client-side search and sort still run over the raw values. The +related list and `object-data-table` produce `data-table` columns too, and they do not +set the flag yet. diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index a15d3d4501..1caf1a713f 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -21,7 +21,7 @@ interface TableColumn { accessorKey: string; // Data property key width?: string; // Column width (e.g., '100px', '20%') sortable?: boolean; // Enable sorting for this column - masked?: boolean; // Never hand the raw value out (see Masked columns) + masked?: boolean; // Withhold the raw value (see Masked columns) } interface DataTableSchema { @@ -135,18 +135,31 @@ the place to read what the data source last returned. ## Masked columns -A column with `masked: true` holds a credential: the table hands its raw value to -nobody. Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard, the cell -carries no `title` tooltip, and the CSV export leaves the column out. Every other -column copies, shows and exports exactly as before. +A column with `masked: true` holds a credential. The table withholds its raw value on +four paths: + +- Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard. +- The cell carries no `title` tooltip. +- The table's CSV export leaves the column out. +- The column never enters inline edit, on any trigger, because every editor would + start from the raw value. + +Every other column copies, shows, exports and edits exactly as before. + +What the flag does **not** cover: the table's client-side search and sort still run +over the raw values, so where they run on the client they can reveal whether a +masked value matches or how it orders. The flag withholds; it does not draw. What a reader sees in the cell comes from the -column's `cell` renderer, and a column without one still draws its value. You rarely -set the flag by hand: `object-grid` sets it for every column whose field type is -drawn as a mask (`password`, `secret`, or a type registered with the mask), from -`isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column -`type` and the object's field type, so a view that shows a `secret` field as -`text` keeps the flag. +producer's `cell` renderer, and the table draws a column with no `cell` as its value. +You rarely set the flag by hand: `object-grid` sets it for every column whose field +type is drawn as a mask (`password`, `secret`, or a type registered with the mask), +from `isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column +`type` and the object's field type, so a view that shows a `secret` field as `text` +keeps the flag. The same rule leaves masked fields out of `object-grid`'s own client +export (CSV and JSON) and draws them through their `cell` on its mobile card. Other +producers of `data-table` columns (the related list, `object-data-table`) do not set +the flag yet. ## Examples diff --git a/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx index d7dddeeeb8..3a1e227032 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx @@ -7,8 +7,8 @@ */ /** - * `TableColumn.masked` — the table hands a masked column's raw value to nobody - * (objectui#10583). + * `TableColumn.masked` — the table withholds a masked column's raw value on the + * paths it owns (objectui#10583). * * The producer (`ObjectGrid`, from `isMaskedFieldType()`) draws the mask * through `cell` and sets the flag; this table cannot import @@ -17,7 +17,9 @@ * * 1. Ctrl+C / Cmd+C on a focused cell — wrote `String(row[accessorKey])`; * 2. the cell wrapper's `title` tooltip — carried the raw value into the DOM; - * 3. the toolbar's CSV export — wrote every column's raw value. + * 3. the toolbar's CSV export — wrote every column's raw value; + * 4. inline edit — `startEdit` seeded the editor with the raw row value, so + * any editor (built-in input or a host's `renderCellEditor`) drew it. * * Each path is pinned three ways in ONE file, so an absence can never pass by * never running: the masked column refuses; an ordinary column in the same @@ -55,7 +57,7 @@ afterEach(() => { }); /** `masked` undefined ⇒ the key is ABSENT from the column, not `false`. */ -function renderTable(masked: boolean | undefined) { +function renderTable(masked: boolean | undefined, extra: Record = {}) { const DataTable = ComponentRegistry.get('data-table') as any; if (!DataTable) throw new Error('data-table not registered'); return render( @@ -71,6 +73,7 @@ function renderTable(masked: boolean | undefined) { pagination: false, searchable: false, exportable: true, + ...extra, }} />, ); @@ -160,3 +163,49 @@ describe('`masked` is declared on the rich column mirror (objectui#10583)', () = if (!wrong.success) expect(wrong.error.issues.map((i) => String(i.path[0]))).toContain('masked'); }); }); + +describe('data-table — a masked column never enters edit mode (objectui#10583)', () => { + /** Every editor this table can draw is an input or a textarea. */ + const rawInAnEditor = () => + Array.from(document.querySelectorAll('input, textarea')).some((el) => + el.value.includes(RAW), + ); + + it('single-click mode — click and Enter open no editor; the click reaches the row like a read-only cell', () => { + const onRowClick = vi.fn(); + renderTable(true, { editable: true, singleClickEdit: true, onRowClick }); + const masked = cellUnder('Key'); + expect(masked.textContent, 'CONTROL: the producer drew the mask').toContain(MASK); + expect(masked.className, 'no edit cursor on a masked cell').not.toContain('cursor-text'); + + fireEvent.click(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea'), 'no editor in the masked cell').toBeNull(); + expect(rawInAnEditor(), 'no editor holds the raw value').toBe(false); + expect(document.body.innerHTML).not.toContain(RAW); + expect(onRowClick, 'the click is not swallowed by an edit that never opens').toHaveBeenCalledTimes(1); + + // CONTROL — the ordinary column in the SAME table does open its editor. + fireEvent.click(cellUnder('Name')); + expect(cellUnder('Name').querySelector('input'), 'CONTROL: the ordinary cell edits').not.toBeNull(); + }); + + it('double-click mode — double-click and Enter open no editor; the ordinary cell still edits', () => { + renderTable(true, { editable: true }); + const masked = cellUnder('Key'); + fireEvent.doubleClick(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea')).toBeNull(); + expect(rawInAnEditor()).toBe(false); + expect(document.body.innerHTML).not.toContain(RAW); + + fireEvent.doubleClick(cellUnder('Name')); + expect(cellUnder('Name').querySelector('input'), 'CONTROL: the ordinary cell edits').not.toBeNull(); + }); + + it('CONTROL — the same column with the flag ABSENT edits exactly as before', () => { + renderTable(undefined, { editable: true, singleClickEdit: true }); + fireEvent.click(cellUnder('Key')); + expect(rawInAnEditor(), 'without the flag the editor is seeded with the stored value').toBe(true); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 04709adfb2..8fcaa964d2 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -1591,6 +1591,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const column = columns.find(col => col.accessorKey === columnKey); if (column?.editable === false) return; + // A MASKED column never enters edit mode, on any trigger (objectui#10583): + // the editor is seeded with the RAW row value below, so every editor — + // the built-in inputs and a host's `renderCellEditor` alike — would draw + // the credential the cell's mask hides. This is the one door Enter, click + // and double-click all pass through. + if (column?.masked) return; editingCellRef.current = { rowIndex, columnKey }; setEditingCell({ rowIndex, columnKey }); @@ -2440,7 +2446,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const hasPendingChange = rowChanges[col.accessorKey] !== undefined; const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey; - const isEditable = editable && col.editable !== false; + // A masked column reads as NOT editable here too + // (objectui#10583), so its cell neither shows the + // edit cursor nor swallows the row's click — + // `startEdit` would refuse it anyway. + const isEditable = editable && col.editable !== false && !col.masked; const isFrozen = frozenColumns > 0 && colIndex < frozenColumns; const frozenOffset = isFrozen ? measuredStickyLefts?.[(selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + colIndex] diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 88b6bac9d0..3f7ddc0d8d 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -378,12 +378,16 @@ a render function used to be written for. A genuinely custom cell **renderer** i a component-layer concern: `VirtualGridColumn.cell` on `VirtualGrid`, a React prop, not an authoring key. -A `password` or `secret` field draws a mask (`••••••`), and the cell does not hand -the raw value out: Ctrl+C / Cmd+C on it copies nothing, and it has no tooltip. The -grid decides this from `isMaskedFieldType()` in `@object-ui/fields` and passes it to -the table as the column's `masked` flag (see the data table's "Masked columns"). A -column `type` authored over such a field (`type: 'text'` on a `secret` field) cannot -lift the refusal. +A `password` or `secret` field draws a mask (`••••••`), and the grid withholds its +raw value on these paths: Ctrl+C / Cmd+C on the cell copies nothing, the cell has no +tooltip, it never enters inline edit, the table's CSV export and the grid's own +client export (CSV and JSON, used when the data source has no server export) leave +it out, and the mobile card draws it through its cell. The grid decides this with +`isMaskedFieldType()` from `@object-ui/fields` and passes it to the table as the +column's `masked` flag (see the data table's "Masked columns"). A column `type` +authored over such a field (`type: 'text'` on a `secret` field) cannot lift the +refusal, though such a cell then draws the value as the text it was told to be. +Not covered: the table's client-side search and sort still run over the raw values. ### Selectable Grid diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 33192160a1..07d1f35b74 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3541,8 +3541,17 @@ export const ObjectGrid: React.FC = ({ : str; }; + // objectui#10583 — a MASKED field leaves neither file. The same rule that + // 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. + const columnTypeByKey = new Map(generateColumns().map((c) => [c.accessorKey, c.type])); + const isMaskedKey = (key: string) => + isMaskedGridColumn(columnTypeByKey.get(key), objectSchema?.fields?.[key]?.type); + if (format === 'csv') { - const cols = generateColumns().filter((c) => c.accessorKey !== '_actions'); + const cols = generateColumns().filter((c) => c.accessorKey !== '_actions' && !isMaskedKey(c.accessorKey)); const fields = cols.map((c) => c.accessorKey); const headers = cols.map((c) => c.header); const rows: string[] = []; @@ -3554,7 +3563,10 @@ export const ObjectGrid: React.FC = ({ }); downloadFile(new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }), fileNameFor('csv')); } else if (format === 'json') { - downloadFile(new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }), fileNameFor('json')); + const unmasked = exportData.map((record) => + Object.fromEntries(Object.entries(record).filter(([key]) => !isMaskedKey(key))), + ); + 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]); @@ -3707,8 +3719,8 @@ export const ObjectGrid: React.FC = ({ // drops them and `data-table` could never tell a masked column from a // text one. It also must not ask the question itself — it cannot import // `@object-ui/fields` — so this producer asks `isMaskedFieldType()` (via - // `isMaskedGridColumn`) and the table obeys the flag: no raw value to the - // clipboard, a `title` tooltip or its CSV export. + // `isMaskedGridColumn`) and the table obeys the flag: no Ctrl+C / Cmd+C + // copy, no `title` tooltip, no column in its CSV export, no inline edit. // // Every path that writes `type` is covered for the same reason the fold // is: all four `generateColumns()` literals and the enrichment map above @@ -5177,7 +5189,16 @@ export const ObjectGrid: React.FC = ({ return 'border-l-gray-300'; }; + // objectui#10583 — a MASKED column (the rule that stamps `TableColumn.masked`) + // is drawn only through its own `cell`, which draws the mask. The branches + // 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. + const isMaskedCardColumn = (key: string) => + isMaskedGridColumn(colMap.get(key)?.type, objectSchema?.fields?.[key]?.type); + const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => { + if (isMaskedCardColumn(key)) return 'other'; const k = key.toLowerCase(); if (amountKeys.some(p => k.includes(p))) return 'amount'; if (stageKeys.some(p => k.includes(p))) return 'stage'; @@ -5237,7 +5258,9 @@ export const ObjectGrid: React.FC = ({ {/* Title row - Name as bold prominent title */} {titleCol && (
- {coerceToSafeValue(row[titleCol.accessorKey]) ?? '—'} + {isMaskedCardColumn(titleCol.accessorKey) + ? titleCol.cell?.(row[titleCol.accessorKey], row) + : (coerceToSafeValue(row[titleCol.accessorKey]) ?? '—')}
)} diff --git a/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx index 608eeffe3f..145157ffcf 100644 --- a/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx +++ b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx @@ -7,7 +7,9 @@ */ /** - * A MASKED grid cell hands its raw value to nobody (objectui#10583). + * A MASKED grid cell withholds its raw value from the keyboard copy and the + * tooltip (objectui#10583). The other grid surfaces are pinned in + * `maskedColumnSurfaces-10583.test.tsx`. * * ## The defect * diff --git a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx new file mode 100644 index 0000000000..b91441b0e0 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx @@ -0,0 +1,284 @@ +/** + * 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. + */ + +/** + * The OTHER grid surfaces a masked field's raw value reached (objectui#10583, + * round 2). `maskedCellCopyRefusal-10583.test.tsx` pins the keyboard copy and + * the tooltip; this file pins the three paths the contract review named: + * + * 1. INLINE EDIT — the editor was seeded with the raw row value. Reachable + * through the flag's own union: a view authoring `type: 'password'` over an + * object `text` field (or over no field def at all) is masked, while the + * object-level `isFieldInlineEditable` gate reads only the object's type. + * 2. THE GRID'S CLIENT EXPORT — the `exportOptions` menu's fallback, taken + * when the data source has no `exportDownload`: CSV wrote every column's + * raw value and JSON wrote whole records. + * 3. THE MOBILE CARD — the title row printed the first column raw, and the + * amount / stage branches, chosen by the field's NAME, printed it too. + * + * Each case carries a control in the same mounted tree (an ordinary field + * edits, exports, draws), and the export pins carry a grid with no masked + * field whose files are byte-identical to what the fallback always wrote. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const MASK = '••••••'; +const CONTROL_VALUE = 'Row one'; +const RAW = { + password: 'RAW-PASSWORD-10583', + secret: 'RAW-SECRET-10583', + vault: 'RAW-VAULT-10583', + pin: 'RAW-PIN-10583', + hidden: 'RAW-HIDDEN-10583', + secretValue: 'RAW-SECRET-VALUE-10583', + status: 'RAW-STATUS-10583', +}; + +const ORIGINAL_INNER_WIDTH = window.innerWidth; +const setWidth = (px: number) => + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: px }); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => setWidth(1280)); + +afterEach(() => { + setWidth(ORIGINAL_INNER_WIDTH); + vi.restoreAllMocks(); + cleanup(); +}); + +function makeDataSource(rows: Record[], fields: Record) { + // ⚠️ No `exportDownload`: that is what sends the grid's export to its client fallback. + return { + find: vi.fn(async () => ({ data: rows, total: rows.length, hasMore: false, pageSize: 50 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields })), + } as any; +} + +function renderGrid(schema: Record, ds?: any) { + const grid = ; + return render( + + {ds ? {grid} : grid} + , + ); +} + +/** 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 rawInAnEditor = (raw: string) => + Array.from(document.querySelectorAll('input, textarea')).some((el) => + el.value.includes(raw), + ); + +/* ── 1. inline edit ──────────────────────────────────────────────────────── */ + +describe('ObjectGrid — a masked column opens no inline editor (objectui#10583)', () => { + const ROWS = [{ id: 'r1', name: CONTROL_VALUE, pin: RAW.pin }]; + // The view authors `type: 'password'` over a field the object declares `text`. + const COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'pin', label: 'PIN', type: 'password' }, + ]; + + const cases = [ + { + name: 'over an object `text` field', + render: () => + renderGrid( + { objectName: 'masked_edit_probe', columns: COLUMNS, editable: true, singleClickEdit: true }, + makeDataSource(ROWS, { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, pin: { type: 'text', label: 'PIN' } }), + ), + }, + { + name: 'over no field def (inline rows)', + render: () => + renderGrid({ data: { provider: 'value', items: ROWS }, columns: COLUMNS, editable: true, singleClickEdit: true }), + }, + ]; + + for (const c of cases) { + it(`view \`type: 'password'\` ${c.name} — click and Enter open no editor; the text control edits`, async () => { + c.render(); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + + const masked = cellUnder('PIN'); + expect(masked.textContent, 'CONTROL: the authored password type drew the mask').toContain(MASK); + fireEvent.click(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea'), 'no editor in the masked cell').toBeNull(); + expect(rawInAnEditor(RAW.pin), 'no editor holds the raw value').toBe(false); + expect(document.body.innerHTML, 'the raw value is nowhere in the DOM').not.toContain(RAW.pin); + + // CONTROL — the text column in the same editable grid opens its editor. + fireEvent.click(cellUnder('Name')); + await waitFor(() => + expect(cellUnder('Name').querySelector('input, textarea'), 'CONTROL: the text cell edits').not.toBeNull(), + ); + }); + } +}); + +/* ── 2. the grid's client export ─────────────────────────────────────────── */ + +describe("ObjectGrid — the client export leaves every masked field out (objectui#10583)", () => { + let blobs: Blob[]; + + beforeEach(() => { + blobs = []; + const createObjectURL = vi.fn((blob: Blob) => { + blobs.push(blob); + return 'blob:objectui-10583'; + }); + for (const target of new Set([globalThis.URL, window.URL])) { + Object.defineProperty(target, 'createObjectURL', { configurable: true, value: createObjectURL }); + Object.defineProperty(target, 'revokeObjectURL', { configurable: true, value: vi.fn() }); + } + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + async function exportAs(format: 'CSV' | 'JSON'): Promise { + const toolbarExport = screen + .getAllByRole('button', { name: /export/i }) + .find((el) => !/export as/i.test(el.textContent ?? '')); + expect(toolbarExport, 'CONTROL: the export menu button rendered').toBeDefined(); + fireEvent.click(toolbarExport!); + fireEvent.click(await screen.findByRole('button', { name: new RegExp(`export as ${format}`, 'i') })); + await waitFor(() => expect(blobs.length, `CONTROL: the ${format} export produced a file`).toBeGreaterThan(0)); + return blobs.pop()!.text(); + } + + const MASKED_ROWS = [ + { + id: 'r1', + name: CONTROL_VALUE, + notes: 'plain note', + api_key: RAW.password, + token: RAW.secret, + vault_key: RAW.vault, + pin: RAW.pin, + hidden_secret: RAW.hidden, + }, + ]; + const MASKED_FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + notes: { type: 'text', label: 'Notes' }, + api_key: { type: 'password', label: 'API Key' }, + token: { type: 'secret', label: 'Token' }, + vault_key: { type: 'secret', label: 'Vault Key' }, + pin: { type: 'text', label: 'PIN' }, + // Not a column: only the JSON branch (whole records) could carry it. + hidden_secret: { type: 'secret', label: 'Hidden' }, + }; + const MASKED_COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'notes', label: 'Notes' }, + { field: 'api_key', label: 'API Key' }, + { field: 'token', label: 'Token' }, + { field: 'vault_key', label: 'Vault Key', type: 'text' }, + { field: 'pin', label: 'PIN', type: 'password' }, + ]; + + function renderExportGrid(rows: Record[], fields: Record, columns: unknown[]) { + renderGrid( + { objectName: 'masked_export_probe', columns, exportOptions: { formats: ['csv', 'json'] } }, + makeDataSource(rows, fields), + ); + } + + it('CSV — no masked column, header and all; the ordinary columns are written', async () => { + renderExportGrid(MASKED_ROWS, MASKED_FIELDS, MASKED_COLUMNS); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + const csv = await exportAs('CSV'); + expect(csv.split('\n')[0], 'only the ordinary columns').toBe('Name,Notes'); + expect(csv, 'CONTROL: the ordinary values are written').toContain(`${CONTROL_VALUE},plain note`); + for (const raw of Object.values(RAW)) expect(csv).not.toContain(raw); + }); + + it('JSON — no masked field in any record, column or not; the other fields are written', async () => { + renderExportGrid(MASKED_ROWS, MASKED_FIELDS, MASKED_COLUMNS); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + const json = JSON.parse(await exportAs('JSON')); + expect(json, 'CONTROL: the unmasked fields are written').toEqual([{ id: 'r1', name: CONTROL_VALUE, notes: 'plain note' }]); + }); + + it('CONTROL — a grid with no masked field exports byte-for-byte what the fallback always wrote', async () => { + const rows = [{ id: 'r1', name: CONTROL_VALUE, notes: 'plain note' }]; + renderExportGrid( + rows, + { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, notes: { type: 'text', label: 'Notes' } }, + [{ field: 'name', label: 'Name' }, { field: 'notes', label: 'Notes' }], + ); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + expect(await exportAs('CSV')).toBe(`Name,Notes\n${CONTROL_VALUE},plain note`); + expect(await exportAs('JSON')).toBe(JSON.stringify(rows, null, 2)); + }); +}); + +/* ── 3. the mobile card ──────────────────────────────────────────────────── */ + +describe('ObjectGrid — the mobile card draws a masked field through its cell (objectui#10583)', () => { + it('below 768px — a masked TITLE column and masked fields named like amount / stage draw the mask, never the value', async () => { + setWidth(375); + const rows = [ + { id: 'r1', api_key: RAW.password, name: CONTROL_VALUE, secret_value: RAW.secretValue, token_status: RAW.status }, + ]; + renderGrid( + { + objectName: 'masked_card_probe', + // `api_key` FIRST — the card's title row. `secret_value` would classify + // as an amount and `token_status` as a stage, by name. + columns: [ + { field: 'api_key', label: 'API Key' }, + { field: 'name', label: 'Name' }, + { field: 'secret_value', label: 'Secret Value' }, + { field: 'token_status', label: 'Token Status' }, + ], + }, + makeDataSource(rows, { + id: { type: 'text' }, + api_key: { type: 'password', label: 'API Key' }, + name: { type: 'text', label: 'Name' }, + secret_value: { type: 'secret', label: 'Secret Value' }, + token_status: { type: 'secret', label: 'Token Status' }, + }), + ); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE), 'CONTROL: the card drew the ordinary field').not.toBeNull()); + expect(document.querySelector('table'), 'CONTROL: this is the card layout, not the table').toBeNull(); + + const html = document.body.innerHTML; + for (const raw of [RAW.password, RAW.secretValue, RAW.status]) { + expect(html, `${raw} is nowhere in the card`).not.toContain(raw); + } + const masks = (document.body.textContent ?? '').split(MASK).length - 1; + expect(masks, 'the title and both named fields draw the mask').toBe(3); + }); +}); diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index b3152bea5f..18bf013ccc 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -14,10 +14,12 @@ import { isMaskedFieldType } from '@object-ui/fields'; * (objectui#10583) * * `ObjectGrid` stamps `TableColumn.masked` from this answer, and `data-table` - * obeys the flag: a masked column's raw value never reaches the clipboard, a - * `title` tooltip or the table's CSV export. `@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. + * 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, and its mobile card draws them through `cell`. `@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 diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index bb2dc2ce5e..6a0d8993f1 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -704,23 +704,31 @@ export interface TableColumn { wrap?: boolean; /** * The column holds a MASKED value — a credential whose cell is drawn as a - * mask — so `data-table` hands the raw value to nobody: Ctrl+C / Cmd+C on - * one of its cells writes nothing to the clipboard, the cell carries no - * `title` tooltip, and the CSV export omits the column. Absent or `false` - * leaves every one of those paths exactly as it was. + * mask. `data-table` withholds the raw value on four paths of its own: + * Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard, the + * cell carries no `title` tooltip, the table's CSV export leaves the column + * out, and the column never enters inline edit (on any trigger), because + * every editor is seeded with the raw value. Absent or `false` leaves every + * one of those paths exactly as it was. + * + * ⚠️ What the flag does NOT cover: the table's client-side search and sort + * still run over the raw values, so where they run on the client they can + * answer questions about a masked value. * * ⚠️ The flag withholds; it does not DRAW. The mask a reader sees comes from - * the column's {@link TableColumn.cell} renderer, which the producer that - * sets this flag supplies. A column with no `cell` still draws its value. + * the PRODUCER's {@link TableColumn.cell} renderer. The table draws a column + * with no `cell` as its value, flag or not. * * ⭐ The producer decides, and the rule is not restated here: `ObjectGrid` * stamps this flag from `isMaskedFieldType()` in `@object-ui/fields` * (objectui#8686), the one authority for "is this field type's cell drawn as * a mask", reading the view-authored type and the object-declared type as a * narrow-only UNION — a view authoring `type: 'text'` over a `secret` - * column keeps the flag. `@object-ui/components` cannot import - * `@object-ui/fields`, so the table obeys the flag instead of asking the - * question itself. + * column keeps the flag. The same rule also leaves masked fields out of the + * grid's own client export and draws them through `cell` on its mobile card. + * `@object-ui/components` cannot import `@object-ui/fields`, so the table + * obeys the flag instead of asking the question itself. Other producers of + * these columns (`RelatedList`, `ObjectDataTable`) do not set it yet. * * Declared by objectui#10583: the grid drew the mask while the table's * keyboard copy wrote `String(row[accessorKey])` for every cell — the grid @@ -789,8 +797,8 @@ export interface StaticTableColumn { /** * NOT on the static `table` surface (objectui#10583) — declared on the rich * {@link TableColumn} only, where `data-table` reads it. The static renderer - * has no keyboard copy, tooltip or export for it to withhold. Use - * `data-table` for the interactive set. + * has no keyboard copy, tooltip, export or inline edit for it to withhold. + * Use `data-table` for the interactive set. * @deprecated Not part of the static `table` renderer's contract. */ masked?: never; diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index e5d159a44c..8ce416c7bc 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -283,13 +283,13 @@ export const TableColumnSchema = z.object({ // objectui#10583. Serializable metadata, so the mirror TYPES it — // `z.boolean()`, like `fitContent` and `wrap`. Without this line the // non-strict object would silently STRIP an authored `masked`, and the - // table would hand the raw value out again: the same second de-facto - // contract #6424 closed for `headerIcon`. The producer that sets it is - // `ObjectGrid`, from `isMaskedFieldType()` (`@object-ui/fields`). + // table would copy, tooltip, export and edit the raw value again: the same + // second de-facto contract #6424 closed for `headerIcon`. The producer that + // sets it is `ObjectGrid`, from `isMaskedFieldType()` (`@object-ui/fields`). masked: z .boolean() .optional() - .describe('Masked column: the table never hands the raw value out (no Ctrl+C / Cmd+C copy, no title tooltip, omitted from CSV export). Withholds only; the cell renderer draws the mask'), + .describe('Masked column: the table withholds the raw value from Ctrl+C / Cmd+C copy, the cell title tooltip, its CSV export and inline edit. Client-side search and sort still read the raw value. It withholds only: the producer\'s cell renderer draws the mask, and the table draws a column with no cell as its value'), }); /** From fc874c1e2d7b0d7425a5616a1fbf3e116288bcde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:20:29 +0000 Subject: [PATCH 04/11] test(plugin-grid): hold the inline-edit control cell by reference once it edits Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../src/__tests__/maskedColumnSurfaces-10583.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx index b91441b0e0..b79fdb6b91 100644 --- a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx +++ b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx @@ -138,9 +138,11 @@ describe('ObjectGrid — a masked column opens no inline editor (objectui#10583) expect(document.body.innerHTML, 'the raw value is nowhere in the DOM').not.toContain(RAW.pin); // CONTROL — the text column in the same editable grid opens its editor. - fireEvent.click(cellUnder('Name')); + // Held by reference: once it edits, its text lives in an input value. + const control = cellUnder('Name'); + fireEvent.click(control); await waitFor(() => - expect(cellUnder('Name').querySelector('input, textarea'), 'CONTROL: the text cell edits').not.toBeNull(), + expect(control.querySelector('input, textarea'), 'CONTROL: the text cell edits').not.toBeNull(), ); }); } From 2d37fd513b777c2ec31a2149dbe0074d00f8bf74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:10:45 +0000 Subject: [PATCH 05/11] fix(plugin-grid): refuse a masked field as a grouping key; state the uncovered paths exactly Round 3 of the masked-cell refusal, from the security re-review: - plugin-grid: a grouping entry on a masked field (isMaskedGridColumn over the view column type and the object-declared type) is dropped, and a console warning names it; the other grouping levels still apply. A masked group label alone would still bucket the records that share a credential, in its raw order. - The expanded-lookup JSON export stays open: the grid holds only its own object's schema, and the related object's field types are not in hand without a new fetch. It is stated as not covered. - docs, README, JSDoc, zod describe, changeset: add the server-streamed export, the expanded lookup record and the auto-width length to "not covered"; cite objectui#10657 and objectui#10658. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 37 +++++++---- .../docs/components/complex/data-table.mdx | 24 ++++--- packages/plugin-grid/README.md | 15 ++++- packages/plugin-grid/src/ObjectGrid.tsx | 40 +++++++++++- .../maskedColumnSurfaces-10583.test.tsx | 62 +++++++++++++++++++ packages/plugin-grid/src/maskedColumn.ts | 3 +- packages/types/src/data-display.ts | 17 +++-- packages/types/src/zod/data-display.zod.ts | 2 +- 8 files changed, 170 insertions(+), 30 deletions(-) diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index a4330933a8..f164672242 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -4,7 +4,7 @@ '@object-ui/plugin-grid': patch --- -A masked grid field's raw value is withheld from copy, tooltip, inline edit, export and the mobile card title (objectui#10583). +A masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refused as a grouping key (objectui#10583). `@object-ui/fields` draws `password` and `secret` cells as `••••••`. In `object-grid` the cell drew the mask, but the raw value still left through other paths: @@ -13,10 +13,11 @@ cell drew the mask, but the raw value still left through other paths: focused cell. - The cell's `title` tooltip carried the raw value, so a hover showed it and the DOM held it. -- Inline edit seeded its editor with the raw value. +- Inline edit, for a view-typed password column, seeded its editor with the raw value. - The grid's client export wrote it: the CSV per column, the JSON as whole records. - The mobile card printed the first column, and fields named like an amount or a stage, raw. +- Grouping by a masked field printed its raw value as each group's label. This is the grid face of the disclosure the detail page closed in objectui#8440. @@ -39,13 +40,25 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. object-declared type as a narrow-only union, the same shape as the detail page's `isMaskedDetailFieldType`. So a view that authors `type: 'text'` over a `secret` field keeps the refusal, and a view that authors `type: 'password'` masks a field the object - declares as text. The same rule leaves every masked field out of the grid's client - export (CSV and JSON, used when the data source has no server export) and draws a - masked field through its cell on the mobile card. Unmasked columns and files are - unchanged. - -**Not covered.** The flag withholds; it does not draw. The mask comes from the -producer's `cell` renderer, and `data-table` draws a column with no `cell` as its -value. The table's client-side search and sort still run over the raw values. The -related list and `object-data-table` produce `data-table` columns too, and they do not -set the flag yet. + declares as text. The same rule leaves every masked field of the grid's object out of + the grid's client export (CSV and JSON, used when the data source has no server + export) and draws a masked field through its cell on the mobile card. It also refuses + a masked field as a grouping key: the entry is ignored, the other grouping levels + still apply, and a console warning names the field. Masking the group label was not + enough, because the groups would still show which records share a credential, in its + raw order. Unmasked columns, files and groupings are unchanged. + +**Not covered.** + +- The flag withholds; it does not draw. The mask comes from the producer's `cell` + renderer, and `data-table` draws a column with no `cell` as its value. +- The table's client-side search and sort still run over the raw values + (objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10658). +- 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 field + of the related object is not pruned. The same holds for the table's CSV export of a + lookup column. +- The related list and `object-data-table` produce `data-table` columns too, and they do + not set the flag yet (objectui#10657). diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index 1caf1a713f..0c867e2f1d 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -146,9 +146,14 @@ four paths: Every other column copies, shows, exports and edits exactly as before. -What the flag does **not** cover: the table's client-side search and sort still run -over the raw values, so where they run on the client they can reveal whether a -masked value matches or how it orders. +What the flag does **not** cover: + +- The table's client-side search and sort still run over the raw values, so where + they run on the client they can reveal whether a masked value matches or how it + orders (objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10658). +- The CSV export of a lookup column whose value is an expanded record writes that + record whole, so a credential field of the related object is not pruned. The flag withholds; it does not draw. What a reader sees in the cell comes from the producer's `cell` renderer, and the table draws a column with no `cell` as its value. @@ -156,10 +161,15 @@ You rarely set the flag by hand: `object-grid` sets it for every column whose fi type is drawn as a mask (`password`, `secret`, or a type registered with the mask), from `isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column `type` and the object's field type, so a view that shows a `secret` field as `text` -keeps the flag. The same rule leaves masked fields out of `object-grid`'s own client -export (CSV and JSON) and draws them through their `cell` on its mobile card. Other -producers of `data-table` columns (the related list, `object-data-table`) do not set -the flag yet. +keeps the flag. The same rule leaves every masked field of the grid's object out of +`object-grid`'s 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: 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. Other producers of +`data-table` columns (the related list, `object-data-table`) do not set the flag yet +(objectui#10657). ## Examples diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 3f7ddc0d8d..4b8de1f0d7 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -382,12 +382,21 @@ A `password` or `secret` field draws a mask (`••••••`), and the grid raw value on these paths: Ctrl+C / Cmd+C on the cell copies nothing, the cell has no tooltip, it never enters inline edit, the table's CSV export and the grid's own client export (CSV and JSON, used when the data source has no server export) leave -it out, and the mobile card draws it through its cell. The grid decides this with -`isMaskedFieldType()` from `@object-ui/fields` and passes it to the table as the +it out, the mobile card draws it through its cell, and it cannot be a grouping key +(a grouping entry on it is ignored with a console warning). The grid decides this +with `isMaskedFieldType()` from `@object-ui/fields` and passes it to the table as the column's `masked` flag (see the data table's "Masked columns"). A column `type` authored over such a field (`type: 'text'` on a `secret` field) cannot lift the refusal, though such a cell then draws the value as the text it was told to be. -Not covered: the table's client-side search and sort still run over the raw values. + +Not covered: + +- The table's client-side search and sort still run over the raw values, and a + masked column's width is still sized from the raw value's length (objectui#10658). +- 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 + field of the related object is not pruned. ### Selectable Grid diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 07d1f35b74..270f3fe24e 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2535,8 +2535,46 @@ export const ObjectGrid: React.FC = ({ }; }, [schema.grouping, schema.columns, schema.objectName, objectSchema, translateOptions, t]); + // objectui#10583 — a MASKED field is REFUSED as a grouping key, loudly. + // 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 + // other entries still group, as `usableGroupingFields` does for an unusable + // 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. + const groupingFieldsRaw = schema.grouping?.fields; + const maskedGroupingSignature = React.useMemo(() => { + const cols = normalizeColumns(schema.columns) as any[] | undefined; + return JSON.stringify( + usableGroupingFields(groupingFieldsRaw) + .map((gf) => gf.field) + .filter((field) => isMaskedGridColumn( + cols?.find?.((c) => typeof c === 'object' && c?.field === field)?.type, + objectSchema?.fields?.[field]?.type, + )), + ); + }, [groupingFieldsRaw, schema.columns, objectSchema]); + // Keyed on the authored array and the signature STRING, never on a memo's + // identity (AGENTS.md #10), so the unmasked path hands `useGroupedData` the + // authored array itself. + const unmaskedGroupingFields = React.useMemo(() => { + const masked: string[] = JSON.parse(maskedGroupingSignature); + if (masked.length === 0) return groupingFieldsRaw; + return usableGroupingFields(groupingFieldsRaw).filter((gf) => !masked.includes(gf.field)); + }, [groupingFieldsRaw, maskedGroupingSignature]); + useEffect(() => { + const masked: string[] = JSON.parse(maskedGroupingSignature); + if (masked.length === 0) return; + console.warn( + `[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked ` + + `field(s) ${masked.join(', ')}. A masked field cannot be a grouping key: its group labels would ` + + 'show the raw value, and its groups would show which records share it. The entry was ignored.', + ); + }, [maskedGroupingSignature, schema.objectName]); + const { groups, isGrouped, toggleGroup } = useGroupedData( - schema.grouping, + maskedGroupingSignature === '[]' ? schema.grouping : { ...schema.grouping, fields: unmaskedGroupingFields }, data, schema.aggregations, groupValueFormatter, diff --git a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx index b79fdb6b91..f1a8bc7abf 100644 --- a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx +++ b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx @@ -20,6 +20,10 @@ * raw value and JSON wrote whole records. * 3. THE MOBILE CARD — the title row printed the first column raw, and the * amount / stage branches, chosen by the field's NAME, printed it too. + * 4. GROUPING (round 3) — a grouping entry on a masked field printed the raw + * value as the group label. The entry is now REFUSED (dropped, with a + * warning), not masked: masked labels would still bucket the records that + * share a credential, in its raw order. * * Each case carries a control in the same mounted tree (an ordinary field * edits, exports, draws), and the export pins carry a grid with no masked @@ -284,3 +288,61 @@ describe('ObjectGrid — the mobile card draws a masked field through its cell ( expect(masks, 'the title and both named fields draw the mask').toBe(3); }); }); + +/* ── 4. grouping ─────────────────────────────────────────────────────────── */ + +describe('ObjectGrid — a masked field is refused as a grouping key (objectui#10583)', () => { + const RAW_GROUP = 'RAW-GROUP-10643'; + // Two records SHARE the credential: a masked-label grouping would still say so. + const ROWS = [ + { id: 'r1', name: CONTROL_VALUE, category: 'Alpha', api_key: RAW_GROUP }, + { id: 'r2', name: 'Row two', category: 'Beta', api_key: RAW_GROUP }, + { id: 'r3', name: 'Row three', category: 'Alpha', api_key: 'RAW-OTHER-10643' }, + ]; + const FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + category: { type: 'text', label: 'Category' }, + api_key: { type: 'password', label: 'API Key' }, + }; + const COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'category', label: 'Category' }, + { field: 'api_key', label: 'API Key' }, + ]; + const groupRows = () => Array.from(document.querySelectorAll('[data-testid^="group-row-"]')); + + function renderGrouped(fields: Array<{ field: string }>) { + renderGrid( + { objectName: 'masked_group_probe', columns: COLUMNS, grouping: { fields } }, + makeDataSource(ROWS, FIELDS), + ); + } + + it('CONTROL — grouping by a text field draws its values as group labels', async () => { + renderGrouped([{ field: 'category' }]); + await waitFor(() => expect(groupRows().length, 'CONTROL: the grid grouped').toBe(2)); + const labels = groupRows().map((g) => g.textContent ?? ''); + expect(labels.some((l) => l.includes('Alpha')) && labels.some((l) => l.includes('Beta'))).toBe(true); + }); + + it('grouping by a password field is refused: no group, no raw label, and a warning names the field', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderGrouped([{ field: 'api_key' }]); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE), 'CONTROL: the rows rendered').not.toBeNull()); + expect(groupRows(), 'no group was built on the masked field').toHaveLength(0); + expect(document.body.innerHTML).not.toContain(RAW_GROUP); + expect(document.body.innerHTML).not.toContain('RAW-OTHER-10643'); + expect( + warn.mock.calls.some((call) => String(call[0]).includes('ObjectGrid grouping') && String(call[0]).includes('api_key')), + 'the refusal is reported, naming the field', + ).toBe(true); + }); + + it('a masked level is dropped and the other levels still group', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderGrouped([{ field: 'category' }, { field: 'api_key' }]); + await waitFor(() => expect(groupRows().length, 'the category level still groups').toBe(2)); + expect(document.body.innerHTML).not.toContain(RAW_GROUP); + }); +}); diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 18bf013ccc..7d6a122bf6 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -17,7 +17,8 @@ import { isMaskedFieldType } from '@object-ui/fields'; * 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, and its mobile card draws them through `cell`. `@object-ui/components` + * 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. * diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 6a0d8993f1..e3d34618e5 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -713,7 +713,8 @@ export interface TableColumn { * * ⚠️ What the flag does NOT cover: the table's client-side search and sort * still run over the raw values, so where they run on the client they can - * answer questions about a masked value. + * answer questions about a masked value, and the column's auto width is + * still sized from the raw value's length (objectui#10658). * * ⚠️ The flag withholds; it does not DRAW. The mask a reader sees comes from * the PRODUCER's {@link TableColumn.cell} renderer. The table draws a column @@ -724,11 +725,17 @@ export interface TableColumn { * (objectui#8686), the one authority for "is this field type's cell drawn as * a mask", reading the view-authored type and the object-declared type as a * narrow-only UNION — a view authoring `type: 'text'` over a `secret` - * column keeps the flag. The same rule also leaves masked fields out of the - * grid's own client export and draws them through `cell` on its mobile card. + * column keeps the flag. The same rule also leaves every masked field of the + * grid's object out of the grid's own client export, draws those fields + * through `cell` on its mobile card, and refuses them as grouping keys. * `@object-ui/components` cannot import `@object-ui/fields`, so the table - * obeys the flag instead of asking the question itself. Other producers of - * these columns (`RelatedList`, `ObjectDataTable`) do not set it yet. + * obeys the flag instead of asking the question itself. Not covered there: + * the server-streamed export (`exportDownload`) sends the masked columns as + * before and relies on the server's masking; the client JSON export, and + * this table's CSV export of a lookup column, write an expanded lookup + * record whole, so a credential field of the related object is not pruned; + * and other producers of these columns (`RelatedList`, `ObjectDataTable`) + * do not set the flag yet (objectui#10657). * * Declared by objectui#10583: the grid drew the mask while the table's * keyboard copy wrote `String(row[accessorKey])` for every cell — the grid diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 8ce416c7bc..7ca87ba27c 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -289,7 +289,7 @@ export const TableColumnSchema = z.object({ masked: z .boolean() .optional() - .describe('Masked column: the table withholds the raw value from Ctrl+C / Cmd+C copy, the cell title tooltip, its CSV export and inline edit. Client-side search and sort still read the raw value. It withholds only: the producer\'s cell renderer draws the mask, and the table draws a column with no cell as its value'), + .describe('Masked column: the table withholds the raw value from Ctrl+C / Cmd+C copy, the cell title tooltip, its CSV export and inline edit. Client-side search, sort and the column auto width still read the raw value. It withholds only: the producer\'s cell renderer draws the mask, and the table draws a column with no cell as its value'), }); /** From c2f54e531552127e355da6a25c5b825b7fad4ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:20:47 +0000 Subject: [PATCH 06/11] fix(plugin-grid): keep the refused-grouping config's fields defined at the type `unmaskedGroupingFields` is now always the filtered usable list, and the unmasked or ungrouped path hands `useGroupedData` the authored config itself, so the config passed never carries `fields: undefined`. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 270f3fe24e..2015d667b4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2556,11 +2556,10 @@ export const ObjectGrid: React.FC = ({ ); }, [groupingFieldsRaw, schema.columns, objectSchema]); // Keyed on the authored array and the signature STRING, never on a memo's - // identity (AGENTS.md #10), so the unmasked path hands `useGroupedData` the - // authored array itself. + // 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); - if (masked.length === 0) return groupingFieldsRaw; return usableGroupingFields(groupingFieldsRaw).filter((gf) => !masked.includes(gf.field)); }, [groupingFieldsRaw, maskedGroupingSignature]); useEffect(() => { @@ -2574,7 +2573,9 @@ export const ObjectGrid: React.FC = ({ }, [maskedGroupingSignature, schema.objectName]); const { groups, isGrouped, toggleGroup } = useGroupedData( - maskedGroupingSignature === '[]' ? schema.grouping : { ...schema.grouping, fields: unmaskedGroupingFields }, + maskedGroupingSignature === '[]' || !schema.grouping + ? schema.grouping + : { ...schema.grouping, fields: unmaskedGroupingFields }, data, schema.aggregations, groupValueFormatter, From 4d341308a2c71d7cc33be9abd6faab2aeaea2e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:22:16 +0000 Subject: [PATCH 07/11] refactor(plugin-grid): type the grouping refusal's column lookup instead of an any cast Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 029a1ca095..048c3dadf9 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2669,14 +2669,13 @@ export const ObjectGrid: React.FC = ({ // the object-declared type. const groupingFieldsRaw = schema.grouping?.fields; const maskedGroupingSignature = React.useMemo(() => { - const cols = normalizeColumns(schema.columns) as any[] | undefined; + 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( - cols?.find?.((c) => typeof c === 'object' && c?.field === field)?.type, - objectSchema?.fields?.[field]?.type, - )), + .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectSchema?.fields?.[field]?.type)), ); }, [groupingFieldsRaw, schema.columns, objectSchema]); // Keyed on the authored array and the signature STRING, never on a memo's From 244498f70604b94422e5be5b00e8add5ff598057 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:56:21 +0000 Subject: [PATCH 08/11] docs(components,plugin-grid,types): state the host-fetched schema window; scope the schema-dependent claims Round 4 (wording only, no behaviour change): - Add the host-fetched-path sentence (objectui#NEWCARD placeholder) to the changeset, the docs "Masked columns" section, the plugin-grid README, the TableColumn.masked JSDoc, the isMaskedGridColumn header and the grouping comment. - Scope the schema-dependent sentences with "once the object schema has loaded". - Cite objectui#10658 as folded into objectui#10657. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 20 ++++++++++------ .../docs/components/complex/data-table.mdx | 24 ++++++++++++------- packages/plugin-grid/README.md | 11 ++++++--- packages/plugin-grid/src/ObjectGrid.tsx | 4 +++- packages/plugin-grid/src/maskedColumn.ts | 5 ++++ packages/types/src/data-display.ts | 20 ++++++++++------ 6 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index f164672242..b27bc252c3 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -4,7 +4,7 @@ '@object-ui/plugin-grid': patch --- -A masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refused as a grouping key (objectui#10583). +Once the grid has its object schema, a masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refused as a grouping key (objectui#10583). `@object-ui/fields` draws `password` and `secret` cells as `••••••`. In `object-grid` the cell drew the mask, but the raw value still left through other paths: @@ -40,10 +40,11 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. object-declared type as a narrow-only union, the same shape as the detail page's `isMaskedDetailFieldType`. So a view that authors `type: 'text'` over a `secret` field keeps the refusal, and a view that authors `type: 'password'` masks a field the object - declares as text. The same rule leaves every masked field of the grid's object out of - the grid's client export (CSV and JSON, used when the data source has no server - export) and draws a masked field through its cell on the mobile card. It also refuses - a masked field as a grouping key: the entry is ignored, the other grouping levels + declares as text. Once the object schema has loaded, the same rule leaves every masked + field of the grid's object out of the grid's client export (CSV and JSON, used when the + data source has no server export) and draws a masked field through its cell on the + mobile card. Once the object schema has loaded, it also refuses a masked field as a + grouping key: the entry is ignored, the other grouping levels still apply, and a console warning names the field. Masking the group label was not enough, because the groups would still show which records share a credential, in its raw order. Unmasked columns, files and groupings are unchanged. @@ -53,8 +54,13 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. - The flag withholds; it does not draw. The mask comes from the producer's `cell` renderer, and `data-table` draws a column with no `cell` as its value. - The table's client-side search and sort still run over the raw values - (objectui#10658). -- A masked column's width is still sized from the raw value's length (objectui#10658). + (objectui#10657, which folded objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). +- 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 settles, an untyped view column over a + `password` / `secret` field draws and hands out the raw value (objectui#NEWCARD). - 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 field diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index 0c867e2f1d..dbcbb27191 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -150,8 +150,9 @@ What the flag does **not** cover: - The table's client-side search and sort still run over the raw values, so where they run on the client they can reveal whether a masked value matches or how it - orders (objectui#10658). -- A masked column's width is still sized from the raw value's length (objectui#10658). + orders (objectui#10657, which folded objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). - The CSV export of a lookup column whose value is an expanded record writes that record whole, so a credential field of the related object is not pruned. @@ -161,13 +162,18 @@ You rarely set the flag by hand: `object-grid` sets it for every column whose fi type is drawn as a mask (`password`, `secret`, or a type registered with the mask), from `isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column `type` and the object's field type, so a view that shows a `secret` field as `text` -keeps the flag. The same rule leaves every masked field of the grid's object out of -`object-grid`'s 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: 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. Other producers of +keeps the flag. Once the object schema has loaded, the same rule leaves every masked +field of the grid's object out of `object-grid`'s 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 settles an untyped +view column over a `password` / `secret` field draws and hands out the raw value +(objectui#NEWCARD). 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. Other producers of `data-table` columns (the related list, `object-data-table`) do not set the flag yet (objectui#10657). diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 4b8de1f0d7..9f96e24264 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -378,8 +378,8 @@ a render function used to be written for. A genuinely custom cell **renderer** i a component-layer concern: `VirtualGridColumn.cell` on `VirtualGrid`, a React prop, not an authoring key. -A `password` or `secret` field draws a mask (`••••••`), and the grid withholds its -raw value on these paths: Ctrl+C / Cmd+C on the cell copies nothing, the cell has no +Once the grid has loaded its object schema, a `password` or `secret` field draws a +mask (`••••••`), and the grid withholds its raw value on these paths: Ctrl+C / Cmd+C on the cell copies nothing, the cell has no tooltip, it never enters inline edit, the table's CSV export and the grid's own client export (CSV and JSON, used when the data source has no server export) leave it out, the mobile card draws it through its cell, and it cannot be a grouping key @@ -392,7 +392,12 @@ refusal, though such a cell then draws the value as the text it was told to be. Not covered: - The table's client-side search and sort still run over the raw values, and a - masked column's width is still sized from the raw value's length (objectui#10658). + masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). +- 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 settles, an untyped view column over a + `password` / `secret` field draws and hands out the raw value (objectui#NEWCARD). - 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 048c3dadf9..d6e850c965 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2659,7 +2659,9 @@ export const ObjectGrid: React.FC = ({ }; }, [schema.grouping, schema.columns, schema.objectName, objectSchema, translateOptions, t]); - // objectui#10583 — a MASKED field is REFUSED as a grouping key, loudly. + // 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#NEWCARD). // 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 diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 7d6a122bf6..2a49a72a7e 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -22,6 +22,11 @@ import { isMaskedFieldType } from '@object-ui/fields'; * 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 settles (objectui#NEWCARD). + * * 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 * as a mask" (objectui#8686). A type the fields package masks, declared diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 5d1dd0e356..28d6001c65 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -714,7 +714,8 @@ export interface TableColumn { * ⚠️ What the flag does NOT cover: the table's client-side search and sort * still run over the raw values, so where they run on the client they can * answer questions about a masked value, and the column's auto width is - * still sized from the raw value's length (objectui#10658). + * still sized from the raw value's length (objectui#10657, which folded + * objectui#10658). * * ⚠️ The flag withholds; it does not DRAW. The mask a reader sees comes from * the PRODUCER's {@link TableColumn.cell} renderer. The table draws a column @@ -725,12 +726,17 @@ export interface TableColumn { * (objectui#8686), the one authority for "is this field type's cell drawn as * a mask", reading the view-authored type and the object-declared type as a * narrow-only UNION — a view authoring `type: 'text'` over a `secret` - * column keeps the flag. The same rule also leaves every masked field of the - * grid's object out of the grid's own client export, draws those fields - * through `cell` on its mobile card, and refuses them as grouping keys. - * `@object-ui/components` cannot import `@object-ui/fields`, so the table - * obeys the flag instead of asking the question itself. Not covered there: - * the server-streamed export (`exportDownload`) sends the masked columns as + * column keeps the flag. Once the grid's object schema has loaded, the same + * rule also leaves every masked field of the grid's object out of the grid's + * own client export, draws those fields through `cell` on its mobile card, + * and refuses them as grouping keys. `@object-ui/components` cannot import + * `@object-ui/fields`, so the table obeys the flag instead of asking the + * question itself. 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 settles an untyped view column over a + * `password` / `secret` field draws and hands out the raw value + * (objectui#NEWCARD); the server-streamed export (`exportDownload`) sends the masked columns as * before and relies on the server's masking; the client JSON export, and * this table's CSV export of a lookup column, write an expanded lookup * record whole, so a credential field of the related object is not pruned; From fe124b7699db4aefa503812addbf0e1c00c5dd86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:05:52 +0000 Subject: [PATCH 09/11] docs(components,plugin-grid,types): cite objectui#10706 for the host-fetched schema window Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 2 +- content/docs/components/complex/data-table.mdx | 2 +- packages/plugin-grid/README.md | 2 +- packages/plugin-grid/src/ObjectGrid.tsx | 2 +- packages/plugin-grid/src/maskedColumn.ts | 2 +- packages/types/src/data-display.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index b27bc252c3..9d22f4b92e 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -60,7 +60,7 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. - 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 settles, an untyped view column over a - `password` / `secret` field draws and hands out the raw value (objectui#NEWCARD). + `password` / `secret` field draws and hands out the raw value (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 field diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index dbcbb27191..e2bfc272cb 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -170,7 +170,7 @@ not cover: on the host-fetched path (rows handed down as `data`, as `ListView` a `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 settles an untyped view column over a `password` / `secret` field draws and hands out the raw value -(objectui#NEWCARD). The server-streamed export (`exportDownload`) sends the masked +(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. Other producers of diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 9f96e24264..39723f9bc1 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -397,7 +397,7 @@ 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 settles, an untyped view column over a - `password` / `secret` field draws and hands out the raw value (objectui#NEWCARD). + `password` / `secret` field draws and hands out the raw value (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 d6e850c965..27919cafd4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2661,7 +2661,7 @@ export const ObjectGrid: React.FC = ({ // 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#NEWCARD). + // type is unknown: the host-fetched window, objectui#10706). // 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 diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 2a49a72a7e..28cec0e269 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -25,7 +25,7 @@ import { isMaskedFieldType } from '@object-ui/fields'; * ⚠️ 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 settles (objectui#NEWCARD). + * unmasked until that schema settles (objectui#10706). * * 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 diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 28d6001c65..fad91a3e01 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -736,7 +736,7 @@ export interface TableColumn { * the cell's own mask depend on the object schema, which the grid fetches * after first paint, so until it settles an untyped view column over a * `password` / `secret` field draws and hands out the raw value - * (objectui#NEWCARD); the server-streamed export (`exportDownload`) sends the masked columns as + * (objectui#10706); the server-streamed export (`exportDownload`) sends the masked columns as * before and relies on the server's masking; the client JSON export, and * this table's CSV export of a lookup column, write an expanded lookup * record whole, so a credential field of the related object is not pruned; From 2dcc28aae9df25bb36d9741da5dcb5cc15a8d7ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:25:43 +0000 Subject: [PATCH 10/11] docs(components,plugin-grid,types): the schema window can be permanent; scope the text-over-secret union Round 5 (wording only, no behaviour change): - "until it settles" becomes "until it arrives, and for good if that read fails": the grid swallows a failed schema read and keeps its heuristic column types (objectui#10706). - The object-declared half of the narrow-only union applies once the object schema has loaded; say so wherever the text-over-secret case is stated. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 15 ++++++++------- content/docs/components/complex/data-table.mdx | 10 +++++----- packages/plugin-grid/README.md | 6 ++++-- packages/plugin-grid/src/maskedColumn.ts | 3 ++- packages/types/src/data-display.ts | 13 +++++++------ 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index 9d22f4b92e..3f12c822ef 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -36,11 +36,11 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. Columns without the flag copy, show, export and edit exactly as before. - **`@object-ui/plugin-grid`: `ObjectGrid` sets the flag** at its column emit seam, from - `isMaskedFieldType()` (objectui#8686). It reads the column's type and the - object-declared type as a narrow-only union, the same shape as the detail page's - `isMaskedDetailFieldType`. So a view that authors `type: 'text'` over a `secret` field - keeps the refusal, and a view that authors `type: 'password'` masks a field the object - declares as text. Once the object schema has loaded, the same rule leaves every masked + `isMaskedFieldType()` (objectui#8686). It reads the column's type and, once the object + schema has loaded, the object-declared type as a narrow-only union, the same shape as + the detail page's `isMaskedDetailFieldType`. So a view that authors `type: 'password'` + masks a field the object declares as text from first paint, and a view that authors + `type: 'text'` over a `secret` field keeps the refusal once the schema has loaded. Once the object schema has loaded, the same rule leaves every masked field of the grid's object out of the grid's client export (CSV and JSON, used when the data source has no server export) and draws a masked field through its cell on the mobile card. Once the object schema has loaded, it also refuses a masked field as a @@ -59,8 +59,9 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. which folded objectui#10658). - 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 settles, an untyped view column over a - `password` / `secret` field draws and hands out the raw value (objectui#10706). + 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#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 field diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index e2bfc272cb..fd72f9fbf3 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -161,16 +161,16 @@ producer's `cell` renderer, and the table draws a column with no `cell` as its v You rarely set the flag by hand: `object-grid` sets it for every column whose field type is drawn as a mask (`password`, `secret`, or a type registered with the mask), from `isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column -`type` and the object's field type, so a view that shows a `secret` field as `text` -keeps the flag. Once the object schema has loaded, the same rule leaves every masked +`type` and, once the object schema has loaded, the object's field type, so a view that +shows a `secret` field as `text` keeps the flag once that schema has loaded. Once the object schema has loaded, the same rule leaves every masked field of the grid's object out of `object-grid`'s 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 settles an untyped -view column over a `password` / `secret` field draws and hands out the raw value -(objectui#10706). The server-streamed export (`exportDownload`) sends the masked +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#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. Other producers of diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 39723f9bc1..da980cc275 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -396,8 +396,10 @@ Not covered: which folded objectui#10658). - 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 settles, an untyped view column over a - `password` / `secret` field draws and hands out the raw value (objectui#10706). + 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#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/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 28cec0e269..95666407f2 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -25,7 +25,8 @@ import { isMaskedFieldType } from '@object-ui/fields'; * ⚠️ 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 settles (objectui#10706). + * unmasked until that schema arrives, and for good if the read fails + * (objectui#10706). * * 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 diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index fad91a3e01..d1125d7a70 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -724,9 +724,10 @@ export interface TableColumn { * ⭐ The producer decides, and the rule is not restated here: `ObjectGrid` * stamps this flag from `isMaskedFieldType()` in `@object-ui/fields` * (objectui#8686), the one authority for "is this field type's cell drawn as - * a mask", reading the view-authored type and the object-declared type as a - * narrow-only UNION — a view authoring `type: 'text'` over a `secret` - * column keeps the flag. Once the grid's object schema has loaded, the same + * a mask", reading the view-authored type and, once the grid's object schema + * has loaded, the object-declared type as a narrow-only UNION — a view + * authoring `type: 'text'` over a `secret` column keeps the flag once that + * schema has loaded. Once the grid's object schema has loaded, the same * rule also leaves every masked field of the grid's object out of the grid's * own client export, draws those fields through `cell` on its mobile card, * and refuses them as grouping keys. `@object-ui/components` cannot import @@ -734,9 +735,9 @@ export interface TableColumn { * question itself. 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 settles an untyped view column over a - * `password` / `secret` field draws and hands out the raw value - * (objectui#10706); the server-streamed export (`exportDownload`) sends the masked columns as + * 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#10706); the server-streamed export (`exportDownload`) sends the masked columns as * before and relies on the server's masking; the client JSON export, and * this table's CSV export of a lookup column, write an expanded lookup * record whole, so a credential field of the related object is not pruned; From d45109cb09eaddf9764a602c66274bfc33de1045 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:38:34 +0000 Subject: [PATCH 11/11] docs(components,plugin-grid,types): cite objectui#10657, which folded objectui#10706 Round 6: citation only, no behaviour change. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/10583-masked-cell-copy.md | 2 +- content/docs/components/complex/data-table.mdx | 2 +- packages/plugin-grid/README.md | 2 +- packages/plugin-grid/src/ObjectGrid.tsx | 2 +- packages/plugin-grid/src/maskedColumn.ts | 2 +- packages/types/src/data-display.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md index 3f12c822ef..9449426ec1 100644 --- a/.changeset/10583-masked-cell-copy.md +++ b/.changeset/10583-masked-cell-copy.md @@ -61,7 +61,7 @@ This is the grid face of the disclosure the detail page closed in objectui#8440. 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#10706). + 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 field diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index fd72f9fbf3..64e8e0da9b 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -170,7 +170,7 @@ not cover: on the host-fetched path (rows handed down as `data`, as `ListView` a `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#10706). The server-streamed export (`exportDownload`) sends the masked +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. Other producers of diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index da980cc275..fd73940e6b 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -399,7 +399,7 @@ Not covered: 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#10706). + (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 27919cafd4..5a641f7282 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2661,7 +2661,7 @@ export const ObjectGrid: React.FC = ({ // 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#10706). + // type is unknown: the host-fetched window, objectui#10657, which folded objectui#10706). // 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 diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts index 95666407f2..624977140c 100644 --- a/packages/plugin-grid/src/maskedColumn.ts +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -26,7 +26,7 @@ import { isMaskedFieldType } from '@object-ui/fields'; * 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#10706). + * (objectui#10657, which folded objectui#10706). * * 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 diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index d1125d7a70..d8f09e0f9b 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -737,7 +737,7 @@ export interface TableColumn { * 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#10706); the server-streamed export (`exportDownload`) sends the masked columns as + * 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, and * this table's CSV export of a lookup column, write an expanded lookup * record whole, so a credential field of the related object is not pruned;