import type { Locator, Page } from "@playwright/test"; import { runScheduledJob, type Api } from "../helpers/api"; import { raiseTicket } from "../helpers/factories"; import { expect, test, uid, usePersona } from "../helpers/fixtures"; import { TicketList } from "../helpers/list"; import { type PersonaKey, personas } from "../helpers/personas"; import { addComment } from "../helpers/ticket"; usePersona("agent2"); test.describe("home", () => { test.beforeEach(async ({ api }) => { await dropHomeLayout(api); }); test.afterEach(async ({ api }) => { await dropHomeLayout(api); }); test("a dragged and resized widget layout survives a reload and resets", async ({ page, apiAs, }) => { const agent = await apiAs("agent2"); const defaults = chartBoxes((await dashboard(agent)).layout); await page.goto("/opendesk/home"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "Edit" }).click(); const widget = page.locator(".vgl-item", { hasText: "My Tickets" }); await expect(widget).toHaveClass(/vgl-item--resizable/); await drag(page, widget, widget, 0, 300); await drag(page, widget, widget.locator(".vgl-item__resizer"), 200, 100); await page.getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Dashboard saved")).toBeVisible(); const saved = chartBoxes((await dashboard(agent)).layout); expect(saved.agent_tickets).not.toEqual(defaults.agent_tickets); const box = await widget.boundingBox(); await page.reload(); await expect(widget).toBeVisible(); expect(await widget.boundingBox()).toEqual(box); await page.getByRole("button", { name: "Edit" }).click(); await page.getByRole("button", { name: "Reset" }).click(); await page.getByRole("button", { name: "Save" }).click(); await expect .poll(async () => chartBoxes((await dashboard(agent)).layout)) .toEqual(defaults); }); test("dismissing a widget's custom range brings its presets back", async ({ page }) => { await page.goto("/opendesk/home"); const metrics = page.locator(".vgl-item", { hasText: "Avg. Time Metrics" }); await metrics.getByRole("button", { name: "6 Months" }).click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible(); await page.keyboard.press("Escape"); // Regression: the empty range picker used to stay with no way back. await expect(metrics.getByRole("button", { name: "6 Months" })).toBeVisible(); }); }); test("dashboard presets and custom range drive the stats period", async ({ page }) => { await page.goto("/opendesk/dashboard"); const trigger = page.getByText("Last 30 Days"); await trigger.click(); // the site's date, as the dashboard counts it; the browser's UTC date can differ const timeZone = await page.evaluate(() => (window as any).timezone.system); const request = page.waitForRequest( (request) => request.url().includes("get_dashboard_data") && request.postData()!.includes(daysAgo(6, timeZone)) ); await page.getByRole("menuitem", { name: "Last 7 Days" }).click(); await request; await expect(page.getByText("Last 7 Days")).toBeVisible(); await page.getByText("Last 7 Days").click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible(); await page.keyboard.press("Escape"); // Regression: dismissing the calendar left a dead end with no presets. await expect(page.getByText("Last 30 Days")).toBeVisible(); await page.getByText("Last 30 Days").click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); const cells = page.getByRole("grid", { name: "Calendar dates" }).getByRole("gridcell"); await cells.nth(8).click(); await cells.nth(10).click(); await expect(page.getByText(/^\w{3} \d+ to \w{3} \d+$/)).toBeVisible(); }); test.describe("dashboard charts", () => { usePersona("manager"); test("number cards show this period and every chart draws", async ({ page, ticket }) => { expect(ticket.name).toBeTruthy(); await page.goto("/opendesk/dashboard"); const cards = page .locator("div", { has: page.getByText("% SLA Fulfilled", { exact: true }) }) .filter({ has: page.getByText("Avg. Resolution", { exact: true }) }) .last(); const tickets = cards .getByText("Tickets", { exact: true }) .locator("xpath=ancestor::div[contains(@class, 'flex-col')][1]"); await expect(tickets).toContainText(/Tickets\s*\d+/); const chart = page.locator("[_echarts_instance_]"); for (const title of ["Ticket Trend", "Tickets by Team", "Tickets by Priority"]) { const card = page .locator("div", { has: page.getByText(title, { exact: true }) }) .filter({ has: chart }) .last(); await expect(card.locator("[_echarts_instance_] svg")).toBeVisible(); } }); }); test("the command palette finds a ticket and opens it", async ({ page, api, apiAs }) => { const token = `palette${uid()}`; const ticket = await raiseTicket(await apiAs("customer"), `${token} subject`); await indexForSearch(api, token); await new TicketList(page).goto(); // The Desktop Chrome device reports a Windows user agent, so Mod is Control. await page.keyboard.press("Control+k"); const palette = page.getByRole("dialog", { name: "Command Palette" }); await palette.getByRole("combobox").fill(token); await palette.getByRole("option", { name: new RegExp(`${token} subject`) }).click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`)); }); test("the command palette does not open over the settings dialog", async ({ page }) => { await new TicketList(page).goto(); await page.keyboard.press("Control+,"); const settings = page.getByRole("dialog"); await expect(settings).toBeVisible(); await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Command Palette" })).toBeHidden(); await expect(settings).toBeVisible(); }); test("search finds a ticket by text that is only in a comment", async ({ page, api, ticket, }) => { const token = `comment${uid()}`; await api.insert("Comment", { comment_type: "Comment", reference_doctype: "OD Ticket", reference_name: ticket.name, content: `
Internal note ${token}
`, }); await indexForSearch(api, token); await page.goto("/opendesk/search"); const box = page.getByRole("textbox", { name: /Search tickets/ }); await box.fill(token); await box.press("Enter"); await page.getByText(new RegExp(token)).first().click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`)); }); test("a notification opens its ticket and mark all as read clears it", async ({ page, api, apiAs, }) => { const customer = await apiAs("customer"); const first = await raiseTicket(customer); const second = await raiseTicket(customer); for (const ticket of [first, second]) { await api.call("frappe.desk.form.assign_to.add", { doctype: "OD Ticket", name: ticket.name, assign_to: JSON.stringify([personas.agent2.email]), }); } const log = (ticket: { name: string }) => api.list("Notification Log", { fields: ["name", "read"], filters: { for_user: personas.agent2.email, document_name: ticket.name }, }); await expect.poll(async () => (await log(second)).length).toBe(1); await page.goto("/opendesk/tickets"); const panel = page.locator(".notifications-panel"); await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click(); await panel.getByText(first.subject).click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${first.name}`)); await expect.poll(async () => (await log(first))[0].read).toBe(1); await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click(); await panel.getByRole("button", { name: "Mark all as read" }).click(); await expect.poll(async () => (await log(second))[0].read).toBe(1); }); test("notification filters narrow the feed by read state and category and reset on close", async ({ page, api, apiAs, }) => { const { read, unread, note } = await seedFilterNotifications(api, apiAs); await page.goto("/opendesk/tickets"); const bell = page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }); const panel = page.locator(".notifications-panel"); // the open menu makes the page aria-hidden, so reach the trigger by CSS const categories = panel.locator("button", { hasText: /categories|Mentions/ }); await bell.click(); await expect(panel.getByText(read.subject)).toBeVisible(); await panel.getByRole("radio", { name: "Unread" }).click(); await expect(panel.getByText(read.subject)).toHaveCount(0); await expect(panel.getByText(unread.subject)).toBeVisible(); // clicking the trigger again closes only the menu, not the panel await categories.click(); await categories.click({ force: true }); await expect(page.getByRole("menu")).toHaveCount(0); await expect(panel).toBeVisible(); await categories.click(); await page.getByRole("menuitem", { name: "Mentions" }).click(); await expect(panel.getByText(note)).toBeVisible(); await expect(panel.getByText(unread.subject)).toHaveCount(0); await panel.getByRole("button", { name: "Close" }).click(); await bell.click(); await expect(categories).toHaveText("All categories"); await expect(panel.getByRole("radio", { name: "All" })).toHaveAttribute("aria-checked", "true"); await expect(panel.getByText(read.subject)).toBeVisible(); }); test("the mobile notifications page filters by read state and category", async ({ page, api, apiAs, }) => { const { read, unread, note } = await seedFilterNotifications(api, apiAs); await page.setViewportSize({ width: 390, height: 760 }); await page.goto("/opendesk/notifications"); await expect(page.getByText(read.subject)).toBeVisible(); await page.getByRole("radio", { name: "Unread" }).click(); await expect(page.getByText(read.subject)).toHaveCount(0); await expect(page.getByText(unread.subject)).toBeVisible(); await page.getByRole("button", { name: "All categories" }).click(); await page.getByRole("menuitem", { name: "Assignments" }).click(); await expect(page.getByText(unread.subject)).toBeVisible(); await expect(page.getByText(note)).toHaveCount(0); }); test("the shortcuts modal opens from the keyboard", async ({ page }) => { await new TicketList(page).goto(); await page.keyboard.press("Control+/"); const modal = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); await expect(modal.getByRole("heading", { name: "Ticket Management" })).toBeVisible(); await expect(modal.getByText("Open reply box")).toBeVisible(); }); /** Give agent2 a read and an unread assignment and an unread mention, each on * its own ticket, so a filter's effect shows up as rows coming and going. */ async function seedFilterNotifications(api: Api, apiAs: (key: PersonaKey) => Promise