fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread
The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus.
This commit is contained in:
@@ -49,6 +49,7 @@ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import NotificationsPanel from "./NotificationsPanel";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
|
||||
// The server list goes through the real live query, so an optimistic patch of its cache is seen.
|
||||
beforeEach(() => {
|
||||
@@ -256,6 +257,61 @@ describe("NotificationsPanel · order", () => {
|
||||
expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"]);
|
||||
});
|
||||
|
||||
it("the rows stay out of the band until the read after the write has landed", async () => {
|
||||
// The write settling is not the list being fresh: a refetch over the network takes a moment,
|
||||
// and until it lands the cache still holds what the read before the commit said.
|
||||
let commit!: () => void;
|
||||
vi.spyOn(api, "markAllNotificationsRead").mockImplementation(
|
||||
() => new Promise((resolve) => (commit = () => resolve({ ok: true } as never))),
|
||||
);
|
||||
h.server = [server(1, 1 * H, false)];
|
||||
h.client = [];
|
||||
const { qc } = mount();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" }));
|
||||
await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled());
|
||||
void qc.invalidateQueries({ queryKey: ["notifications"] });
|
||||
await waitFor(() =>
|
||||
expect(qc.getQueryData<{ items: AppNotification[] }>(["notifications"])?.items[0]?.read).toBe(
|
||||
false,
|
||||
),
|
||||
);
|
||||
let land!: () => void;
|
||||
vi.mocked(api.notifications).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
land = () => resolve({ items: [server(1, 1 * H, true)], total: 1 });
|
||||
}),
|
||||
);
|
||||
await act(async () => commit());
|
||||
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||
await act(async () => land());
|
||||
await waitFor(() => expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"]));
|
||||
expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer Mark all read for a band that only holds what asks by its kind", async () => {
|
||||
// An access-requests nudge stays up for as long as requests wait: the gesture cannot empty
|
||||
// that band, and a button that does nothing when pressed is not offered.
|
||||
h.server = [server(1, 1 * H, true)];
|
||||
h.client = [
|
||||
client(9, 5 * 60 * 1000, {
|
||||
level: "warning",
|
||||
title: "common.accessRequestsTitle",
|
||||
message: "1 pending",
|
||||
meta: { kind: "access-requests" },
|
||||
}),
|
||||
];
|
||||
mount();
|
||||
await loaded();
|
||||
expect(screen.getByText("inbox.sectionNeedsYou:1")).toBeTruthy();
|
||||
const button = screen.getByRole("button", { name: "inbox.markAllRead" });
|
||||
expect(button.hasAttribute("disabled") || button.getAttribute("aria-disabled") === "true").toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("a refused Mark all read brings the rows back at once, not at the next poll", async () => {
|
||||
vi.spyOn(api, "markAllNotificationsRead").mockRejectedValue(new Error("offline"));
|
||||
h.server = [server(1, 1 * H, false)];
|
||||
@@ -270,6 +326,42 @@ describe("NotificationsPanel · order", () => {
|
||||
await waitFor(() => expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"]));
|
||||
});
|
||||
|
||||
it("says a row is unread while the keyboard is on it; its name for Back stays the same", async () => {
|
||||
// The unread dot is only drawn: a read row held in Needs you sounded the same as an unread one.
|
||||
vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never);
|
||||
h.server = [server(1, 1 * H, false), server(2, 2 * H, true)];
|
||||
h.client = [];
|
||||
mount();
|
||||
await loaded();
|
||||
const spoken = (text: string) => {
|
||||
const row = screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
act(() => focusNavItem(row.closest("section")!, Number(row.getAttribute("data-nav-index"))));
|
||||
return [
|
||||
document.activeElement?.getAttribute("aria-label"),
|
||||
row.getAttribute("data-nav-name"),
|
||||
];
|
||||
};
|
||||
expect(spoken("Ready 1")).toEqual(["inbox.unreadName", "inbox.downloadDone.title: Ready 1"]);
|
||||
expect(spoken("Ready 2")).toEqual([
|
||||
"inbox.downloadDone.title: Ready 2",
|
||||
"inbox.downloadDone.title: Ready 2",
|
||||
]);
|
||||
// Marked read from its button, the row takes the focus before it reads read: what it is called
|
||||
// follows once it does.
|
||||
const row = screen.getByText("Ready 1").closest<HTMLElement>("[data-nav-index]")!;
|
||||
const button = within(row).getByRole("button", { name: "inbox.markRead" });
|
||||
button.focus();
|
||||
h.server = [server(1, 1 * H, true), server(2, 2 * H, true)];
|
||||
fireEvent.click(button);
|
||||
await waitFor(() =>
|
||||
expect(within(row).queryByRole("button", { name: "inbox.markRead" })).toBeNull(),
|
||||
);
|
||||
expect(document.activeElement).toBe(row);
|
||||
await waitFor(() =>
|
||||
expect(row.getAttribute("aria-label")).toBe("inbox.downloadDone.title: Ready 1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("names each row by what it is about, so two of one kind tell apart", async () => {
|
||||
// A screen reader hears the name while the keyboard is on the row, and Back finds the row it
|
||||
// left by it: "Download ready" alone was every download's name.
|
||||
|
||||
Reference in New Issue
Block a user