/**
* GalleryDetailPanel tests: thumbnail selection, caption editing, removal,
* and the `nodeKey`-keyed resync effect (code-review finding #4 — the panel
* must not clobber in-progress local edits when a parent re-renders with a
* new `attributes` object identity for the SAME gallery node, but must reset
* when the sidebar switches to a DIFFERENT gallery node).
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
GalleryDetailPanel,
type GalleryDetailPanelProps,
} from "../src/components/editor/GalleryDetailPanel";
import type { GalleryAttributes, GalleryImage } from "../src/components/editor/GalleryNode";
import { render } from "./utils/render.tsx";
vi.mock("../src/lib/api", async () => {
const actual = await vi.importActual("../src/lib/api");
return {
...actual,
fetchMediaList: vi.fn().mockResolvedValue({ items: [] }),
fetchMediaProviders: vi.fn().mockResolvedValue([]),
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
uploadMedia: vi.fn().mockResolvedValue({}),
uploadToProvider: vi.fn().mockResolvedValue({}),
updateMedia: vi.fn().mockResolvedValue({}),
};
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return {children};
}
function makeImage(overrides: Partial = {}): GalleryImage {
return {
_type: "image",
_key: "img1",
asset: { _type: "reference", _ref: "m1", url: "/media/m1.jpg" },
alt: "Image One",
...overrides,
};
}
function threeImages(): GalleryImage[] {
return [
makeImage({
_key: "img1",
asset: { _type: "reference", _ref: "m1", url: "/media/m1.jpg" },
alt: "Image One",
}),
makeImage({
_key: "img2",
asset: { _type: "reference", _ref: "m2", url: "/media/m2.jpg" },
alt: "Image Two",
}),
makeImage({
_key: "img3",
asset: { _type: "reference", _ref: "m3", url: "/media/m3.jpg" },
alt: "Image Three",
}),
];
}
type PanelAttrs = GalleryAttributes & { selectedImageKey?: string; nodeKey?: string };
function renderPanel(props: Partial & { attributes: PanelAttrs }) {
const defaultProps: GalleryDetailPanelProps = {
attributes: props.attributes,
onUpdate: vi.fn(),
onDelete: vi.fn(),
onClose: vi.fn(),
inline: true,
};
return render(
,
);
}
describe("GalleryDetailPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clicking a thumbnail shows that image's settings card with matching alt value", async () => {
const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
const screen = await renderPanel({ attributes });
// No settings card until a thumbnail is selected.
expect(screen.getByLabelText("Alt text").query()).toBeNull();
const thumb = screen.getByRole("img", { name: "Image Two" });
await expect.element(thumb).toBeInTheDocument();
thumb.element().closest("button")!.click();
const altInput = screen.getByLabelText("Alt text");
await expect.element(altInput).toBeInTheDocument();
await expect.element(altInput).toHaveValue("Image Two");
});
it("editing caption calls onUpdate with the patched images array", async () => {
const onUpdate = vi.fn();
const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
const screen = await renderPanel({ attributes, onUpdate });
const thumb = screen.getByRole("img", { name: "Image One" });
await expect.element(thumb).toBeInTheDocument();
thumb.element().closest("button")!.click();
const captionInput = screen.getByLabelText("Caption");
await expect.element(captionInput).toBeInTheDocument();
const inputEl = captionInput.element() as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(inputEl, "New caption");
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(onUpdate).toHaveBeenCalled();
});
const lastCall = onUpdate.mock.calls.at(-1)![0] as Partial;
const patchedImages = lastCall.images!;
expect(patchedImages.find((img) => img._key === "img1")?.caption).toBe("New caption");
// Other images are untouched.
expect(patchedImages.find((img) => img._key === "img2")?.caption).toBeUndefined();
});
it("remove button removes only that image", async () => {
const onUpdate = vi.fn();
const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
const screen = await renderPanel({ attributes, onUpdate });
const removeBtn = screen.getByRole("button", { name: "Remove image 2" });
await expect.element(removeBtn).toBeInTheDocument();
removeBtn.element().click();
await vi.waitFor(() => {
expect(onUpdate).toHaveBeenCalled();
});
const lastCall = onUpdate.mock.calls.at(-1)![0] as Partial;
const remaining = lastCall.images!;
expect(remaining).toHaveLength(2);
expect(remaining.map((img) => img._key)).toEqual(["img1", "img3"]);
});
it("does not clobber local edits when re-rendered with the same nodeKey but a new attrs identity", async () => {
const onUpdate = vi.fn();
const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
const screen = await renderPanel({ attributes, onUpdate });
// Local edit: remove the second image.
const removeBtn = screen.getByRole("button", { name: "Remove image 2" });
await expect.element(removeBtn).toBeInTheDocument();
removeBtn.element().click();
await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1));
// Re-render with a brand-new `attributes` object (new identity) but the
// SAME nodeKey and the ORIGINAL (stale, unedited) images — simulating a
// parent re-wrapping attrs without the sidebar having switched nodes.
const staleAttrs: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
await screen.rerender(
,
);
// The removed image must still be gone — local state was not clobbered.
expect(screen.getByRole("img", { name: "Image Two" }).query()).toBeNull();
await expect.element(screen.getByRole("img", { name: "Image One" })).toBeInTheDocument();
await expect.element(screen.getByRole("img", { name: "Image Three" })).toBeInTheDocument();
});
it("resets local state when re-rendered with a different nodeKey", async () => {
const onUpdate = vi.fn();
const attributes: PanelAttrs = { images: threeImages(), columns: 3, nodeKey: "10" };
const screen = await renderPanel({ attributes, onUpdate });
// Local edit: remove the second image.
const removeBtn = screen.getByRole("button", { name: "Remove image 2" });
await expect.element(removeBtn).toBeInTheDocument();
removeBtn.element().click();
await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1));
expect(screen.getByRole("img", { name: "Image Two" }).query()).toBeNull();
// Re-render for a DIFFERENT gallery node (different nodeKey, fresh images).
const otherNodeImages: GalleryImage[] = [
makeImage({
_key: "other1",
asset: { _type: "reference", _ref: "o1", url: "/media/o1.jpg" },
alt: "Other Image",
}),
];
const otherAttrs: PanelAttrs = { images: otherNodeImages, columns: 2, nodeKey: "20" };
await screen.rerender(
,
);
// The panel now reflects the new node's images — the previous node's
// edited state is gone, replaced with the new node's snapshot.
await expect.element(screen.getByRole("img", { name: "Other Image" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Image One" }).query()).toBeNull();
expect(screen.getByRole("img", { name: "Image Three" }).query()).toBeNull();
});
});