mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: pin frequently-used tools to the top of the dashboard (#440)
* feat(i18n): add pin/unpin/pinned strings, retire addToFavourites stub Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp * feat(web): add per-user pinned-tools store Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp * feat(web): add opt-in pin toggle to ToolCard Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp * feat(web): render Pinned section on the dashboard All tab Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp * test(web): cover pin toggle (component) and dashboard pin flow (e2e) Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "./helpers";
|
||||
|
||||
// Mutates the shared per-user `pinnedTools` preference on the server, so it
|
||||
// pins and then unpins within the single test to leave state clean.
|
||||
test.describe("Pin tools", () => {
|
||||
test("pin a tool, persist across reload, then unpin", async ({ loggedInPage: page }) => {
|
||||
// Resize lives under Image > Essentials on the All tab (default).
|
||||
const pinToggle = page.getByTestId("pin-toggle-resize").first();
|
||||
await expect(pinToggle).toBeVisible();
|
||||
|
||||
// Not pinned yet: no Pinned section heading.
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toHaveCount(0);
|
||||
|
||||
// Pin it.
|
||||
await pinToggle.click();
|
||||
|
||||
// The Pinned section appears with the Resize card.
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toBeVisible();
|
||||
|
||||
// Reload: the pin persisted server-side and re-hydrates.
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toBeVisible();
|
||||
|
||||
// Unpin (there are two Resize pin toggles now: one in Pinned, one in the
|
||||
// Image group). Either flips the shared state; click the first and assert
|
||||
// the section is gone.
|
||||
await page.getByTestId("pin-toggle-resize").first().click();
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// fetch + localStorage must be stubbed before the modules under test load.
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => storageMap.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storageMap.set(k, v),
|
||||
removeItem: (k: string) => storageMap.delete(k),
|
||||
clear: () => storageMap.clear(),
|
||||
get length() {
|
||||
return storageMap.size;
|
||||
},
|
||||
key: () => null,
|
||||
});
|
||||
|
||||
import { flushPinnedWrites, usePinnedToolsStore } from "@/stores/pinned-tools-store";
|
||||
|
||||
function okJson(data: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(data),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
function failResponse(status: number) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status,
|
||||
json: () => Promise.reject(new Error("no body")),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("pinned-tools store", () => {
|
||||
beforeEach(async () => {
|
||||
// Drain any write queued by a prior test before swapping the fetch mock,
|
||||
// so a leaked PUT cannot run against the next test's fresh mock.
|
||||
await flushPinnedWrites();
|
||||
fetchMock.mockReset();
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: [],
|
||||
lastConfirmed: [],
|
||||
loaded: false,
|
||||
loadError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fetch loads pinnedTools from /v1/preferences", async () => {
|
||||
fetchMock.mockReturnValueOnce(okJson({ preferences: { pinnedTools: ["resize", "compress"] } }));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
const s = usePinnedToolsStore.getState();
|
||||
expect(s.pinnedTools).toEqual(["resize", "compress"]);
|
||||
expect(s.lastConfirmed).toEqual(["resize", "compress"]);
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.loadError).toBe(false);
|
||||
});
|
||||
|
||||
it("fetch defaults to [] when pinnedTools is missing or malformed", async () => {
|
||||
fetchMock.mockReturnValueOnce(okJson({ preferences: { pinnedTools: "not-an-array" } }));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual([]);
|
||||
expect(usePinnedToolsStore.getState().loaded).toBe(true);
|
||||
});
|
||||
|
||||
it("fetch sets loadError on network failure", async () => {
|
||||
fetchMock.mockReturnValueOnce(failResponse(500));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
expect(usePinnedToolsStore.getState().loaded).toBe(true);
|
||||
expect(usePinnedToolsStore.getState().loadError).toBe(true);
|
||||
});
|
||||
|
||||
it("pin prepends and updates state synchronously", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["compress"], lastConfirmed: ["compress"] });
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize", "compress"]);
|
||||
});
|
||||
|
||||
it("pin is a no-op when the tool is already pinned", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["resize"], lastConfirmed: ["resize"] });
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize"]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unpin removes the tool", () => {
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: ["resize", "compress"],
|
||||
lastConfirmed: ["resize", "compress"],
|
||||
});
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().unpin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["compress"]);
|
||||
});
|
||||
|
||||
it("pin persists the full array via PUT /v1/preferences", async () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: [], lastConfirmed: [] });
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/preferences");
|
||||
expect(opts.method).toBe("PUT");
|
||||
expect(JSON.parse(opts.body)).toEqual({ pinnedTools: ["resize"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(usePinnedToolsStore.getState().lastConfirmed).toEqual(["resize"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls back to lastConfirmed when the PUT fails", async () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["compress"], lastConfirmed: ["compress"] });
|
||||
fetchMock.mockReturnValueOnce(failResponse(500));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
// Optimistic update applied immediately.
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize", "compress"]);
|
||||
// Rolls back once the failed write settles.
|
||||
await vi.waitFor(() =>
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["compress"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("isPinned reflects current state", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["resize"], lastConfirmed: ["resize"] });
|
||||
expect(usePinnedToolsStore.getState().isPinned("resize")).toBe(true);
|
||||
expect(usePinnedToolsStore.getState().isPinned("compress")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolCard } from "@/components/common/tool-card";
|
||||
import { usePinnedToolsStore } from "@/stores/pinned-tools-store";
|
||||
|
||||
// Make the store's optimistic persistence a no-op so the test stays server-free.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
apiGet: vi.fn(() => Promise.resolve({ preferences: {} })),
|
||||
apiPut: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
}));
|
||||
|
||||
const resize = TOOLS.find((tool) => tool.id === "resize");
|
||||
if (!resize) throw new Error("resize tool missing from TOOLS");
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: [],
|
||||
lastConfirmed: [],
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard(showPin: boolean) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ToolCard tool={resize} variant="descriptive" showPin={showPin} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ToolCard pin button", () => {
|
||||
it("renders no pin button unless showPin is set", () => {
|
||||
renderCard(false);
|
||||
expect(screen.queryByTestId("pin-toggle-resize")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles pinned state and aria label when clicked", () => {
|
||||
renderCard(true);
|
||||
const btn = screen.getByTestId("pin-toggle-resize");
|
||||
expect(btn.getAttribute("aria-label")).toBe("Pin");
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize"]);
|
||||
const pinnedBtn = screen.getByTestId("pin-toggle-resize");
|
||||
expect(pinnedBtn.getAttribute("aria-label")).toBe("Unpin");
|
||||
expect(pinnedBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
fireEvent.click(pinnedBtn);
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user