diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index b7c43c8d9..e67e08e84 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -138,6 +138,7 @@ export function ResultsGrid({ const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnId: string } | null>(null); const [editValue, setEditValue] = useState(""); const [viewMode, setViewMode] = useState<"card" | "table">("card"); + const [wrapText, setWrapText] = useState(false); const [selectedRow, setSelectedRow] = useState<{ row: Record; index: number } | null>(null); const [columnFilters, setColumnFilters] = useState>(new Map()); const [activeFilterCol, setActiveFilterCol] = useState(null); @@ -218,6 +219,9 @@ export function ResultsGrid({ }, []); const columns = useMemo>[]>(() => { + // `truncate` carries its own `white-space: nowrap`, so wrapping has to replace it here, + // on the element holding the value, not only on the cell around it. + const valueFlow = wrapText ? "whitespace-pre-wrap break-words" : "truncate h-full"; return result.fields.map((field) => ({ // `id` + `accessorFn`, never `accessorKey`: TanStack reads a DOT in an // accessorKey as a path into the row, so `shipping.city` was fetched as @@ -371,7 +375,7 @@ export function ResultsGrid({ if (effectiveMaskingEnabled && sensitivePattern && val !== null && val !== undefined && !isRevealed) { const masked = maskValueByPattern(val, sensitivePattern); return ( -
+
{masked} {userCanReveal && ( {editingEnabled && pendingChanges && pendingChanges.length > 0 && (
@@ -116,6 +145,7 @@ export function StatsBar({ variant="ghost" size="sm" className="h-6 px-1.5 text-xs text-success hover:bg-success-tint/10" + aria-label="Apply changes" onClick={onApplyChanges} > @@ -124,6 +154,7 @@ export function StatsBar({ variant="ghost" size="sm" className="h-6 px-1.5 text-xs text-danger hover:bg-danger-tint/10" + aria-label="Discard changes" onClick={onDiscardChanges} > diff --git a/tests/components/ResultsGrid.test.tsx b/tests/components/ResultsGrid.test.tsx index 5695175ea..1dbc07bfb 100644 --- a/tests/components/ResultsGrid.test.tsx +++ b/tests/components/ResultsGrid.test.tsx @@ -85,6 +85,13 @@ mock.module("@/components/results-grid/StatsBar", () => ({ `${(props.pendingChanges as unknown[]).length} changes`, ) : null, + props.onToggleWrapText + ? React.createElement( + "button", + { "data-testid": "wrap-toggle", onClick: props.onToggleWrapText as () => void }, + "WRAP", + ) + : null, (props.activeFilterCount as number) > 0 ? React.createElement( "button", @@ -108,6 +115,7 @@ mock.module("@/components/results-grid/StatsBar", () => ({ })); // ── Mock @tanstack/react-virtual ──────────────────────────────────────────── +const mockVirtualizerMeasure = mock(() => {}); mock.module("@tanstack/react-virtual", () => ({ useVirtualizer: (opts: { count: number }) => ({ getVirtualItems: () => @@ -118,6 +126,8 @@ mock.module("@tanstack/react-virtual", () => ({ key: i, })), getTotalSize: () => opts.count * 36, + measureElement: () => {}, + measure: mockVirtualizerMeasure, }), })); @@ -1061,11 +1071,13 @@ describe("ResultsGrid", () => { test("sorting reorders the rendered rows, not just the header indicator", () => { const { getAllByRole, container } = render(React.createElement(ResultsGrid, { result: mockResult })); - // `:not([data-testid])` excludes the mocked ResultCard above, which also - // carries data-index; only the desktop table's rows come off the table - // instance, and they are the ones the row model orders. + // `:not([data-testid])` excludes the mocked ResultCard above and `:not(button)` + // the mobile table's rows, which both carry data-index too; only the desktop + // table's rows come off the table instance, and they are the ones the row model orders. const renderedRows = () => - Array.from(container.querySelectorAll("[data-index]:not([data-testid])")).map((row) => row.textContent ?? ""); + Array.from(container.querySelectorAll("[data-index]:not([data-testid]):not(button)")).map( + (row) => row.textContent ?? "", + ); expect(renderedRows()).toHaveLength(3); expect(renderedRows()[0]).toContain("Alice"); @@ -1115,4 +1127,111 @@ describe("ResultsGrid", () => { } }); }); + + // ═══════════════════════════════════════════════════════════════════════ + // Text Wrapping Tests + // ═══════════════════════════════════════════════════════════════════════ + + describe("Text wrapping", () => { + // Walks from every element showing `text` up to its virtual row (desktop and + // mobile both render in the DOM), collecting what could hold the value to one line. + function lineConstraints(container: HTMLElement, text: string) { + const found = Array.from(container.querySelectorAll("span")).filter((el) => el.textContent === text); + expect(found.length).toBeGreaterThan(0); + return found.map((el) => { + const classes: string[] = []; + let node: HTMLElement | null = el; + while (node && !node.style.transform) { + classes.push(...Array.from(node.classList)); + node = node.parentElement; + } + expect(node).not.toBeNull(); + // measureElement files a row's height under this attribute; a row without it + // grows on screen while the rows below it stay where the old height put them. + expect(node!.dataset.index).toBeDefined(); + return { classes, rowHeight: node!.style.height, mobile: node!.tagName === "BUTTON" }; + }); + } + + function expectSingleLine(container: HTMLElement, text: string) { + for (const { classes, rowHeight, mobile } of lineConstraints(container, text)) { + expect(classes).toContain("whitespace-nowrap"); + // The desktop grid's ellipsis is part of the unchanged behaviour; the mobile table never had one. + if (!mobile) expect(classes).toContain("truncate"); + expect(rowHeight).toBe("36px"); + } + } + + function expectWrapped(container: HTMLElement, text: string) { + for (const { classes, rowHeight } of lineConstraints(container, text)) { + expect(classes).not.toContain("truncate"); + expect(classes).not.toContain("whitespace-nowrap"); + expect(classes).not.toContain("h-full"); + expect(rowHeight).toBe(""); + } + } + + test("a plain cell wraps and its row sheds the fixed height, and turning it off restores both", () => { + const { container, getByTestId } = render(React.createElement(ResultsGrid, { result: mockResult })); + expectSingleLine(container, "alice@example.com"); + + fireEvent.click(getByTestId("wrap-toggle")); + expectWrapped(container, "alice@example.com"); + + fireEvent.click(getByTestId("wrap-toggle")); + expectSingleLine(container, "alice@example.com"); + }); + + test("every toggle drops the measured row heights, so turning wrap off shrinks rows back", () => { + // The virtualizer caches each measured row; without a reset, rows grown while + // wrapping keep that height after the toggle is off again. + const { getByTestId } = render(React.createElement(ResultsGrid, { result: mockResult })); + mockVirtualizerMeasure.mockClear(); + + fireEvent.click(getByTestId("wrap-toggle")); + expect(mockVirtualizerMeasure).toHaveBeenCalledTimes(2); + + fireEvent.click(getByTestId("wrap-toggle")); + expect(mockVirtualizerMeasure).toHaveBeenCalledTimes(4); + }); + + test("an editable cell wraps too", () => { + const { container, getByTestId } = render( + React.createElement(ResultsGrid, { + result: mockResult, + editingEnabled: true, + onCellChange: mock(() => {}), + pendingChanges: [], + }), + ); + fireEvent.click(getByTestId("wrap-toggle")); + expectWrapped(container, "alice@example.com"); + }); + + test("masked and revealed cells wrap too", () => { + mockShouldMask.mockReturnValue(true); + mockCanReveal.mockReturnValue(true); + mockDetectSensitiveColumnsFromConfig.mockReturnValue( + new Map([ + ["email", { name: "email", maskType: "email" as const, columnPatterns: ["email"], enabled: true, id: "e1" }], + ]), + ); + const { container, getByTestId } = render( + React.createElement(ResultsGrid, { + result: mockResult, + maskingEnabled: true, + maskingConfig: { + enabled: true, + patterns: [], + roleSettings: { admin: { canToggle: true, canReveal: true }, user: { canToggle: false, canReveal: false } }, + }, + }), + ); + fireEvent.click(getByTestId("wrap-toggle")); + expectWrapped(container, "***"); + + fireEvent.click(container.querySelector('button[title="Reveal value (10s)"]')!); + expectWrapped(container, "alice@example.com"); + }); + }); }); diff --git a/tests/components/results-grid/StatsBar.test.tsx b/tests/components/results-grid/StatsBar.test.tsx index 7d8f0b5d5..7d1adf485 100644 --- a/tests/components/results-grid/StatsBar.test.tsx +++ b/tests/components/results-grid/StatsBar.test.tsx @@ -43,6 +43,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={onClearFilters} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -71,6 +73,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="table" onSetViewMode={onSetViewMode} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive effectiveMaskingEnabled={false} userCanToggle @@ -88,6 +92,29 @@ describe("results-grid/StatsBar", () => { expect(onSetViewMode).toHaveBeenCalledTimes(2); }); + test("supports text wrapping toggle", () => { + const onToggleWrapText = mock(() => {}); + const { queryByText } = render( + {})} + viewMode="table" + onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={onToggleWrapText} + hasSensitive={false} + effectiveMaskingEnabled={false} + userCanToggle={false} + />, + ); + + expect(queryByText("WRAP")).not.toBeNull(); + fireEvent.click(queryByText("WRAP")!); + expect(onToggleWrapText).toHaveBeenCalledTimes(1); + }); + test("shows locked masked label when user cannot toggle", () => { const { queryByText } = render( { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive effectiveMaskingEnabled userCanToggle={false} @@ -114,6 +143,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -130,6 +161,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -150,6 +183,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -173,6 +208,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -196,6 +233,8 @@ describe("results-grid/StatsBar", () => { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -213,7 +252,7 @@ describe("results-grid/StatsBar", () => { const pendingChanges: CellChange[] = [ { rowIndex: 0, columnId: "name", originalValue: "Alice", newValue: "Alicia" }, ]; - const { container, queryByText } = render( + const { queryByText, getByLabelText } = render( { onClearFilters={mock(() => {})} viewMode="card" onSetViewMode={mock(() => {})} + wrapText={false} + onToggleWrapText={mock(() => {})} hasSensitive={false} effectiveMaskingEnabled={false} userCanToggle={false} @@ -232,9 +273,8 @@ describe("results-grid/StatsBar", () => { ); expect(queryByText("1 change")).not.toBeNull(); - const buttons = container.querySelectorAll("button"); - fireEvent.click(buttons[0]!); - fireEvent.click(buttons[1]!); + fireEvent.click(getByLabelText("Apply changes")); + fireEvent.click(getByLabelText("Discard changes")); expect(onApplyChanges).toHaveBeenCalledTimes(1); expect(onDiscardChanges).toHaveBeenCalledTimes(1); });