mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 12:03:12 +00:00
# Conflicts: # src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts # src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts # src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx # src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx # src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { useLocation } from "react-router";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import NotificationsPage from "@/app/(protected)/notifications";
|
|
import { NotificationBell } from "@/components/notifications/notification-bell";
|
|
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
|
import { renderWithProviders } from "@/test/test-utils";
|
|
|
|
const apiGet = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/api", () => ({ apiGet }));
|
|
|
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
|
useAuthContext: () => ({ user: { id: "dispatcher-me" } }),
|
|
}));
|
|
|
|
type ItemInput = {
|
|
id: string;
|
|
reason: string;
|
|
severity: "Critical" | "High" | "Medium" | "Low";
|
|
title: string;
|
|
count?: number;
|
|
rowType?: "dismissable" | "acknowledge";
|
|
target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string };
|
|
workOrders?: { id: number; number: string }[];
|
|
};
|
|
|
|
function item(input: ItemInput) {
|
|
return {
|
|
rowType: "dismissable" as ItemInput["rowType"],
|
|
count: 1,
|
|
triggeredAt: "2026-09-18T12:00:00Z",
|
|
workOrders: [] as { id: number; number: string }[],
|
|
...input,
|
|
};
|
|
}
|
|
|
|
function section(reason: string, label: string, items: ReturnType<typeof item>[]) {
|
|
const severity = items[0]?.severity ?? "Low";
|
|
return {
|
|
reason,
|
|
label,
|
|
severity,
|
|
count: items.reduce((sum, row) => sum + row.count, 0),
|
|
items,
|
|
};
|
|
}
|
|
|
|
const unassigned = item({
|
|
id: "unassigned",
|
|
reason: "unassigned",
|
|
severity: "High",
|
|
title: "3 work orders are unassigned",
|
|
count: 3,
|
|
target: { kind: "queue", queue: "unassigned" },
|
|
});
|
|
|
|
const avetaFirst = item({
|
|
id: "aveta-missing-41",
|
|
reason: "aveta-missing",
|
|
severity: "Medium",
|
|
title: "WO #1041 is missing the Aveta document",
|
|
target: { kind: "workOrder", workOrderId: 41, tab: "extras" },
|
|
});
|
|
|
|
const avetaSecond = item({
|
|
id: "aveta-missing-42",
|
|
reason: "aveta-missing",
|
|
severity: "Medium",
|
|
title: "WO #1042 is missing the Aveta document",
|
|
target: { kind: "workOrder", workOrderId: 42, tab: "extras" },
|
|
});
|
|
|
|
const noVendor = item({
|
|
id: "no-vendor-7",
|
|
reason: "no-vendor",
|
|
severity: "Medium",
|
|
title: "WO #1007 starts within 48h with no vendor assigned",
|
|
target: { kind: "workOrder", workOrderId: 7, tab: "info" },
|
|
});
|
|
|
|
const acknowledgeRow = item({
|
|
id: "sla-9",
|
|
reason: "sla",
|
|
severity: "Critical",
|
|
rowType: "acknowledge",
|
|
title: "WO #1009 missed its response deadline",
|
|
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
|
|
});
|
|
|
|
function feed(...sections: ReturnType<typeof section>[]) {
|
|
return { generatedAt: "2026-09-18T12:00:00Z", sections };
|
|
}
|
|
|
|
const standardFeed = feed(
|
|
section("unassigned", "Unassigned", [unassigned]),
|
|
section("no-vendor", "No Vendor", [noVendor]),
|
|
section("aveta-missing", "Aveta Missing", [avetaFirst, avetaSecond]),
|
|
);
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
|
|
}
|
|
|
|
function renderBell() {
|
|
return renderWithProviders(
|
|
<NotificationSessionProvider>
|
|
<NotificationBell />
|
|
<LocationProbe />
|
|
</NotificationSessionProvider>,
|
|
{ withAuth: false },
|
|
);
|
|
}
|
|
|
|
async function openPanel(user: ReturnType<typeof userEvent.setup>) {
|
|
await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ }));
|
|
return screen.findByRole("dialog");
|
|
}
|
|
|
|
function sectionLabels(panel: HTMLElement) {
|
|
return within(panel)
|
|
.getAllByRole("region")
|
|
.map((region) => region.getAttribute("aria-label"));
|
|
}
|
|
|
|
describe("notification center", () => {
|
|
beforeEach(() => {
|
|
apiGet.mockReset();
|
|
apiGet.mockResolvedValue(standardFeed);
|
|
});
|
|
|
|
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
|
|
renderBell();
|
|
|
|
expect(
|
|
await screen.findByRole("button", { name: "Notifications, 4 unread" }),
|
|
).toBeInTheDocument();
|
|
expect(apiGet).toHaveBeenCalledWith("notifications");
|
|
});
|
|
|
|
it("shows labeled sections with live counts in severity then reason order", async () => {
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
|
|
expect(sectionLabels(panel)).toEqual(["Unassigned, 3", "No Vendor, 1", "Aveta Missing, 2"]);
|
|
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
|
|
expect(within(aveta).getByRole("heading", { name: "Aveta Missing" })).toBeInTheDocument();
|
|
expect(within(aveta).getAllByRole("listitem")).toHaveLength(2);
|
|
});
|
|
|
|
it("never renders an empty section and shows the empty state when nothing is left", async () => {
|
|
apiGet.mockResolvedValue(
|
|
feed(section("unassigned", "Unassigned", [unassigned]), section("due-soon", "Due Soon", [])),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
|
|
|
|
await user.click(within(panel).getByRole("button", { name: "Dismiss notification" }));
|
|
|
|
expect(within(panel).queryAllByRole("region")).toHaveLength(0);
|
|
expect(within(panel).getByText("Nothing needs your attention")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
|
|
});
|
|
|
|
it("styles unread rows apart from read ones and marks everything read on request", async () => {
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
const rows = within(panel).getAllByRole("listitem");
|
|
expect(rows.every((row) => row.dataset.unread === "true")).toBe(true);
|
|
expect(within(panel).getAllByText("Unread:", { exact: false })).toHaveLength(4);
|
|
|
|
await user.click(within(panel).getByRole("button", { name: "Mark all read" }));
|
|
|
|
expect(
|
|
within(panel)
|
|
.getAllByRole("listitem")
|
|
.every((row) => row.dataset.unread === "false"),
|
|
).toBe(true);
|
|
expect(within(panel).queryByText("Unread:", { exact: false })).not.toBeInTheDocument();
|
|
expect(within(panel).getByRole("button", { name: "Mark all read" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
|
|
});
|
|
|
|
it("dismisses one item, lowering its section count, and keeps the dismissal to this session only", async () => {
|
|
const user = userEvent.setup();
|
|
const { unmount } = renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
|
|
const firstRow = within(aveta).getByText(avetaFirst.title).closest("li");
|
|
expect(firstRow).not.toBeNull();
|
|
await user.click(
|
|
within(firstRow as HTMLElement).getByRole("button", { name: "Dismiss notification" }),
|
|
);
|
|
|
|
expect(within(panel).getByRole("region", { name: "Aveta Missing, 1" })).toBeInTheDocument();
|
|
expect(within(panel).queryByText(avetaFirst.title)).not.toBeInTheDocument();
|
|
expect(within(panel).getByText(avetaSecond.title)).toBeInTheDocument();
|
|
|
|
unmount();
|
|
renderBell();
|
|
const freshPanel = await openPanel(user);
|
|
expect(within(freshPanel).getByText(avetaFirst.title)).toBeInTheDocument();
|
|
});
|
|
|
|
it("re-sorts sections after a dismissal changes which severity leads", async () => {
|
|
apiGet.mockResolvedValue(
|
|
feed(
|
|
section("aveta-missing", "Aveta Missing", [
|
|
item({ ...avetaFirst, severity: "High", title: "WO #1041 urgent" }),
|
|
avetaSecond,
|
|
]),
|
|
section("no-vendor", "No Vendor", [noVendor]),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
expect(sectionLabels(panel)).toEqual(["Aveta Missing, 2", "No Vendor, 1"]);
|
|
|
|
const highRow = within(panel).getByText("WO #1041 urgent").closest("li") as HTMLElement;
|
|
await user.click(within(highRow).getByRole("button", { name: "Dismiss notification" }));
|
|
|
|
expect(sectionLabels(panel)).toEqual(["No Vendor, 1", "Aveta Missing, 1"]);
|
|
});
|
|
|
|
it("clears every dismissable item but keeps rows that need acknowledging", async () => {
|
|
apiGet.mockResolvedValue(
|
|
feed(
|
|
section("sla", "SLA at Risk", [acknowledgeRow]),
|
|
section("unassigned", "Unassigned", [unassigned]),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
|
|
expect(within(slaRow).queryByRole("button", { name: "Dismiss notification" })).toBeNull();
|
|
|
|
await user.click(within(panel).getByRole("button", { name: "Clear all" }));
|
|
|
|
expect(sectionLabels(panel)).toEqual(["SLA at Risk, 1"]);
|
|
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
await user.click(within(panel).getByRole("button", { name: /3 work orders are unassigned/ }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
|
const location = screen.getByRole("status", { name: "Current location" }).textContent ?? "";
|
|
const url = new URL(location, "http://localhost");
|
|
expect(url.pathname).toBe("/workorders");
|
|
expect(url.searchParams.get("dispatchers")).toBe("__unassigned");
|
|
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
|
|
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
|
|
expect(url.searchParams.get("statuses")).toBeTruthy();
|
|
expect(url.searchParams.get("fromDashboard")).toBeNull();
|
|
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens an Aveta item on that work order's upload tab", async () => {
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
await user.click(within(panel).getByRole("button", { name: new RegExp(avetaSecond.title) }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
|
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
|
"/workorders?wo=42&tab=extras",
|
|
);
|
|
});
|
|
|
|
it("serves the same feed as a full page and sends No Vendor straight to choosing a vendor", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithProviders(
|
|
<NotificationSessionProvider>
|
|
<NotificationsPage />
|
|
<LocationProbe />
|
|
</NotificationSessionProvider>,
|
|
{ withAuth: false, route: "/notifications" },
|
|
);
|
|
|
|
expect(await screen.findByRole("region", { name: "No Vendor, 1" })).toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "Notifications" })).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) }));
|
|
|
|
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
|
"/workorders/edit/7?assignVendor=1",
|
|
);
|
|
});
|
|
|
|
it("links every work order in a vendor conflict", async () => {
|
|
apiGet.mockResolvedValue(
|
|
feed(
|
|
section("vendor-conflict", "Vendor Conflict", [
|
|
item({
|
|
id: "vendor-conflict-501-502",
|
|
reason: "vendor-conflict",
|
|
severity: "Medium",
|
|
title: "Acme Plumbing has overlapping appointments",
|
|
count: 2,
|
|
target: { kind: "workOrder", workOrderId: 501, tab: "info" },
|
|
workOrders: [
|
|
{ id: 501, number: "501" },
|
|
{ id: 502, number: "502" },
|
|
],
|
|
}),
|
|
]),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderWithProviders(
|
|
<NotificationSessionProvider>
|
|
<NotificationsPage />
|
|
<LocationProbe />
|
|
</NotificationSessionProvider>,
|
|
{ withAuth: false, route: "/notifications" },
|
|
);
|
|
|
|
const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" });
|
|
expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute(
|
|
"href",
|
|
"/workorders/501",
|
|
);
|
|
|
|
await user.click(within(conflicts).getByRole("link", { name: "WO #502" }));
|
|
|
|
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
|
"/workorders/502",
|
|
);
|
|
const row = within(conflicts).getByRole("listitem");
|
|
expect(row.dataset.unread).toBe("false");
|
|
});
|
|
|
|
it("tells the user when the feed cannot load", async () => {
|
|
apiGet.mockRejectedValue(new Error("boom"));
|
|
renderWithProviders(
|
|
<NotificationSessionProvider>
|
|
<NotificationsPage />
|
|
</NotificationSessionProvider>,
|
|
{ withAuth: false, route: "/notifications" },
|
|
);
|
|
|
|
expect(
|
|
await screen.findByText("Notifications could not be loaded. They will retry automatically."),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|