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
1 change: 1 addition & 0 deletions src/components/grid/ResultsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export function ResultsGrid() {
<TruncatedCell
value={originalValue}
columnName={col.name}
dataType={col.data_type}
onViewFull={(content, colName) => {
setCellViewer({
isOpen: true,
Expand Down
92 changes: 65 additions & 27 deletions src/components/grid/TruncatedCell.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Maximize2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";

interface Props {
value: unknown;
columnName: string;
dataType?: string;
onViewFull: (content: string | null, columnName: string) => void;
}

const MAX_DISPLAY_LENGTH = 50;
const TRUNCATE_LENGTH = 47; // Leaves room for "..."
const TEXT_TYPE_MIN_LENGTH = 20;
const TEXT_TYPE_PATTERN = /text|blob|json|mediumtext|longtext/i;

function formatValue(val: unknown): string {
if (val === null || val === undefined) return "NULL";
Expand All @@ -15,40 +19,74 @@ function formatValue(val: unknown): string {
return String(val);
}

function shouldTruncate(val: unknown): boolean {
if (val === null || val === undefined) return false;
const formatted = formatValue(val);
return formatted.length > MAX_DISPLAY_LENGTH;
}

function getTruncatedDisplay(val: unknown): string {
if (val === null || val === undefined) return "NULL";
const formatted = formatValue(val);
if (formatted.length > MAX_DISPLAY_LENGTH) {
return formatted.slice(0, TRUNCATE_LENGTH) + "...";
}
return formatted;
function isLongTextType(dataType?: string): boolean {
if (!dataType) return false;
return TEXT_TYPE_PATTERN.test(dataType);
}

export function TruncatedCell({
value,
columnName,
dataType,
onViewFull,
}: Props) {
const isTruncated = shouldTruncate(value);
const displayText = getTruncatedDisplay(value);
const formatted = formatValue(value);
const isTextType = isLongTextType(dataType);
const showForTextType = isTextType
&& value !== null
&& value !== undefined
&& formatted.length >= TEXT_TYPE_MIN_LENGTH;

const [isOverflowing, setIsOverflowing] = useState(false);
const textRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
const el = textRef.current;
if (!el) return;
let scheduled = false;
const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth);
const ro = new ResizeObserver(() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
check();
});
});
ro.observe(el);
check();
return () => ro.disconnect();
}, [formatted, columnName, dataType]);

const showIcon = showForTextType || isOverflowing;
const openViewer = () => {
onViewFull(value === null || value === undefined ? null : formatted, columnName);
};

return (
<div
className={isTruncated ? "cursor-pointer hover:underline" : ""}
onDoubleClick={() => {
if (isTruncated) {
onViewFull(value === null || value === undefined ? null : formatValue(value), columnName);
}
}}
title={isTruncated ? "Double-click to view full content" : ""}
>
{displayText}
<div className="group flex min-w-0 items-center gap-1">
<div
ref={textRef}
className="min-w-0 truncate"
onDoubleClick={showIcon ? openViewer : undefined}
title={showForTextType ? "View full content" : undefined}
>
{formatted}
</div>
{showIcon && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openViewer();
}}
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)] focus-visible:opacity-100 group-hover:opacity-100"
title="View full content"
aria-label="View full content"
>
<Maximize2 className="h-3 w-3" />
</button>
)}
</div>
);
}
227 changes: 189 additions & 38 deletions src/components/grid/__tests__/TruncatedCell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { TruncatedCell } from "../TruncatedCell";

class FakeResizeObserver {
static instances: FakeResizeObserver[] = [];
callback: ResizeObserverCallback;
observed: Element[] = [];
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
FakeResizeObserver.instances.push(this);
}
observe(el: Element) {
this.observed.push(el);
}
unobserve() {}
disconnect() {
FakeResizeObserver.instances = FakeResizeObserver.instances.filter((i) => i !== this);
}
trigger() {
this.callback([], this as unknown as ResizeObserver);
}
}

beforeAll(() => {
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => {}) as typeof globalThis.cancelAnimationFrame;
});

beforeEach(() => {
FakeResizeObserver.instances = [];
});

afterEach(() => {
vi.restoreAllMocks();
});

function getIconButton(container: HTMLElement) {
return container.querySelector("button[aria-label=\"View full content\"]");
}

describe("TruncatedCell", () => {
it("renders NULL for null value", () => {
const { container } = render(
Expand Down Expand Up @@ -45,70 +87,179 @@ describe("TruncatedCell", () => {
expect(container.textContent).toBe("42");
});

it("truncates long string values", () => {
it("renders full long string values (visual truncation handled by CSS, #216)", () => {
const longText = "a".repeat(100);
const { container } = render(
<TruncatedCell value={longText} columnName="col" onViewFull={vi.fn()} />,
);
const text = container.textContent ?? "";
expect(text.length).toBeLessThan(100);
expect(text.endsWith("...")).toBe(true);
expect(container.textContent).toContain("aaa");
expect(container.textContent?.length).toBe(longText.length);
});

it("does not truncate short string values", () => {
const shortText = "a".repeat(30);
it("applies truncate class to the text element", () => {
const { container } = render(
<TruncatedCell value={shortText} columnName="col" onViewFull={vi.fn()} />,
<TruncatedCell value="any value" columnName="col" onViewFull={vi.fn()} />,
);
expect(container.textContent).toBe(shortText);
const text = container.querySelector(".truncate");
expect(text).not.toBeNull();
});

it("calls onViewFull on double click when truncated", () => {
const onViewFull = vi.fn();
const longText = "a".repeat(100);
render(
<TruncatedCell value={longText} columnName="myCol" onViewFull={onViewFull} />,
it("does not show icon for short non-text values", () => {
const { container } = render(
<TruncatedCell value="hi" columnName="col" onViewFull={vi.fn()} />,
);
expect(getIconButton(container)).toBeNull();
});

it("does not show icon for short numeric values", () => {
const { container } = render(
<TruncatedCell value={42} columnName="col" onViewFull={vi.fn()} />,
);
expect(getIconButton(container)).toBeNull();
});

it("does not show icon for null values", () => {
const { container } = render(
<TruncatedCell value={null} columnName="col" onViewFull={vi.fn()} />,
);
expect(getIconButton(container)).toBeNull();
});

it("does not show icon for text types with below-threshold values", () => {
const { container } = render(
<TruncatedCell
value={"x".repeat(15)}
columnName="col"
dataType="varchar(255)"
onViewFull={vi.fn()}
/>,
);
expect(getIconButton(container)).toBeNull();
});

const cell = screen.getByText((content) => content.endsWith("..."));
fireEvent.doubleClick(cell);
expect(onViewFull).toHaveBeenCalledWith(longText, "myCol");
it("shows icon for text/blob/json types with non-trivial values", () => {
const longText = "a".repeat(100);
for (const dataType of ["text", "TEXT", "tinytext", "mediumtext", "longtext", "blob", "BLOB", "json", "JSON"]) {
const { container, unmount } = render(
<TruncatedCell
value={longText}
columnName="col"
dataType={dataType}
onViewFull={vi.fn()}
/>,
);
expect(getIconButton(container)).not.toBeNull();
unmount();
}
});

it("does not call onViewFull on double click when not truncated", () => {
it("shows icon when actual overflow is detected via ResizeObserver (#216)", async () => {
const onViewFull = vi.fn();
render(
<TruncatedCell value="short" columnName="col" onViewFull={onViewFull} />,
const { container } = render(
<TruncatedCell
value={"a".repeat(50)}
columnName="col"
dataType="varchar(50)"
onViewFull={onViewFull}
/>,
);
expect(getIconButton(container)).toBeNull();

fireEvent.doubleClick(screen.getByText("short"));
expect(onViewFull).not.toHaveBeenCalled();
const textEl = container.querySelector(".truncate") as HTMLElement;
Object.defineProperty(textEl, "scrollWidth", { configurable: true, value: 500 });
Object.defineProperty(textEl, "clientWidth", { configurable: true, value: 100 });
await act(async () => {
FakeResizeObserver.instances[0]?.trigger();
});

expect(getIconButton(container)).not.toBeNull();
});

it("null values do not trigger onViewFull on double click (not truncated)", () => {
it("hides icon when overflow ceases after column widens", async () => {
const { container } = render(
<TruncatedCell
value={"a".repeat(50)}
columnName="col"
dataType="varchar(50)"
onViewFull={vi.fn()}
/>,
);
const textEl = container.querySelector(".truncate") as HTMLElement;

Object.defineProperty(textEl, "scrollWidth", { configurable: true, value: 500 });
Object.defineProperty(textEl, "clientWidth", { configurable: true, value: 100 });
await act(async () => {
FakeResizeObserver.instances[0]?.trigger();
});
expect(getIconButton(container)).not.toBeNull();

Object.defineProperty(textEl, "scrollWidth", { configurable: true, value: 50 });
Object.defineProperty(textEl, "clientWidth", { configurable: true, value: 100 });
await act(async () => {
FakeResizeObserver.instances[0]?.trigger();
});
expect(getIconButton(container)).toBeNull();
});

it("icon click opens the viewer", () => {
const onViewFull = vi.fn();
render(
<TruncatedCell value={null} columnName="col" onViewFull={onViewFull} />,
const longText = "a".repeat(100);
const { container } = render(
<TruncatedCell
value={longText}
columnName="payload"
dataType="text"
onViewFull={onViewFull}
/>,
);
const button = getIconButton(container) as HTMLButtonElement;
fireEvent.click(button);
expect(onViewFull).toHaveBeenCalledWith(longText, "payload");
});

fireEvent.doubleClick(screen.getByText("NULL"));
expect(onViewFull).not.toHaveBeenCalled();
it("icon click stops propagation so row selection is not triggered", () => {
const onViewFull = vi.fn();
const parentClick = vi.fn();
const longText = "a".repeat(100);
const { container } = render(
<div onClick={parentClick}>
<TruncatedCell
value={longText}
columnName="payload"
dataType="text"
onViewFull={onViewFull}
/>
</div>,
);
const button = getIconButton(container) as HTMLButtonElement;
fireEvent.click(button);
expect(onViewFull).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});

it("shows hover title when truncated", () => {
it("double-clicking the text also opens the viewer when icon is shown", () => {
const onViewFull = vi.fn();
const longText = "a".repeat(100);
render(
<TruncatedCell value={longText} columnName="col" onViewFull={vi.fn()} />,
const { container } = render(
<TruncatedCell
value={longText}
columnName="payload"
dataType="text"
onViewFull={onViewFull}
/>,
);
expect(screen.getByTitle("Double-click to view full content")).toBeInTheDocument();
const textEl = container.querySelector(".truncate") as HTMLElement;
fireEvent.doubleClick(textEl);
expect(onViewFull).toHaveBeenCalledWith(longText, "payload");
});

it("has no hover title when not truncated", () => {
render(
<TruncatedCell value="short" columnName="col" onViewFull={vi.fn()} />,
it("double-click does not open the viewer when no icon is shown", () => {
const onViewFull = vi.fn();
const { container } = render(
<TruncatedCell value="short" columnName="col" onViewFull={onViewFull} />,
);
expect(
screen.queryByTitle("Double-click to view full content"),
).not.toBeInTheDocument();
const textEl = container.querySelector(".truncate") as HTMLElement;
fireEvent.doubleClick(textEl);
expect(onViewFull).not.toHaveBeenCalled();
});
});
Loading