IKHSAN
EN Read in EnglishID

opendesk

Publik
Branch
main
Commit terakhir
08d9f3328 Oktober 2026

navigation.spec.ts

Buka raw
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: `<p>Internal note ${token}</p>`,
  });
  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<Api>) {
  const customer = await apiAs("customer");
  const read = await raiseTicket(customer);
  const unread = await raiseTicket(customer);
  for (const ticket of [read, unread]) {
    await api.call("frappe.desk.form.assign_to.add", {
      doctype: "OD Ticket",
      name: ticket.name,
      assign_to: JSON.stringify([personas.agent2.email]),
    });
  }
  const mentioned = await raiseTicket(customer);
  const note = `Filter mention ${uid()}`;
  const email = personas.agent2.email;
  await addComment(
    await apiAs("agent"),
    mentioned.name,
    `<span class="mention" data-type="mention" data-id="${email}" data-label="${email}">@${email}</span> ${note}`
  );
  const logs = () =>
    api.list("Notification Log", {
      fields: ["name", "document_name"],
      filters: {
        for_user: email,
        document_name: ["in", [read.name, unread.name, mentioned.name]],
      },
    });
  await expect.poll(async () => (await logs()).length).toBe(3);
  const readLog = (await logs()).find((l) => l.document_name === read.name)!;
  await (await apiAs("agent2")).call(
    "frappe.desk.doctype.notification_log.notification_log.mark_as_read",
    { docname: readLog.name }
  );
  return { read, unread, note };
}

async function dashboard(api: Api) {
  return api.call("opendesk.api.agent_home.agent_home.get_dashboard");
}

function chartBoxes(layout: any[]) {
  return Object.fromEntries(
    layout.map(({ chart, layout: { x, y, w, h } }) => [chart, { x, y, w, h }])
  );
}

async function dropHomeLayout(api: Api) {
  const name = await api.exists("OD Field Layout", { user: personas.agent2.email });
  if (name) await api.delete("OD Field Layout", name);
}

/** Drag `handle` by an offset, then wait for the grid to settle `widget`. */
async function drag(page: Page, widget: Locator, handle: Locator, dx: number, dy: number) {
  const box = (await handle.boundingBox())!;
  const x = box.x + Math.min(40, box.width / 2);
  const y = box.y + Math.min(30, box.height / 2);
  await page.mouse.move(x, y);
  await page.mouse.down();
  await page.mouse.move(x + dx, y + dy, { steps: 15 });
  await page.mouse.up();
  let last = "";
  await expect
    .poll(async () => {
      const style = (await widget.getAttribute("style")) || "";
      const settled = style === last;
      last = style;
      return settled;
    }, { intervals: [300] })
    .toBe(true);
}

/** New docs only reach the search index through scheduled jobs; run them now. */
async function indexForSearch(api: Api, token: string) {
  await runScheduledJob(api, "frappe.search.sqlite_search.build_index_if_not_exists");
  await expect
    .poll(
      async () => {
        await runScheduledJob(api, "frappe.search.sqlite_search.index_docs_in_queue");
        const found = await api
          .call("opendesk.api.search.search", { query: token })
          .catch(() => ({ results: [] }));
        return found.results.length;
      },
      { timeout: 30_000, intervals: [1_000] }
    )
    .toBeGreaterThan(0);
}

// The browser runs in UTC (see playwright.config.ts), so the date is computed in UTC too.
function daysAgo(days: number, timeZone: string) {
  return new Intl.DateTimeFormat("en-CA", { timeZone }).format(Date.now() - days * 86_400_000);
}
byte
14.489
baris
359
Jenis
ts
Commit terakhir
28 Oktober 2026