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:
SnapOtter
2026-07-06 17:59:30 +08:00
committed by GitHub
parent ec78d36d95
commit 3aaaacc7a1
27 changed files with 472 additions and 35 deletions
+30
View File
@@ -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);
});
});
+131
View File
@@ -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);
});
});
+58
View File
@@ -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([]);
});
});