Files
gridline/desktop/src/components/grid/CellEditor.test.tsx
T
adrianbonpin bdf2bb543b
Release / windows (push) Successful in 17m44s
Release / linux (push) Successful in 21m9s
Release 0.7.15 (#25)
2026-09-06 04:58:10 +00:00

259 lines
11 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CellEditor } from "./CellEditor";
describe("CellEditor", () => {
it("renders the initial value and commits on Enter", () => {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={onCancel} />);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Alice");
fireEvent.change(input, { target: { value: "Alicia" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia");
});
it("renders a native date input for date columns", () => {
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15");
expect(input).toHaveAttribute("type", "date");
});
it("renders a datetime-local input for timestamp columns", () => {
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
expect(input).toHaveAttribute("type", "datetime-local");
});
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByDisplayValue("2024-01-15T10:30");
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
});
it("renders a boolean select with true/false options", () => {
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
const select = screen.getByRole("combobox");
expect(select).toHaveValue("true");
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
});
it("renders a number input for integer columns", () => {
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("step", "1");
});
it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("leaves an empty nullable cell alone when it was already empty (NULL stays NULL)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("cancels on Escape", () => {
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={vi.fn()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
expect(onCancel).toHaveBeenCalled();
});
it("uses textarea for large/JSON columns", () => {
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
});
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled();
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
});
it("commits an empty string on non-nullable text columns", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("");
});
it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "5" } });
expect(screen.queryByTestId("cell-editor-error")).toBeNull();
});
it("does not offer a NULL row in the FK dropdown for non-nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const buttons = screen.getAllByRole("button");
expect(buttons.some((b) => b.textContent === "NULL")).toBe(false);
});
it("offers a NULL row in the FK dropdown for nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
const nullRow = screen.getAllByRole("button").find((b) => b.textContent === "NULL");
expect(nullRow).toBeDefined();
fireEvent.click(nullRow!);
expect(onCommit).toHaveBeenCalledWith(null);
});
it("commits null when the NULL option is selected in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("renders a combobox with enum values and commits on change", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
const labels = screen.getAllByRole("option").map((o) => o.textContent);
expect(labels).toEqual(expect.arrayContaining(["active", "inactive", "pending"]));
fireEvent.change(select, { target: { value: "pending" } });
expect(onCommit).toHaveBeenCalledWith("pending");
});
it("filters FK options by query and commits the clicked value", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const search = screen.getByLabelText(/search foreign key/i);
fireEvent.change(search, { target: { value: "bo" } });
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveTextContent("2 — Bob");
fireEvent.click(buttons[0]);
expect(onCommit).toHaveBeenCalledWith("2");
});
it("shows all FK options when the query is empty", () => {
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.getAllByRole("button")).toHaveLength(2);
});
it("renders text columns as a single-line scrolling textarea", () => {
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
expect(input.tagName).toBe("TEXTAREA");
expect(input.className).toContain("overflow-y-auto");
});
it("renders the FK placeholder and a No matches empty state", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]} fkPlaceholder="Search users…"
onCommit={onCommit} onCancel={vi.fn()} />);
const search = screen.getByLabelText(/search foreign key/i);
expect(search).toHaveAttribute("placeholder", "Search users…");
fireEvent.change(search, { target: { value: "zzz" } });
expect(screen.getByText("No matches")).toBeInTheDocument();
});
it("renders the FK option list as a fixed-position overlay so it is never clipped", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }, { value: "2", label: "2 — Bob" }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
expect(list.style.position).toBe("fixed");
expect(document.body.contains(list)).toBe(true);
expect(list).toHaveTextContent("1 — Alice");
expect(list).toHaveTextContent("2 — Bob");
});
it("renders FK options as one-row values only (FK-reference style, cap 4, fixed width)", () => {
const onCommit = vi.fn();
const cells = Array.from({ length: 6 }, (_, i) => ({
name: `col${i}`,
value: `v${i}`,
}));
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1", cells }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
// first 3 values shown, column names NOT shown, 4th+ capped
for (let i = 0; i < 4; i++) {
expect(list).toHaveTextContent(`v${i}`);
}
expect(list).not.toHaveTextContent("col0");
expect(list).not.toHaveTextContent("v4");
// fixed 360px width, FK-viewer surface styling
expect(list.style.width).toBe("360px");
expect(list.className).toContain("bg-surface");
expect(list.className).toContain("rounded-lg");
expect(list.className).toContain("shadow-xl");
// clicking the row commits the value
fireEvent.click(screen.getByRole("button"));
expect(onCommit).toHaveBeenCalledWith("1");
});
});