feat: add favorites drawer management
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog";
|
||||
|
||||
function readDialog(): HTMLElement {
|
||||
const dialog = document.querySelector(
|
||||
'[data-sces-favorite-folder-dialog="root"]'
|
||||
);
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
throw new Error("Expected favorite folder dialog");
|
||||
}
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function readInput(): HTMLInputElement {
|
||||
const input = document.querySelector(
|
||||
'[data-sces-favorite-folder-dialog="input"]'
|
||||
);
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error("Expected favorite folder name input");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function readButton(name: "confirm" | "cancel"): HTMLButtonElement {
|
||||
const button = document.querySelector(
|
||||
`[data-sces-favorite-folder-dialog="${name}"]`
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Expected ${name} button`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("favorite-folder-dialog", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("renders the requested title and initial name, then trims a confirmation", async () => {
|
||||
const result = promptForFavoriteFolderName(document, {
|
||||
initialValue: " 重点合作 ",
|
||||
title: "重命名收藏夹"
|
||||
});
|
||||
|
||||
expect(readDialog().getAttribute("role")).toBe("dialog");
|
||||
expect(readDialog().textContent).toContain("重命名收藏夹");
|
||||
expect(readInput().value).toBe(" 重点合作 ");
|
||||
expect(document.activeElement).toBe(readInput());
|
||||
|
||||
readButton("confirm").click();
|
||||
|
||||
await expect(result).resolves.toBe("重点合作");
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorite-folder-dialog="root"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps an inline error open for blank names and clears it after input", async () => {
|
||||
const result = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
|
||||
readInput().value = " ";
|
||||
readButton("confirm").click();
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent
|
||||
).toBe("请输入收藏夹名称");
|
||||
expect(readInput().getAttribute("aria-invalid")).toBe("true");
|
||||
expect(document.activeElement).toBe(readInput());
|
||||
|
||||
readInput().value = "重点";
|
||||
readInput().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent
|
||||
).toBe("");
|
||||
|
||||
readButton("cancel").click();
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("cancels from its cancel button, overlay click, and Escape", async () => {
|
||||
const fromCancel = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
readButton("cancel").click();
|
||||
await expect(fromCancel).resolves.toBeNull();
|
||||
|
||||
const fromOverlay = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
readDialog().dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await expect(fromOverlay).resolves.toBeNull();
|
||||
|
||||
const fromEscape = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await expect(fromEscape).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("confirms a valid name with Enter", async () => {
|
||||
const result = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
readInput().value = "待联系";
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||
|
||||
await expect(result).resolves.toBe("待联系");
|
||||
});
|
||||
|
||||
test("reuses and focuses one active dialog per document, then allows a new one after close", async () => {
|
||||
const first = promptForFavoriteFolderName(document, {
|
||||
initialValue: "首个值",
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
const input = readInput();
|
||||
input.blur();
|
||||
|
||||
const reused = promptForFavoriteFolderName(document, {
|
||||
initialValue: "不会替换",
|
||||
title: "重命名收藏夹"
|
||||
});
|
||||
|
||||
expect(reused).toBe(first);
|
||||
expect(
|
||||
document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]')
|
||||
).toHaveLength(1);
|
||||
expect(readInput().value).toBe("首个值");
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
readButton("cancel").click();
|
||||
await expect(first).resolves.toBeNull();
|
||||
|
||||
const second = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
expect(
|
||||
document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]')
|
||||
).toHaveLength(1);
|
||||
readButton("cancel").click();
|
||||
await expect(second).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("traps Tab within the dialog and restores the opener focus on close", async () => {
|
||||
const opener = document.createElement("button");
|
||||
opener.type = "button";
|
||||
opener.textContent = "打开收藏夹";
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
|
||||
const result = promptForFavoriteFolderName(document, {
|
||||
title: "新建收藏夹"
|
||||
});
|
||||
|
||||
readInput().focus();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }));
|
||||
expect(document.activeElement).toBe(readButton("confirm"));
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
||||
expect(document.activeElement).toBe(readInput());
|
||||
|
||||
readButton("cancel").click();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createFavoritesDrawer,
|
||||
type FavoritesDrawerHandlers
|
||||
} from "../src/content/market/favorites-drawer";
|
||||
import type {
|
||||
FavoriteCreator,
|
||||
FavoriteFolder,
|
||||
FavoriteMembership,
|
||||
FavoritesStateV1
|
||||
} from "../src/content/market/favorites-store";
|
||||
|
||||
const firstFolder = createFolder("folder-1", "合作");
|
||||
const secondFolder = createFolder("folder-2", "待联系");
|
||||
|
||||
function createFolder(id: string, name: string): FavoriteFolder {
|
||||
return {
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
id,
|
||||
name,
|
||||
updatedAt: "2026-07-17T00:00:00.000Z"
|
||||
};
|
||||
}
|
||||
|
||||
function createCreator(authorId: string, authorName: string): FavoriteCreator {
|
||||
return {
|
||||
authorId,
|
||||
authorName,
|
||||
savedAt: "2026-07-17T00:00:00.000Z"
|
||||
};
|
||||
}
|
||||
|
||||
function createMembership(authorId: string, folderId: string): FavoriteMembership {
|
||||
return {
|
||||
addedAt: "2026-07-17T00:00:00.000Z",
|
||||
authorId,
|
||||
folderId
|
||||
};
|
||||
}
|
||||
|
||||
function createState(options: {
|
||||
creators?: FavoriteCreator[];
|
||||
folders?: FavoriteFolder[];
|
||||
memberships?: FavoriteMembership[];
|
||||
} = {}): FavoritesStateV1 {
|
||||
return {
|
||||
creators:
|
||||
options.creators ?? [createCreator("author-1", "Alpha"), createCreator("author-2", "Beta")],
|
||||
folders: options.folders ?? [firstFolder, secondFolder],
|
||||
memberships:
|
||||
options.memberships ?? [
|
||||
createMembership("author-1", firstFolder.id),
|
||||
createMembership("author-2", secondFolder.id)
|
||||
],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options: {
|
||||
confirmImport?: (message: string) => boolean;
|
||||
handlers?: Partial<FavoritesDrawerHandlers>;
|
||||
} = {}) {
|
||||
const handlers: FavoritesDrawerHandlers = {
|
||||
onCreateFolder: async () => {},
|
||||
onDeleteFolder: async () => {},
|
||||
onImportFolder: async () => {},
|
||||
onImportSelected: async () => {},
|
||||
onRemoveCreatorFromFolder: async () => {},
|
||||
onRenameFolder: async () => {},
|
||||
...options.handlers
|
||||
};
|
||||
const confirmImport = options.confirmImport ?? vi.fn(() => true);
|
||||
const controller = createFavoritesDrawer(document, handlers, confirmImport);
|
||||
|
||||
return { confirmImport, controller, handlers };
|
||||
}
|
||||
|
||||
function readTab(): HTMLButtonElement {
|
||||
const tab = document.querySelector('[data-sces-favorites-tab="button"]');
|
||||
if (!(tab instanceof HTMLButtonElement)) {
|
||||
throw new Error("Expected favorites tab");
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function readDrawer(): HTMLElement {
|
||||
const drawer = document.querySelector('[data-sces-favorites-drawer="root"]');
|
||||
if (!(drawer instanceof HTMLElement)) {
|
||||
throw new Error("Expected favorites drawer");
|
||||
}
|
||||
return drawer;
|
||||
}
|
||||
|
||||
function click(selector: string): void {
|
||||
const target = document.querySelector(selector);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error(`Expected ${selector}`);
|
||||
}
|
||||
target.click();
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("favorites-drawer", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("mounts one fixed, closed trigger and drawer left of the native service rail", () => {
|
||||
createHarness();
|
||||
|
||||
expect(document.querySelectorAll('[data-sces-favorites-tab="button"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('[data-sces-favorites-drawer="root"]')).toHaveLength(1);
|
||||
expect(readTab().textContent).toContain("收藏夹");
|
||||
expect(readTab().style.position).toBe("fixed");
|
||||
expect(readTab().style.right).toBe("56px");
|
||||
expect(readTab().getAttribute("aria-expanded")).toBe("false");
|
||||
expect(readDrawer().hidden).toBe(true);
|
||||
expect(readDrawer().style.position).toBe("fixed");
|
||||
expect(readDrawer().style.right).toBe("56px");
|
||||
expect(readDrawer().style.width).toBe("380px");
|
||||
expect(readDrawer().getAttribute("role")).toBe("dialog");
|
||||
});
|
||||
|
||||
test("opens, closes, and restores focus through the trigger, close control, and Escape", () => {
|
||||
createHarness();
|
||||
|
||||
readTab().click();
|
||||
expect(readDrawer().hidden).toBe(false);
|
||||
expect(readTab().getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.activeElement).toBe(
|
||||
document.querySelector('[data-sces-favorites-drawer="close"]')
|
||||
);
|
||||
|
||||
click('[data-sces-favorites-drawer="close"]');
|
||||
expect(readDrawer().hidden).toBe(true);
|
||||
expect(document.activeElement).toBe(readTab());
|
||||
|
||||
readTab().click();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(readDrawer().hidden).toBe(true);
|
||||
expect(document.activeElement).toBe(readTab());
|
||||
});
|
||||
|
||||
test("deduplicates creators in all view and shows their folder affiliations", () => {
|
||||
const { controller } = createHarness();
|
||||
controller.render(
|
||||
createState({
|
||||
creators: [
|
||||
createCreator("author-1", "Alpha"),
|
||||
createCreator("author-1", "Alpha newer"),
|
||||
createCreator("author-2", "Beta")
|
||||
],
|
||||
memberships: [
|
||||
createMembership("author-1", firstFolder.id),
|
||||
createMembership("author-1", secondFolder.id),
|
||||
createMembership("author-2", secondFolder.id)
|
||||
]
|
||||
})
|
||||
);
|
||||
readTab().click();
|
||||
|
||||
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(2);
|
||||
expect(readDrawer().textContent).toContain("Alpha");
|
||||
expect(readDrawer().textContent).toContain("合作、待联系");
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("switches to a folder view and falls back to all when that folder is deleted", () => {
|
||||
const { controller } = createHarness();
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
|
||||
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
|
||||
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
|
||||
expect(readDrawer().textContent).toContain("Alpha");
|
||||
expect(document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
|
||||
).not.toBeNull();
|
||||
|
||||
controller.render(
|
||||
createState({
|
||||
creators: [createCreator("author-2", "Beta")],
|
||||
folders: [secondFolder],
|
||||
memberships: [createMembership("author-2", secondFolder.id)]
|
||||
})
|
||||
);
|
||||
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
|
||||
expect(readDrawer().textContent).toContain("Beta");
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => {
|
||||
const { controller } = createHarness();
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
|
||||
const search = document.querySelector(
|
||||
'[data-sces-favorites-drawer="search"]'
|
||||
) as HTMLInputElement;
|
||||
search.value = " beta ";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
const refocusedSearch = document.querySelector(
|
||||
'[data-sces-favorites-drawer="search"]'
|
||||
) as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(refocusedSearch);
|
||||
expect(refocusedSearch.selectionStart).toBe(refocusedSearch.value.length);
|
||||
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
|
||||
expect(readDrawer().textContent).toContain("Beta");
|
||||
|
||||
const nextSearch = document.querySelector(
|
||||
'[data-sces-favorites-drawer="search"]'
|
||||
) as HTMLInputElement;
|
||||
nextSearch.value = "没有";
|
||||
nextSearch.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(readDrawer().textContent).toContain("暂无收藏达人");
|
||||
});
|
||||
|
||||
test("imports only selected author ids and removes an author from the active folder", async () => {
|
||||
const onImportSelected = vi.fn(async (_authorIds: string[]) => {});
|
||||
const onRemoveCreatorFromFolder = vi.fn(
|
||||
async (_authorId: string, _folderId: string) => {}
|
||||
);
|
||||
const { controller } = createHarness({
|
||||
handlers: { onImportSelected, onRemoveCreatorFromFolder }
|
||||
});
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
|
||||
const alpha = document.querySelector(
|
||||
'[data-sces-favorites-select-author-id="author-1"]'
|
||||
) as HTMLInputElement;
|
||||
const beta = document.querySelector(
|
||||
'[data-sces-favorites-select-author-id="author-2"]'
|
||||
) as HTMLInputElement;
|
||||
alpha.checked = true;
|
||||
alpha.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const nextBeta = document.querySelector(
|
||||
'[data-sces-favorites-select-author-id="author-2"]'
|
||||
) as HTMLInputElement;
|
||||
nextBeta.checked = true;
|
||||
nextBeta.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(readDrawer().textContent).toContain("导入已选 2 位达人");
|
||||
click('[data-sces-favorites-drawer="import-selected"]');
|
||||
await flush();
|
||||
expect(onImportSelected).toHaveBeenCalledWith(["author-1", "author-2"]);
|
||||
|
||||
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
|
||||
click('[data-sces-favorites-remove-author-id="author-1"]');
|
||||
await flush();
|
||||
expect(onRemoveCreatorFromFolder).toHaveBeenCalledWith("author-1", firstFolder.id);
|
||||
});
|
||||
|
||||
test("calls create, rename, and delete handlers with the matching folder id", async () => {
|
||||
const onCreateFolder = vi.fn(async () => {});
|
||||
const onRenameFolder = vi.fn(async (_folderId: string) => {});
|
||||
const onDeleteFolder = vi.fn(async (_folderId: string) => {});
|
||||
const { controller } = createHarness({
|
||||
handlers: { onCreateFolder, onDeleteFolder, onRenameFolder }
|
||||
});
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
|
||||
click('[data-sces-favorites-drawer="create-folder"]');
|
||||
click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`);
|
||||
click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`);
|
||||
await flush();
|
||||
|
||||
expect(onCreateFolder).toHaveBeenCalledTimes(1);
|
||||
expect(onRenameFolder).toHaveBeenCalledWith(secondFolder.id);
|
||||
expect(onDeleteFolder).toHaveBeenCalledWith(secondFolder.id);
|
||||
});
|
||||
|
||||
test("confirms a deduplicated full-folder import once before importing", async () => {
|
||||
const confirmImport = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
const onImportFolder = vi.fn(async (_folderId: string, _authorIds: string[]) => {});
|
||||
const { controller } = createHarness({
|
||||
confirmImport,
|
||||
handlers: { onImportFolder }
|
||||
});
|
||||
controller.render(
|
||||
createState({
|
||||
memberships: [
|
||||
createMembership("author-1", firstFolder.id),
|
||||
createMembership("author-1", firstFolder.id),
|
||||
createMembership("author-2", firstFolder.id)
|
||||
]
|
||||
})
|
||||
);
|
||||
readTab().click();
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
|
||||
).toBeNull();
|
||||
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
|
||||
click('[data-sces-favorites-drawer="import-folder"]');
|
||||
await flush();
|
||||
expect(confirmImport).toHaveBeenLastCalledWith("将当前收藏夹的 2 位达人导入秒探吗?");
|
||||
expect(onImportFolder).not.toHaveBeenCalled();
|
||||
|
||||
click('[data-sces-favorites-drawer="import-folder"]');
|
||||
await flush();
|
||||
expect(confirmImport).toHaveBeenCalledTimes(2);
|
||||
expect(onImportFolder).toHaveBeenCalledWith(firstFolder.id, ["author-1", "author-2"]);
|
||||
});
|
||||
|
||||
test("disables mutation and import controls while busy but keeps close usable", async () => {
|
||||
const onCreateFolder = vi.fn(async () => {});
|
||||
const { controller } = createHarness({ handlers: { onCreateFolder } });
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
controller.setBusy(true);
|
||||
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'[data-sces-favorites-drawer="create-folder"]'
|
||||
) as HTMLButtonElement).disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(document.querySelector(
|
||||
`[data-sces-favorites-folder-rename="${firstFolder.id}"]`
|
||||
) as HTMLButtonElement).disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'[data-sces-favorites-drawer="import-selected"]'
|
||||
) as HTMLButtonElement).disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'[data-sces-favorites-drawer="close"]'
|
||||
) as HTMLButtonElement).disabled
|
||||
).toBe(false);
|
||||
|
||||
click('[data-sces-favorites-drawer="create-folder"]');
|
||||
await flush();
|
||||
expect(onCreateFolder).not.toHaveBeenCalled();
|
||||
click('[data-sces-favorites-drawer="close"]');
|
||||
expect(readDrawer().hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("renders errors and removes its DOM and document behavior on disposal", () => {
|
||||
const { controller } = createHarness();
|
||||
controller.render(createState());
|
||||
readTab().click();
|
||||
controller.setError("保存失败");
|
||||
expect(
|
||||
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
|
||||
).toBe("保存失败");
|
||||
|
||||
controller.dispose();
|
||||
controller.dispose();
|
||||
expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull();
|
||||
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user