1import type { Page } from "@playwright/test";
2import type { Api } from "../../../helpers/api";
3import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
4import { personas } from "../../../helpers/personas";
5import {
6 composer,
7 createTeam,
8 openTicket,
9 recordCalls,
10 setOpendeskSetting,
11} from "../../../helpers/ticket";
12
13usePersona("agent");
14
15test("sidebar fields save once per change and persist", async ({ page, api, ticket }) => {
16 const team = `E2E Team ${uid()}`;
17 await createTeam(api, team, [personas.agent.email]);
18 const setValue = recordCalls(page, "frappe.client.set_value");
19 await openTicket(page, ticket.name);
20
21 const pick = async (placeholder: string, option: string) => {
22 const field = page.getByRole("combobox", { name: placeholder });
23 await field.click();
24 await field.fill(option);
25 await page.getByRole("option", { name: option, exact: true }).click();
26 };
27 await pick("Set Priority...", "High");
28 await expect.poll(() => setValue.length).toBe(1);
29 await pick("Set Ticket Type...", "Bug");
30 await expect.poll(() => setValue.length).toBe(2);
31 await pick("Set Team...", team);
32 await expect.poll(() => setValue.length).toBe(3);
33
34 // clearing is its own save; picking after it must not send a second one
35 await page.getByRole("combobox", { name: "Set Priority..." }).hover();
36 await page.locator('[data-slot=trigger]:has([placeholder="Set Priority..."]) [data-slot=clear]').click();
37 await expect.poll(() => setValue.length).toBe(4);
38 await pick("Set Priority...", "Low");
39 await expect.poll(() => setValue.length).toBe(5);
40 await page.waitForLoadState("networkidle");
41 expect(setValue).toHaveLength(5);
42
43 await page.reload();
44 await expect(page.getByRole("combobox", { name: "Set Priority..." })).toHaveValue("Low");
45 await expect(page.getByRole("combobox", { name: "Set Ticket Type..." })).toHaveValue("Bug");
46 await expect(page.getByRole("combobox", { name: "Set Team..." })).toHaveValue(team);
47 const saved = await api.get("OD Ticket", ticket.name);
48 expect(saved).toMatchObject({ priority: "Low", ticket_type: "Bug", agent_group: team });
49});
50
51test("setting a team that has no members does not error", async ({ page, ticket }) => {
52 const errors: string[] = [];
53 page.on("pageerror", (error) => errors.push(error.message));
54 await openTicket(page, ticket.name);
55 const field = page.getByRole("combobox", { name: "Set Team..." });
56 await field.click();
57 await field.fill("Billing");
58 await page.getByRole("option", { name: "Billing", exact: true }).click();
59 await page.waitForLoadState("networkidle");
60 expect(errors).toEqual([]);
61});
62
63test("tags can be created with a colour, added and removed", async ({ page, api, ticket }) => {
64 const tag = `e2e-${uid()}`;
65 const tagsOnTicket = async () =>
66 ((await api.get("OD Ticket", ticket.name))._user_tags || "")
67 .split(",")
68 .filter(Boolean)
69 .sort();
70 // a customer's first ticket is auto-tagged; start untagged
71 await api.call("opendesk.api.tags.update_tags", {
72 doctype: "OD Ticket",
73 name: ticket.name,
74 removed: await tagsOnTicket(),
75 });
76 await openTicket(page, ticket.name);
77
78 await page.getByRole("button", { name: "+ Add" }).click();
79 await page.getByPlaceholder("Search or create tags").fill(tag);
80 await page.getByRole("option", { name: `Create "${tag}"` }).click();
81 await page.getByRole("button", { name: "Green" }).click();
82 await expect.poll(tagsOnTicket).toEqual([tag]);
83 expect((await api.get("Tag", tag)).color).toBe("Green");
84
85 await page.getByTitle(tag).click();
86 await page.getByRole("option", { name: tag }).click();
87 await page.keyboard.press("Escape");
88 await expect.poll(tagsOnTicket).toEqual([]);
89 await expect(page.getByTitle(tag)).toHaveCount(0);
90});
91
92test.fixme("tag activity reads '&', not '&'", async ({ page, api, ticket }) => {
93 // log_tag_activity joins with " & ", saving sanitises it to & and the log row renders text
94 await api.call("opendesk.api.tags.update_tags", {
95 doctype: "OD Ticket",
96 name: ticket.name,
97 added: [{ name: `e2e-${uid()}` }],
98 removed: ["First Ticket"],
99 });
100 await openTicket(page, ticket.name);
101 await expect(page.getByText(/added tag .* & removed tag/)).toBeVisible();
102});
103
104test("agents are assigned and unassigned from the sidebar", async ({ page, api, ticket }) => {
105 const assignees = async () =>
106 (
107 await api.list("ToDo", {
108 fields: ["allocated_to"],
109 filters: { reference_type: "OD Ticket", reference_name: ticket.name, status: "Open" },
110 })
111 ).map((todo) => todo.allocated_to);
112 await openTicket(page, ticket.name);
113
114 await page.getByRole("button", { name: "Set Assignee..." }).click();
115 // Search first: the list shows only one page of agents.
116 await page.getByPlaceholder("Search agents...").fill("Bela");
117 await page.getByRole("button", { name: "Bela E2E" }).click();
118 await page.keyboard.press("Escape");
119 await expect.poll(assignees).toEqual([personas.agent2.email]);
120
121 await page.locator("body").click();
122 await page.keyboard.press("a");
123 // the popover is portaled after the sidebar, whose trigger now shows Bela too
124 await page.getByRole("button", { name: "Bela E2E" }).last().click();
125 await page.keyboard.press("Escape");
126 await expect.poll(assignees).toEqual([]);
127 await expect(page.getByRole("button", { name: "Set Assignee..." })).toBeVisible();
128});
129
130test.describe("assign within team", () => {
131 let restore: Array<() => Promise<unknown>> = [];
132 test.beforeAll(async ({ api }) => {
133 restore = [
134 await setOpendeskSetting(api, "restrict_tickets_by_agent_group", 1),
135 await setOpendeskSetting(api, "assign_within_team", 1),
136 ];
137 });
138 test.afterAll(async () => {
139 for (const undo of restore.reverse()) await undo();
140 });
141
142 test("only the ticket team's agents are offered", async ({ page, api, ticket }) => {
143 const team = `E2E Team ${uid()}`;
144 await createTeam(api, team, [personas.agent.email, personas.manager.email]);
145 await api.update("OD Ticket", ticket.name, { agent_group: team });
146 await openTicket(page, ticket.name);
147
148 // the team's rule may already have auto-assigned someone, so open via the shortcut
149 await page.locator("body").click();
150 await page.keyboard.press("a");
151 await expect(page.getByRole("button", { name: "Mona E2E" }).last()).toBeVisible();
152 await expect(page.getByRole("button", { name: "Bela E2E" })).toHaveCount(0);
153 });
154});
155
156test("status changes from the menu and the s shortcut, and the subject is renamed", async ({
157 page,
158 api,
159 ticket,
160}) => {
161 const status = async () => (await api.get("OD Ticket", ticket.name)).status;
162 await openTicket(page, ticket.name);
163 const header = page.getByRole("banner");
164
165 await header.getByRole("button", { name: "Open" }).click();
166 await page.getByRole("menuitem", { name: "Replied" }).click();
167 await expect.poll(status).toBe("Replied");
168 await expect(header.getByRole("button", { name: "Replied" })).toBeVisible();
169
170 await page.locator("body").click();
171 await page.keyboard.press("s");
172 await page.getByRole("menuitem", { name: "Resolved" }).click();
173 await expect.poll(status).toBe("Resolved");
174
175 const subject = `Renamed ${uid()}`;
176 await header.getByRole("button", { name: ticket.subject }).click();
177 const dialog = page.getByRole("dialog", { name: "Rename Subject" });
178 await dialog.getByRole("textbox").fill(subject);
179 await dialog.getByRole("button", { name: /^Rename/ }).click();
180 await expect(header.getByRole("button", { name: subject })).toBeVisible();
181 expect((await api.get("OD Ticket", ticket.name)).subject).toBe(subject);
182});
183
184/** A personal saved reply that stages field, team and tag actions. */
185async function stagingReply(apiAs: (key: "agent") => Promise<Api>, tag: string) {
186 return (await apiAs("agent")).insert("OD Saved Reply", {
187 title: `Escalate ${uid()}`,
188 message: "<p>Thanks for writing in</p>",
189 scope: "Personal",
190 actions: JSON.stringify([
191 { action_type: "Set Priority", value: "High" },
192 { action_type: "Set Ticket Type", value: "Question" },
193 { action_type: "Set Team", value: "Product Experts" },
194 { action_type: "Add Tag", value: tag },
195 ]),
196 });
197}
198
199async function applySavedReply(page: Page, title: string) {
200 await page.getByRole("button", { name: "Reply", exact: true }).click();
201 await page.locator("[data-slot=root]:has(input[type=file]) + *").first().click();
202 await page.getByRole("dialog").getByText(title).click();
203 await expect(composer(page)).toContainText("Thanks for writing in");
204 await page.getByRole("button", { name: "Show actions" }).click();
205}
206
207test("saved reply chips can be repointed and apply after sending", async ({ page, api, apiAs, ticket }) => {
208 const team = `E2E Team ${uid()}`;
209 const tag = `e2e-${uid()}`;
210 await createTeam(api, team, [personas.agent.email]);
211 const reply = await stagingReply(apiAs, tag);
212 await openTicket(page, ticket.name);
213 await applySavedReply(page, reply.title);
214
215 await page.getByRole("button", { name: /^Type / }).click();
216 for (const name of ["Bug", "Incident", "Question"]) {
217 await expect(page.getByRole("listbox").getByRole("option", { name })).toBeVisible();
218 }
219 await page.getByRole("listbox").getByRole("option", { name: "Bug" }).click();
220 await expect(page.getByRole("button", { name: /^Type / })).toContainText("Bug");
221
222 await page.getByRole("button", { name: /^Team / }).click();
223 await page.getByRole("combobox", { name: "Select team" }).fill(team);
224 await page.getByRole("listbox").getByRole("option", { name: team }).click();
225 await expect(page.getByRole("button", { name: /^Team / })).toContainText(team);
226
227 await page.getByRole("button", { name: /^Send/ }).click();
228 await expect
229 .poll(async () => {
230 const saved = await api.get("OD Ticket", ticket.name);
231 return [saved.priority, saved.ticket_type, saved.agent_group, saved._user_tags?.includes(tag)];
232 })
233 .toEqual(["High", "Bug", team, true]);
234});
235
236test.fixme("a repointed chip reopens with the full list", async ({ page, apiAs, ticket }) => {
237 // the picker keeps the picked label as its search text, so only that option shows
238 const reply = await stagingReply(apiAs, `e2e-${uid()}`);
239 await openTicket(page, ticket.name);
240 await applySavedReply(page, reply.title);
241
242 await page.getByRole("button", { name: /^Type / }).click();
243 await page.getByRole("listbox").getByRole("option", { name: "Bug" }).click();
244 await page.getByRole("button", { name: /^Type / }).click();
245 for (const name of ["Bug", "Incident", "Question"]) {
246 await expect(page.getByRole("listbox").getByRole("option", { name })).toBeVisible();
247 }
248});
249