feat: add row favorites picker
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { syncFavoriteRowPickers } from "../src/content/market/favorite-row-picker";
|
||||
import type { FavoriteCreatorInput, FavoriteFolder } 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 = "author-1"): FavoriteCreatorInput {
|
||||
return {
|
||||
authorId,
|
||||
authorName: "Alice",
|
||||
coreUserId: "core-1"
|
||||
};
|
||||
}
|
||||
|
||||
function createActionCell(label = "下单"): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.dataset.testid = "native-action-cell";
|
||||
const nativeButton = document.createElement("button");
|
||||
nativeButton.dataset.nativeAction = "order";
|
||||
nativeButton.textContent = label;
|
||||
cell.appendChild(nativeButton);
|
||||
document.body.appendChild(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function readFavoriteButton(actionCell: HTMLElement): HTMLButtonElement {
|
||||
const button = actionCell.querySelector(
|
||||
':scope > button[data-sces-favorite-row-action="button"]'
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error("Expected favorite action button");
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
function readPopover(): HTMLElement | null {
|
||||
return document.querySelector('[data-sces-favorite-row-picker="root"]');
|
||||
}
|
||||
|
||||
function sync(options: {
|
||||
actionCell?: HTMLElement;
|
||||
authorId?: string;
|
||||
folders?: FavoriteFolder[];
|
||||
folderIds?: string[];
|
||||
onCreateFolder?: () => Promise<FavoriteFolder | null>;
|
||||
onSetCreatorFolderIds?: (
|
||||
creator: FavoriteCreatorInput,
|
||||
folderIds: string[]
|
||||
) => Promise<void>;
|
||||
rows?: Array<{
|
||||
actionCell?: HTMLElement;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
coreUserId?: string;
|
||||
}>;
|
||||
} = {}): void {
|
||||
const creator = createCreator(options.authorId);
|
||||
syncFavoriteRowPickers({
|
||||
document,
|
||||
folderIdsByAuthorId: new Map([
|
||||
[creator.authorId, new Set(options.folderIds ?? [])]
|
||||
]),
|
||||
folders: options.folders ?? [firstFolder, secondFolder],
|
||||
onCreateFolder: options.onCreateFolder ?? (async () => null),
|
||||
onSetCreatorFolderIds: options.onSetCreatorFolderIds ?? (async () => {}),
|
||||
rows:
|
||||
options.rows ??
|
||||
(options.actionCell
|
||||
? [{ ...creator, actionCell: options.actionCell }]
|
||||
: [])
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsyncEvents(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("favorite-row-picker", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("prepends one picker control without replacing the native action", () => {
|
||||
const actionCell = createActionCell();
|
||||
|
||||
sync({ actionCell });
|
||||
|
||||
const button = readFavoriteButton(actionCell);
|
||||
expect(actionCell.firstElementChild).toBe(button);
|
||||
expect(actionCell.querySelector('[data-native-action="order"]')?.textContent).toBe(
|
||||
"下单"
|
||||
);
|
||||
expect(button.type).toBe("button");
|
||||
expect(button.title).toBe("加入收藏夹");
|
||||
expect(button.dataset.scesFavoriteCount).toBe("0");
|
||||
expect(button.dataset.scesFavoriteIcon).toBe("bookmark-outline");
|
||||
});
|
||||
|
||||
test("renders saved count and title from existing memberships", () => {
|
||||
const actionCell = createActionCell();
|
||||
|
||||
sync({ actionCell, folderIds: [firstFolder.id, secondFolder.id] });
|
||||
|
||||
const button = readFavoriteButton(actionCell);
|
||||
expect(button.title).toBe("已收藏至 2 个收藏夹");
|
||||
expect(button.dataset.scesFavoriteCount).toBe("2");
|
||||
expect(button.dataset.scesFavoriteIcon).toBe("bookmark-filled");
|
||||
});
|
||||
|
||||
test("disables a row with no author id and does not open a picker", () => {
|
||||
const actionCell = createActionCell();
|
||||
|
||||
sync({ actionCell, authorId: " " });
|
||||
|
||||
const button = readFavoriteButton(actionCell);
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.title).toContain("达人 ID");
|
||||
button.click();
|
||||
expect(readPopover()).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps one document popover when different row buttons are clicked", () => {
|
||||
const firstCell = createActionCell("下单 A");
|
||||
const secondCell = createActionCell("下单 B");
|
||||
const firstCreator = createCreator("author-1");
|
||||
const secondCreator = createCreator("author-2");
|
||||
|
||||
sync({
|
||||
rows: [
|
||||
{ ...firstCreator, actionCell: firstCell },
|
||||
{ ...secondCreator, actionCell: secondCell }
|
||||
]
|
||||
});
|
||||
|
||||
readFavoriteButton(firstCell).click();
|
||||
expect(readPopover()).not.toBeNull();
|
||||
readFavoriteButton(secondCell).click();
|
||||
expect(document.querySelectorAll('[data-sces-favorite-row-picker="root"]')).toHaveLength(1);
|
||||
expect(readPopover()?.textContent).toContain("合作");
|
||||
});
|
||||
|
||||
test("saves the complete checked folder selection for the matching creator", async () => {
|
||||
const actionCell = createActionCell();
|
||||
const onSetCreatorFolderIds = vi.fn(async () => {});
|
||||
|
||||
sync({ actionCell, onSetCreatorFolderIds });
|
||||
readFavoriteButton(actionCell).click();
|
||||
const folderCheckbox = document.querySelector(
|
||||
`input[data-sces-favorite-folder-id="${secondFolder.id}"]`
|
||||
) as HTMLInputElement;
|
||||
folderCheckbox.checked = true;
|
||||
folderCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushAsyncEvents();
|
||||
|
||||
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [secondFolder.id]);
|
||||
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
|
||||
});
|
||||
|
||||
test("adds a newly created folder to the creator selection", async () => {
|
||||
const actionCell = createActionCell();
|
||||
const createdFolder = createFolder("folder-3", "重点");
|
||||
const onCreateFolder = vi.fn(async () => createdFolder);
|
||||
const onSetCreatorFolderIds = vi.fn(async () => {});
|
||||
|
||||
sync({ actionCell, onCreateFolder, onSetCreatorFolderIds });
|
||||
readFavoriteButton(actionCell).click();
|
||||
const createButton = document.querySelector(
|
||||
'[data-sces-favorite-row-picker="create-folder"]'
|
||||
) as HTMLButtonElement;
|
||||
createButton.click();
|
||||
await flushAsyncEvents();
|
||||
|
||||
expect(onCreateFolder).toHaveBeenCalledTimes(1);
|
||||
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [createdFolder.id]);
|
||||
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
|
||||
});
|
||||
|
||||
test("keeps the visible selection when saving a checkbox change fails", async () => {
|
||||
const actionCell = createActionCell();
|
||||
const onSetCreatorFolderIds = vi.fn(async () => {
|
||||
throw new Error("save failed");
|
||||
});
|
||||
|
||||
sync({ actionCell, folderIds: [firstFolder.id], onSetCreatorFolderIds });
|
||||
readFavoriteButton(actionCell).click();
|
||||
const firstCheckbox = document.querySelector(
|
||||
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
|
||||
) as HTMLInputElement;
|
||||
firstCheckbox.checked = false;
|
||||
firstCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushAsyncEvents();
|
||||
|
||||
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), []);
|
||||
expect(
|
||||
(
|
||||
document.querySelector(
|
||||
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
|
||||
) as HTMLInputElement
|
||||
).checked
|
||||
).toBe(true);
|
||||
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
|
||||
});
|
||||
|
||||
test("closes the popover on outside pointer-down and Escape", () => {
|
||||
const actionCell = createActionCell();
|
||||
sync({ actionCell });
|
||||
|
||||
readFavoriteButton(actionCell).click();
|
||||
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||
expect(readPopover()).toBeNull();
|
||||
|
||||
readFavoriteButton(actionCell).click();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(readPopover()).toBeNull();
|
||||
});
|
||||
|
||||
test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => {
|
||||
const actionCell = createActionCell();
|
||||
const staleCallback = vi.fn(async () => {});
|
||||
const currentCallback = vi.fn(async () => {});
|
||||
|
||||
sync({ actionCell, onSetCreatorFolderIds: staleCallback });
|
||||
readFavoriteButton(actionCell).click();
|
||||
expect(readPopover()).not.toBeNull();
|
||||
|
||||
sync({ actionCell, onSetCreatorFolderIds: currentCallback });
|
||||
expect(
|
||||
actionCell.querySelectorAll('[data-sces-favorite-row-action="button"]')
|
||||
).toHaveLength(1);
|
||||
const folderCheckbox = document.querySelector(
|
||||
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
|
||||
) as HTMLInputElement;
|
||||
folderCheckbox.checked = true;
|
||||
folderCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushAsyncEvents();
|
||||
expect(staleCallback).not.toHaveBeenCalled();
|
||||
expect(currentCallback).toHaveBeenCalledWith(createCreator(), [firstFolder.id]);
|
||||
|
||||
sync({ rows: [] });
|
||||
expect(readPopover()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@ describe("market-dom-sync", () => {
|
||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18);
|
||||
expect(table?.headerSelectionCheckbox).not.toBeNull();
|
||||
expect(table?.rows[0]?.selectionCheckbox).not.toBeNull();
|
||||
expect(table?.rows[0]?.actionCell).toBeUndefined();
|
||||
});
|
||||
|
||||
test("renders loading, success, missing, and failed states", () => {
|
||||
@@ -229,6 +230,11 @@ describe("market-dom-sync", () => {
|
||||
)
|
||||
).toBe(350);
|
||||
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
||||
expect(table.rows.map((row) => row.actionCell?.dataset.testid)).toEqual([
|
||||
"action-cell-111",
|
||||
"action-cell-222"
|
||||
]);
|
||||
expect(table.rows[0]?.actionCell?.textContent?.trim()).toBe("下单");
|
||||
|
||||
renderMarketRowState(table.rows[0], {
|
||||
authorId: "111",
|
||||
|
||||
Reference in New Issue
Block a user