feat(notifications): add account-scoped inbox and persistent preferences
This commit is contained in:
1 parent
8f55ff2d17
commit
1eee6661f9
39 files changed
+1559
-3
No files matched your search
@@ -197,6 +197,7 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
if (
|
||||
[
|
||||
"/notifications",
|
||||
"/admin/unsaved-harness",
|
||||
"/admin/attachment-harness",
|
||||
"/admin/studio-harness",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { NotificationCenter } from "@/features/notifications/notification-center";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { themePaletteCss } from "@/lib/theme-css";
|
||||
@@ -25,6 +26,11 @@ theme.textContent = themePaletteCss(
|
||||
);
|
||||
document.head.appendChild(theme);
|
||||
const route = window.location.pathname;
|
||||
if (route.includes("notifications")) {
|
||||
document.documentElement.classList.add("app");
|
||||
document.body.classList.add("site-bg");
|
||||
document.body.style.backgroundColor = "var(--color-background)";
|
||||
}
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw Error("Fixture root missing");
|
||||
const rows = [
|
||||
@@ -96,8 +102,18 @@ createRoot(root).render(
|
||||
now={new Date("2030-01-01T12:00:00Z")}
|
||||
messages={messages}
|
||||
>
|
||||
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
|
||||
<h1 className="mb-5 text-2xl font-bold">
|
||||
<main
|
||||
data-admin={route.includes("notifications") ? undefined : ""}
|
||||
className={
|
||||
route.includes("notifications")
|
||||
? "min-w-0 mx-auto max-w-7xl px-3 py-3 md:px-6 md:py-6"
|
||||
: "min-w-0 mx-auto max-w-6xl p-5"
|
||||
}
|
||||
>
|
||||
<h1
|
||||
hidden={route.includes("notifications")}
|
||||
className="mb-5 text-2xl font-bold"
|
||||
>
|
||||
{route.includes("settings")
|
||||
? "CMS settings"
|
||||
: route.includes("tickets")
|
||||
@@ -106,7 +122,9 @@ createRoot(root).render(
|
||||
? "News editor"
|
||||
: "Furniture synchronization"}
|
||||
</h1>
|
||||
{route.includes("unsaved-harness") ? (
|
||||
{route.includes("notifications") ? (
|
||||
<NotificationCenter userId={7} />
|
||||
) : route.includes("unsaved-harness") ? (
|
||||
<UnsavedHarness />
|
||||
) : route.includes("studio-harness") ? (
|
||||
<StudioClient
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => {
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
test("notification reads persist, new replies are unread and failed preferences stay unchanged", async ({
|
||||
page,
|
||||
}) => {
|
||||
let key = "support:42";
|
||||
const readKeys = new Set<string>();
|
||||
let fail = false;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
if (fail)
|
||||
return route.fulfill({ status: 503, json: { error: "Unavailable" } });
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.action === "read") readKeys.add(body.key);
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
key,
|
||||
category: "support",
|
||||
title: "Your account request",
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: "2030-01-01T12:00:00Z",
|
||||
read: readKeys.has(key),
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: readKeys.has(key) ? 0 : 1,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
const mark = page.getByRole("button", { name: "Mark as read" });
|
||||
await expect(mark).toBeVisible();
|
||||
await mark.focus();
|
||||
await expect(mark).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(mark).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText("No unread notifications", { exact: true }),
|
||||
).toBeVisible();
|
||||
key = "support:43";
|
||||
await page.reload();
|
||||
await expect(mark).toBeVisible();
|
||||
fail = true;
|
||||
await mark.click();
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"The change could not be saved. Please try again.",
|
||||
);
|
||||
await expect(mark).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).toBeChecked();
|
||||
expect(await support.evaluate((el) => getComputedStyle(el).color)).not.toBe(
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
expect(
|
||||
await support.evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.screenshot({
|
||||
path: `test-results/notifications-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("notification service failure offers a retry and recovers to empty state", async ({
|
||||
page,
|
||||
}) => {
|
||||
let failing = true;
|
||||
await page.route("**/api/notifications**", (route) =>
|
||||
route.fulfill(
|
||||
failing
|
||||
? { status: 503, json: { error: "Unavailable" } }
|
||||
: {
|
||||
json: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pages: 1,
|
||||
unread: 0,
|
||||
preferences: { support: true, friends: true, events: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Notifications are temporarily unavailable.",
|
||||
);
|
||||
failing = false;
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("category changes persist and pagination recovers when filtering shrinks the feed", async ({
|
||||
page,
|
||||
}) => {
|
||||
let enabled = true;
|
||||
await page.route("**/api/notifications**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
enabled = route.request().postDataJSON().preferences.support;
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
const requested = Number(
|
||||
new URL(route.request().url()).searchParams.get("page") ?? 1,
|
||||
);
|
||||
const current = enabled ? Math.min(2, requested) : 1;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: enabled
|
||||
? [
|
||||
{
|
||||
key: `support:${current}`,
|
||||
category: "support",
|
||||
title: `Reply page ${current}`,
|
||||
href: "/help/tickets/2",
|
||||
occurredAt: null,
|
||||
read: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
page: current,
|
||||
pages: enabled ? 2 : 1,
|
||||
unread: enabled ? 21 : 0,
|
||||
preferences: { support: enabled, friends: true, events: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/notifications");
|
||||
await page.getByRole("button", { name: "Next", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 2" })).toBeVisible();
|
||||
const support = page.getByRole("checkbox", { name: "Support replies" });
|
||||
await support.click();
|
||||
await expect(support).not.toBeChecked();
|
||||
await expect(
|
||||
page.getByText("No notifications in your enabled categories."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Next", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(support).not.toBeChecked();
|
||||
await support.click();
|
||||
await expect(page.getByRole("link", { name: "Reply page 1" })).toBeVisible();
|
||||
});
|
||||
Reference in new issue
Block a user