1import type { Api } from "../../../helpers/api";
2import { raiseTicket } from "../../../helpers/factories";
3import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
4import {
5 TicketList,
6 resetDefaultTicketView,
7} from "../../../helpers/list";
8import { personas } from "../../../helpers/personas";
9
10usePersona("manager");
11
12test.beforeEach(async ({ api }) => {
13 await resetDefaultTicketView(api, personas.manager.email);
14});
15
16test.afterEach(async ({ api }) => {
17 await resetDefaultTicketView(api, personas.manager.email);
18});
19
20test("filters and quick filters narrow the list and clear back", async ({
21 page,
22 api,
23 apiAs,
24}) => {
25 const id = uid();
26 const customer = await apiAs("customer");
27 const high = await raiseTicket(customer, `${id} high`);
28 const low = await raiseTicket(customer, `${id} low`);
29 await api.update("OD Ticket", high.name, { priority: "High" });
30 await api.update("OD Ticket", low.name, { priority: "Low" });
31 const org = `E2E Org ${id}`;
32 await api.insert("OD Customer", { customer_name: org });
33 await api.update("OD Ticket", low.name, { customer: org });
34
35 const list = new TicketList(page);
36 await list.goto();
37 await list.searchSubject(id);
38 await expect(list.rows()).toHaveCount(2);
39
40 await list.addFilter("Priority", "High");
41 await expect(list.rows()).toHaveCount(1);
42 await expect(list.row(`${id} high`)).toBeVisible();
43
44 const popover = page.getByRole("dialog", { name: "Filter" });
45 await popover
46 .locator(".group", { hasText: "Priority is High" })
47 .getByRole("button", { name: "Remove filter" })
48 .click();
49 await expect(list.rows()).toHaveCount(2);
50 await page.keyboard.press("Escape");
51
52 await page.getByRole("combobox", { name: "Customer" }).click();
53 await page.getByRole("option", { name: org }).click();
54 await expect(list.rows()).toHaveCount(1);
55 await expect(list.row(`${id} low`)).toBeVisible();
56
57 await page.getByRole("button", { name: "Filter", exact: true }).click();
58 await popover.getByRole("button", { name: "Clear all" }).click();
59 await expect(page.getByRole("textbox", { name: "Subject" })).toHaveValue("");
60 await expect(list.rows().nth(2)).toBeVisible();
61});
62
63test("a date quick filter keeps its value and narrows the list", async ({
64 page,
65 api,
66 apiAs,
67}) => {
68 const id = uid();
69 const customer = await apiAs("customer");
70 await raiseTicket(customer, `${id} today`);
71 const old = await raiseTicket(customer, `${id} old`);
72 await api.update("OD Ticket", old.name, { opening_date: "2020-01-15" });
73 const setter = await api.insert("Property Setter", {
74 doctype_or_field: "DocField",
75 doc_type: "OD Ticket",
76 field_name: "opening_date",
77 property: "in_standard_filter",
78 property_type: "Check",
79 value: "1",
80 });
81 try {
82 // The meta cache is cleared before the setter commits, so a concurrent read can re-cache it stale.
83 await expect
84 .poll(async () => {
85 const filters = await api.call("opendesk.api.doc.get_quick_filters", { doctype: "OD Ticket" });
86 const found = filters.some((filter: { name: string }) => filter.name === "opening_date");
87 if (!found) await api.update("Property Setter", setter.name, { value: "1" });
88 return found;
89 })
90 .toBe(true);
91 const list = new TicketList(page);
92 await list.goto();
93 await list.searchSubject(id);
94 await expect(list.rows()).toHaveCount(2);
95
96 const dateFilter = page.getByRole("combobox", { name: "Opening Date" });
97 await dateFilter.click();
98 await page.getByRole("gridcell", { name: /\(Today\)/ }).click();
99 await expect(list.rows()).toHaveCount(1);
100 await expect(list.row(`${id} today`)).toBeVisible();
101 await expect(dateFilter).not.toHaveValue("");
102 } finally {
103 await api.delete("Property Setter", setter.name);
104 }
105});
106
107test("sort by a picked field, flip direction and clear", async ({ page, apiAs }) => {
108 const id = uid();
109 const customer = await apiAs("customer");
110 for (const word of ["bravo", "alpha", "charlie"]) {
111 await raiseTicket(customer, `${id} ${word}`);
112 }
113 const list = new TicketList(page);
114 await list.goto();
115 await list.searchSubject(id);
116 await expect(list.rows()).toHaveCount(3);
117 const order = async () =>
118 (await list.rows().allInnerTexts()).map((text) =>
119 ["alpha", "bravo", "charlie"].find((word) => text.includes(`${id} ${word}`))
120 );
121
122 await page.getByRole("button", { name: "Last Modified" }).click();
123 const sortPopover = page.getByRole("dialog", { name: "Last Modified" });
124 await sortPopover.getByRole("button", { name: "Last Modified" }).click();
125 await page.getByRole("option", { name: "Subject", exact: true }).click();
126 await expect.poll(order).toEqual(["charlie", "bravo", "alpha"]);
127
128 await page.getByRole("dialog", { name: "Subject" }).locator("button").first().click();
129 await expect.poll(order).toEqual(["alpha", "bravo", "charlie"]);
130
131 await page.getByRole("button", { name: "Clear Sort" }).click();
132 await page.keyboard.press("Escape");
133 await expect(page.getByRole("button", { name: "Sort", exact: true })).toBeVisible();
134});
135
136test("an added datetime column renders relative time and can be removed", async ({
137 page,
138 apiAs,
139}) => {
140 const subject = `${uid()} columns`;
141 await raiseTicket(await apiAs("customer"), subject);
142 const list = new TicketList(page);
143 await list.goto();
144 await list.searchSubject(subject);
145 await expect(list.rows()).toHaveCount(1);
146 const row = list.row(subject);
147 await expect(row).not.toContainText(/ago.*ago/);
148
149 await page.getByRole("button", { name: "Columns" }).click();
150 await page.getByRole("button", { name: "Add Column" }).click();
151 await page.getByPlaceholder("Select option").fill("Last Modified");
152 await page.getByRole("option", { name: "Last Modified", exact: true }).click();
153 await page.keyboard.press("Escape");
154 // Regression: the column was saved with type "option" and showed the raw value.
155 await expect(row).toContainText(/ago.*ago/);
156 await expect(row).not.toContainText(/\d{4}-\d{2}-\d{2}/);
157
158 await page.getByRole("button", { name: "Columns" }).click();
159 await page
160 .getByRole("dialog", { name: "Columns" })
161 .locator(".cursor-grab", { hasText: "Last Modified" })
162 .getByRole("button")
163 .last()
164 .click();
165 await expect(row).not.toContainText(/ago.*ago/);
166});
167
168test("page length and load more page through the list", async ({ page, apiAs }) => {
169 const id = uid();
170 const customer = await apiAs("customer");
171 for (let index = 0; index < 21; index++) {
172 await raiseTicket(customer, `${id} page ${index}`);
173 }
174 const list = new TicketList(page);
175 await list.goto();
176 await list.searchSubject(id);
177 await expect(list.rows()).toHaveCount(20);
178
179 await page.getByRole("radio", { name: "50" }).click();
180 await expect(list.rows()).toHaveCount(21);
181
182 await page.getByRole("radio", { name: "20" }).click();
183 await expect(list.rows()).toHaveCount(20);
184 await page.getByRole("button", { name: "Load More" }).click();
185 await expect(list.rows()).toHaveCount(21);
186});
187
188test("clicking an assignee avatar filters the list by that agent", async ({
189 page,
190 api,
191 apiAs,
192}) => {
193 const id = uid();
194 const customer = await apiAs("customer");
195 const { agent, agent2 } = personas;
196 const assignments = {
197 solo: [agent.email],
198 other: [agent2.email],
199 both: [agent.email, agent2.email],
200 };
201 for (const [label, users] of Object.entries(assignments)) {
202 const ticket = await raiseTicket(customer, `${id} ${label}`);
203 await assign(api, ticket.name, users);
204 }
205 const list = new TicketList(page);
206 await list.goto();
207 await list.searchSubject(id);
208 await expect(list.rows()).toHaveCount(3);
209
210 // Regression: a single assignee arrives as a string and the click did nothing.
211 await list.row(`${id} solo`).locator(`.user-avatar[data-name="${agent.email}"]`).click();
212 await expect(list.rows()).toHaveCount(2);
213 await expect(list.row(`${id} other`)).toHaveCount(0);
214
215 // the avatar filter is auto saved to the default view, so a reload would keep it
216 await list.clearFilters();
217 await list.searchSubject(id);
218 await expect(list.rows()).toHaveCount(3);
219 await list.row(`${id} both`).locator(`.user-avatar[data-name="${agent2.email}"]`).click();
220 await expect(list.rows()).toHaveCount(2);
221 await expect(list.row(`${id} solo`)).toHaveCount(0);
222});
223
224test("bulk edit, assign and reply act on every selected ticket", async ({
225 page,
226 api,
227 apiAs,
228}) => {
229 const id = uid();
230 const customer = await apiAs("customer");
231 const first = await raiseTicket(customer, `${id} first`);
232 const second = await raiseTicket(customer, `${id} second`);
233 const names = [first.name, second.name];
234 const list = new TicketList(page);
235 await list.goto();
236 await list.searchSubject(id);
237 await expect(list.rows()).toHaveCount(2);
238
239 await list.select(`${id} first`, `${id} second`);
240 await list.selectionBar().getByRole("button", { name: "Assign" }).click();
241 const assignDialog = page.getByRole("dialog", { name: "Assign" });
242 await assignDialog.getByRole("button", { name: "Select agents" }).click();
243 const picker = page.getByRole("dialog", { name: "Select agents" });
244 await picker.getByRole("textbox").fill(personas.agent2.firstName);
245 await picker.getByRole("button", { name: new RegExp(personas.agent2.firstName) }).click();
246 await page.keyboard.press("Escape");
247 await assignDialog.getByRole("button", { name: "Assign 2 tickets" }).click();
248 await expect(page.getByText("Assigned 2 ticket(s)")).toHaveCount(1);
249 for (const name of names) {
250 await expect.poll(() => assignees(api, name)).toContain(personas.agent2.email);
251 }
252
253 await list.select(`${id} first`, `${id} second`);
254 await list.selectionMenu("Edit");
255 const editDialog = page.getByRole("dialog", { name: "Edit" });
256 await editDialog.getByRole("combobox").click();
257 await page.getByRole("option", { name: "Priority", exact: true }).click();
258 await editDialog.getByRole("combobox").nth(1).click();
259 await page.getByRole("option", { name: "Urgent" }).click();
260 await editDialog.getByRole("button", { name: "Update 2 tickets" }).click();
261 await expect(page.getByText("Updated 2 tickets")).toHaveCount(1);
262 for (const name of names) {
263 await expect.poll(async () => (await api.get("OD Ticket", name)).priority).toBe("Urgent");
264 }
265
266 await list.select(`${id} first`, `${id} second`);
267 await list.selectionBar().getByRole("button", { name: "Reply" }).click();
268 const replyDialog = page.getByRole("dialog");
269 await replyDialog.locator("[contenteditable=true]").fill(`${id} bulk reply`);
270 await replyDialog.getByRole("button", { name: /Send|Reply/ }).last().click();
271 for (const name of names) {
272 await expect.poll(() => replyCount(api, name, id)).toBe(1);
273 }
274});
275
276test("selected tickets export as CSV", async ({ page, apiAs }) => {
277 const subject = `${uid()} export`;
278 await raiseTicket(await apiAs("customer"), subject);
279 const list = new TicketList(page);
280 await list.goto();
281 await list.searchSubject(subject);
282 await list.select(subject);
283
284 await list.selectionMenu("Export");
285 const dialog = page.getByRole("dialog");
286 await dialog.getByRole("combobox").click();
287 await page.getByRole("option", { name: "CSV" }).click();
288 const download = page.waitForEvent("download");
289 await dialog.getByRole("button", { name: "Download" }).click();
290 const file = await download;
291 expect(file.suggestedFilename()).toMatch(/\.csv$/);
292 const content = await (await file.createReadStream()).toArray();
293 expect(Buffer.concat(content).toString()).toContain(subject);
294});
295
296async function assign(api: Api, name: string, users: string[]) {
297 await api.call("frappe.desk.form.assign_to.add", {
298 doctype: "OD Ticket",
299 name,
300 assign_to: JSON.stringify(users),
301 });
302}
303
304async function assignees(api: Api, name: string) {
305 const todos = await api.list("ToDo", {
306 fields: ["allocated_to"],
307 filters: { reference_type: "OD Ticket", reference_name: name, status: "Open" },
308 });
309 return todos.map((todo) => todo.allocated_to);
310}
311
312async function replyCount(api: Api, name: string, text: string) {
313 const replies = await api.list("Communication", {
314 fields: ["content"],
315 filters: { reference_doctype: "OD Ticket", reference_name: name, sent_or_received: "Sent" },
316 });
317 return replies.filter((reply) => reply.content.includes(text)).length;
318}
319