Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/10583-masked-cell-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@object-ui/types': minor
'@object-ui/components': patch
'@object-ui/plugin-grid': patch
---

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:

- `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, 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.

- **`@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 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, 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
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#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 arrives, and for good if that read fails (the
grid swallows the failure and keeps its heuristic column types), an untyped view column
over a `password` / `secret` field draws and hands out the raw value (objectui#10657, which folded objectui#10706).
- The server-streamed export (`exportDownload`) sends the masked columns as before and
relies on the server's masking.
- The client JSON export writes an expanded lookup record whole, so a credential 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).
45 changes: 45 additions & 0 deletions content/docs/components/complex/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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; // Withhold the raw value (see Masked columns)
}

interface DataTableSchema {
Expand Down Expand Up @@ -132,6 +133,50 @@ 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 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 (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.

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.
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, 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 arrives, and for good
if that read fails, an untyped view column over a `password` / `secret` field draws
and hands out the raw value (objectui#10657, which folded objectui#10706). The server-streamed export (`exportDownload`) sends the masked
columns as before and relies on the server's masking, and the client JSON export
writes an expanded lookup record whole, so a credential field of the related object
is not pruned. Other producers of
`data-table` columns (the related list, `object-data-table`) do not set the flag yet
(objectui#10657).

## Examples

### Product Inventory
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* 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 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
* `@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;
* 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
* 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<typeof vi.fn>;
let createObjectURL: ReturnType<typeof vi.fn>;

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, extra: Record<string, unknown> = {}) {
const DataTable = ComponentRegistry.get('data-table') as any;
if (!DataTable) throw new Error('data-table not registered');
return render(
<DataTable
schema={{
type: 'data-table',
data: ROWS,
columns: [
{ header: 'Name', accessorKey: 'name' },
// The producer's shape: it draws the mask through `cell` and sets the flag.
{ header: 'Key', accessorKey: 'key', cell: () => MASK, ...(masked === undefined ? {} : { masked }) },
],
pagination: false,
searchable: false,
exportable: true,
...extra,
}}
/>,
);
}

/** 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<string> {
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');
});
});

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<HTMLInputElement | HTMLTextAreaElement>('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);
});
});
Loading
Loading