feat: track selected market creators
This commit is contained in:
parent
91d8347b76
commit
96e93628bd
@ -258,6 +258,34 @@ export function syncPluginSortHeaders(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncMarketSelectionState(
|
||||||
|
table: MarketTableDom,
|
||||||
|
selectedAuthorIds: Set<string>
|
||||||
|
): void {
|
||||||
|
table.rows.forEach((rowDom) => {
|
||||||
|
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
|
||||||
|
rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!table.headerSelectionCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRows = table.rows.filter((rowDom) =>
|
||||||
|
rowDom.visibilityTargets.some((target) => !target.hidden)
|
||||||
|
);
|
||||||
|
const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows;
|
||||||
|
const selectedCount = scopedRows.filter((rowDom) =>
|
||||||
|
selectedAuthorIds.has(rowDom.authorId)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
table.headerSelectionCheckbox.indeterminate =
|
||||||
|
selectedCount > 0 && selectedCount < scopedRows.length;
|
||||||
|
table.headerSelectionCheckbox.checked =
|
||||||
|
scopedRows.length > 0 && selectedCount === scopedRows.length;
|
||||||
|
table.headerSelectionCheckbox.disabled = scopedRows.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||||
const header = root.querySelector("[data-market-header]") as HTMLElement | null;
|
const header = root.querySelector("[data-market-header]") as HTMLElement | null;
|
||||||
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
|
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
|
||||||
@ -285,9 +313,11 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
|||||||
const backendMetricsCells = Object.fromEntries(
|
const backendMetricsCells = Object.fromEntries(
|
||||||
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
|
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
|
||||||
) as Record<BackendMetricField, HTMLElement>;
|
) as Record<BackendMetricField, HTMLElement>;
|
||||||
|
const authorId = row.dataset.authorId ?? "";
|
||||||
|
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authorId: row.dataset.authorId ?? "",
|
authorId,
|
||||||
authorName:
|
authorName:
|
||||||
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||||
"",
|
"",
|
||||||
@ -616,6 +646,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
readDivGridPriceDisplay(priceCells[index]?.textContent),
|
readDivGridPriceDisplay(priceCells[index]?.textContent),
|
||||||
fallbackMarketRow?.price21To60s
|
fallbackMarketRow?.price21To60s
|
||||||
);
|
);
|
||||||
|
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
readMarketPageSignature,
|
readMarketPageSignature,
|
||||||
renderMarketRowState,
|
renderMarketRowState,
|
||||||
syncPluginSortHeaders,
|
syncPluginSortHeaders,
|
||||||
|
syncMarketSelectionState,
|
||||||
syncMarketTable,
|
syncMarketTable,
|
||||||
type MarketRowDom
|
type MarketRowDom
|
||||||
} from "./dom-sync";
|
} from "./dom-sync";
|
||||||
@ -99,6 +100,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
let lastKnownPageSignature = "";
|
let lastKnownPageSignature = "";
|
||||||
let needsResync = false;
|
let needsResync = false;
|
||||||
let scheduledSyncTimeoutId: number | null = null;
|
let scheduledSyncTimeoutId: number | null = null;
|
||||||
|
const selectedAuthorIds = new Set<string>();
|
||||||
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||||
const observer = mutationObserverFactory(() => {
|
const observer = mutationObserverFactory(() => {
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
@ -114,7 +116,14 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
const toolbarNeedsRemount =
|
const toolbarNeedsRemount =
|
||||||
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
||||||
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
|
const selectionControlsMissing =
|
||||||
|
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
||||||
|
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
||||||
|
if (
|
||||||
|
nextPageSignature === lastKnownPageSignature &&
|
||||||
|
!toolbarNeedsRemount &&
|
||||||
|
!selectionControlsMissing
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,10 +394,78 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const records = getVisibleOrderedRecords(table);
|
const records = getVisibleOrderedRecords(table);
|
||||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||||
applyRowOrder(table, records.map((record) => record.authorId));
|
applyRowOrder(table, records.map((record) => record.authorId));
|
||||||
|
bindSelectionControls(table);
|
||||||
|
syncMarketSelectionState(table, selectedAuthorIds);
|
||||||
lastKnownPageSignature = readMarketPageSignature(options.document);
|
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.rows.forEach((rowDom) => {
|
||||||
|
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
|
||||||
|
if (rowDom.selectionCheckbox.dataset.marketSelectionBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowDom.selectionCheckbox.dataset.marketSelectionBound = "true";
|
||||||
|
rowDom.selectionCheckbox.addEventListener("change", () => {
|
||||||
|
if (rowDom.selectionCheckbox.checked) {
|
||||||
|
selectedAuthorIds.add(rowDom.authorId);
|
||||||
|
} else {
|
||||||
|
selectedAuthorIds.delete(rowDom.authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectionControls();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!table.headerSelectionCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table.headerSelectionCheckbox.dataset.marketSelectionBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.headerSelectionCheckbox.dataset.marketSelectionBound = "true";
|
||||||
|
table.headerSelectionCheckbox.addEventListener("change", () => {
|
||||||
|
const currentTable = syncMarketTable(options.document);
|
||||||
|
if (!currentTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRows = currentTable.rows.filter((rowDom) =>
|
||||||
|
rowDom.visibilityTargets.some((target) => !target.hidden)
|
||||||
|
);
|
||||||
|
const scopedRows = visibleRows.length > 0 ? visibleRows : currentTable.rows;
|
||||||
|
if (table.headerSelectionCheckbox?.checked) {
|
||||||
|
scopedRows.forEach((rowDom) => {
|
||||||
|
selectedAuthorIds.add(rowDom.authorId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scopedRows.forEach((rowDom) => {
|
||||||
|
selectedAuthorIds.delete(rowDom.authorId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectionControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectionControls(): void {
|
||||||
|
const table = syncMarketTable(options.document);
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindSelectionControls(table);
|
||||||
|
syncMarketSelectionState(table, selectedAuthorIds);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
||||||
activeSort = getNextSortState(activeSort, field);
|
activeSort = getNextSortState(activeSort, field);
|
||||||
applyCurrentView();
|
applyCurrentView();
|
||||||
|
|||||||
@ -290,6 +290,140 @@ describe("market-content-entry", () => {
|
|||||||
expect((toolbar as HTMLElement | null)?.hidden).toBe(false);
|
expect((toolbar as HTMLElement | null)?.hidden).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selection keeps a clicked creator checked after the table re-renders", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const mutationObserver = createMutationObserverFactory();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
mutationObserverFactory: mutationObserver.factory,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickSelectionCheckboxForAuthor("a");
|
||||||
|
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
|
||||||
|
|
||||||
|
const table = document.querySelector("[data-market-table]");
|
||||||
|
if (!(table instanceof HTMLElement)) {
|
||||||
|
throw new Error("Missing market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
table.outerHTML = buildMarketTableOnlyFixture();
|
||||||
|
mutationObserver.trigger();
|
||||||
|
await flushWithTimers();
|
||||||
|
|
||||||
|
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selection survives a page change and re-render", async () => {
|
||||||
|
const pages = [
|
||||||
|
[
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
|
||||||
|
]
|
||||||
|
];
|
||||||
|
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||||||
|
installAsyncPaginationHarness(pages);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickSelectionCheckboxForAuthor("111");
|
||||||
|
click('[data-testid="next-page"]');
|
||||||
|
await flushWithTimers();
|
||||||
|
|
||||||
|
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
|
||||||
|
expect(readSelectionCheckboxForAuthor("333").checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selection header selects all visible creators on the current page", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickHeaderSelectionCheckbox();
|
||||||
|
|
||||||
|
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
|
||||||
|
expect(readSelectionCheckboxForAuthor("222").checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selection header clears all visible creators on the current page", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickHeaderSelectionCheckbox();
|
||||||
|
clickHeaderSelectionCheckbox();
|
||||||
|
|
||||||
|
expect(readSelectionCheckboxForAuthor("111").checked).toBe(false);
|
||||||
|
expect(readSelectionCheckboxForAuthor("222").checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selection header becomes indeterminate when only part of the current page is selected", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickSelectionCheckboxForAuthor("111");
|
||||||
|
|
||||||
|
expect(readHeaderSelectionCheckbox().checked).toBe(false);
|
||||||
|
expect(readHeaderSelectionCheckbox().indeterminate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("hydrates current page rows on start", async () => {
|
test("hydrates current page rows on start", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
|
||||||
@ -3138,6 +3272,50 @@ function click(selector: string) {
|
|||||||
element.click();
|
element.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clickSelectionCheckboxForAuthor(authorId: string) {
|
||||||
|
readSelectionCheckboxForAuthor(authorId).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickHeaderSelectionCheckbox() {
|
||||||
|
readHeaderSelectionCheckbox().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSelectionCheckboxForAuthor(authorId: string) {
|
||||||
|
const bySelectionAuthorId = document.querySelector(
|
||||||
|
`[data-market-selection-author-id="${authorId}"]`
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (bySelectionAuthorId) {
|
||||||
|
return bySelectionAuthorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySyntheticRow = document.querySelector(
|
||||||
|
`[data-market-row][data-author-id="${authorId}"] [data-market-selection-checkbox="row"]`
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (bySyntheticRow) {
|
||||||
|
return bySyntheticRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byAuthorCell = document.querySelector(
|
||||||
|
`[data-testid="author-cell-${authorId}"] [data-market-selection-checkbox="row"]`
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (byAuthorCell) {
|
||||||
|
return byAuthorCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Missing selection checkbox for author: ${authorId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderSelectionCheckbox() {
|
||||||
|
const checkbox = document.querySelector(
|
||||||
|
'[data-market-selection-checkbox="header"]'
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (!checkbox) {
|
||||||
|
throw new Error("Missing header selection checkbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
function setInputValue(selector: string, value: string) {
|
function setInputValue(selector: string, value: string) {
|
||||||
const element = document.querySelector(selector) as HTMLInputElement | null;
|
const element = document.querySelector(selector) as HTMLInputElement | null;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user