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
43 changes: 34 additions & 9 deletions src/components/ResultsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export function ResultsGrid({
const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnId: string } | null>(null);
const [editValue, setEditValue] = useState<string>("");
const [viewMode, setViewMode] = useState<"card" | "table">("card");
const [wrapText, setWrapText] = useState(false);
const [selectedRow, setSelectedRow] = useState<{ row: Record<string, unknown>; index: number } | null>(null);
const [columnFilters, setColumnFilters] = useState<Map<string, string>>(new Map());
const [activeFilterCol, setActiveFilterCol] = useState<string | null>(null);
Expand Down Expand Up @@ -218,6 +219,9 @@ export function ResultsGrid({
}, []);

const columns = useMemo<ColumnDef<typeof tableFeatureSet, Record<string, unknown>>[]>(() => {
// `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
Expand Down Expand Up @@ -371,7 +375,7 @@ export function ResultsGrid({
if (effectiveMaskingEnabled && sensitivePattern && val !== null && val !== undefined && !isRevealed) {
const masked = maskValueByPattern(val, sensitivePattern);
return (
<div className="truncate w-full h-full flex items-center gap-1 group/cell">
<div className={cn("w-full flex gap-1 group/cell", valueFlow, wrapText ? "items-start" : "items-center")}>
<span className="text-fg-muted italic">{masked}</span>
{userCanReveal && (
<button
Expand All @@ -393,7 +397,7 @@ export function ResultsGrid({
if (effectiveMaskingEnabled && sensitivePattern && isRevealed) {
const { display, className } = formatCellValue(val);
return (
<div className="truncate w-full h-full flex items-center gap-1">
<div className={cn("w-full flex gap-1", valueFlow, wrapText ? "items-start" : "items-center")}>
<span className={className}>{display}</span>
<Lock strokeWidth={1.5} className="w-2.5 h-2.5 text-hue-purple/50 shrink-0" />
</div>
Expand All @@ -410,15 +414,15 @@ export function ResultsGrid({
// editing at all (issue #269).
if (!editingEnabled) {
return (
<div className={cn("truncate w-full h-full", pendingChange && "bg-warning-tint/10 rounded px-0.5")}>
<div className={cn("w-full", valueFlow, pendingChange && "bg-warning-tint/10 rounded px-0.5")}>
<span className={cn(className, pendingChange && "text-warning")}>{display}</span>
</div>
);
}

return (
<div
className={cn("truncate w-full h-full cursor-text", pendingChange && "bg-warning-tint/10 rounded px-0.5")}
className={cn("w-full cursor-text", valueFlow, pendingChange && "bg-warning-tint/10 rounded px-0.5")}
onDoubleClick={() => {
setEditingCell({ rowIndex: row.index, columnId: column.id });
setEditValue(pendingChange ? pendingChange.newValue : String(val ?? ""));
Expand All @@ -433,6 +437,7 @@ export function ResultsGrid({
maxSize: 500,
}));
}, [
wrapText,
result.fields,
result.columnTypes,
editingCell,
Expand Down Expand Up @@ -528,6 +533,14 @@ export function ResultsGrid({
onClearFilters={handleClearFilters}
viewMode={viewMode}
onSetViewMode={setViewMode}
wrapText={wrapText}
onToggleWrapText={() => {
// Both virtualizers cache every row they measured. Dropping the cache on each
// toggle is what lets rows grown while wrapping shrink back once it is off.
rowVirtualizer.measure();
mobileTableVirtualizer.measure();
setWrapText((value) => !value);
}}
hasSensitive={hasSensitive}
effectiveMaskingEnabled={effectiveMaskingEnabled}
userCanToggle={userCanToggle}
Expand Down Expand Up @@ -614,14 +627,16 @@ export function ResultsGrid({
<button
type="button"
key={virtualRow.index}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: `${virtualRow.size}px`,
...(wrapText ? { minHeight: "48px" } : { height: `${virtualRow.size}px` }),
transform: `translateY(${virtualRow.start}px)`,
}}
ref={wrapText ? mobileTableVirtualizer.measureElement : undefined}
className="flex hover:bg-brand-tint/[0.03] transition-colors border-b border-hairline cursor-pointer text-left"
onClick={() => setSelectedRow({ row, index: virtualRow.index })}
>
Expand All @@ -638,9 +653,11 @@ export function ResultsGrid({
<div
key={field}
className={cn(
"h-full px-4 py-3 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden flex items-center",
"px-4 py-3 border-r border-hairline text-xs font-mono overflow-hidden flex min-w-[120px]",
wrapText
? "whitespace-pre-wrap break-words items-start"
: "h-full whitespace-nowrap items-center",
idx === 0 && "sticky left-0 z-10 bg-sunken shadow-[2px_0_8px_rgba(0,0,0,0.3)]",
"min-w-[120px]",
)}
>
<span className={className}>{displayValue}</span>
Expand Down Expand Up @@ -687,8 +704,11 @@ export function ResultsGrid({
<div
key={row.id}
data-index={virtualRow.index}
ref={wrapText ? rowVirtualizer.measureElement : undefined}
style={{
height: `${virtualRow.size}px`,
// A fixed height is all measureElement would ever read back, so a
// wrapping row sizes to its content and reports that instead.
...(wrapText ? { minHeight: "36px" } : { height: `${virtualRow.size}px` }),
transform: `translateY(${virtualRow.start}px)`,
position: "absolute",
top: 0,
Expand All @@ -700,7 +720,12 @@ export function ResultsGrid({
<div
key={cell.id}
style={{ width: cell.column.getSize(), minWidth: cell.column.getSize() }}
className="h-full px-4 py-2 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden group-hover:border-hairline-strong flex items-center shrink-0"
className={cn(
"px-4 py-2 border-r border-hairline text-xs font-mono overflow-hidden group-hover:border-hairline-strong flex shrink-0",
wrapText
? "whitespace-pre-wrap break-words items-start"
: "h-full whitespace-nowrap items-center",
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
Expand Down
33 changes: 32 additions & 1 deletion src/components/results-grid/StatsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@
import React from "react";
import { QueryResult } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ChevronDown, LayoutGrid, Table2, LoaderCircle, EyeOff, Eye, Save, X, Funnel, Lock } from "lucide-react";
import {
ChevronDown,
LayoutGrid,
Table2,
LoaderCircle,
EyeOff,
Eye,
Save,
X,
Funnel,
Lock,
WrapText,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { CellChange } from "@/components/ResultsGrid";
import { describeWarning } from "@/components/results-grid/utils";
Expand All @@ -19,6 +31,8 @@ export interface StatsBarProps {
onClearFilters: () => void;
viewMode: "card" | "table";
onSetViewMode: (mode: "card" | "table") => void;
wrapText: boolean;
onToggleWrapText: () => void;
// Masking props
hasSensitive: boolean;
effectiveMaskingEnabled: boolean;
Expand All @@ -41,6 +55,8 @@ export function StatsBar({
onClearFilters,
viewMode,
onSetViewMode,
wrapText,
onToggleWrapText,
hasSensitive,
effectiveMaskingEnabled,
userCanToggle,
Expand Down Expand Up @@ -106,6 +122,19 @@ export function StatsBar({
{MASKED_LABEL}
</span>
) : null)}
<Button
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-xs font-medium gap-1",
wrapText ? "text-brand bg-brand-tint/10" : "text-fg-muted",
)}
onClick={onToggleWrapText}
title={wrapText ? "Disable text wrapping" : "Enable text wrapping"}
>
<WrapText className="w-3 h-3" />
{wrapText ? "WRAP ON" : "WRAP"}
</Button>

{editingEnabled && pendingChanges && pendingChanges.length > 0 && (
<div className="flex items-center gap-1">
Expand All @@ -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}
>
<Save strokeWidth={1.5} className="w-3 h-3" />
Expand All @@ -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}
>
<X strokeWidth={1.5} className="w-3 h-3" />
Expand Down
127 changes: 123 additions & 4 deletions tests/components/ResultsGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: () =>
Expand All @@ -118,6 +126,8 @@ mock.module("@tanstack/react-virtual", () => ({
key: i,
})),
getTotalSize: () => opts.count * 36,
measureElement: () => {},
measure: mockVirtualizerMeasure,
}),
}));

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});
});
Loading
Loading