1import type { Locator, Page } from "@playwright/test";
2import { expect, test, uid, usePersona } from "../../helpers/fixtures";
3import { personas } from "../../helpers/personas";
4import { openSettings } from "../../helpers/settings";
5
6const REPLY = "OD Saved Reply";
7
8usePersona("admin");
9
10let titles: string[];
11
12test.beforeEach(() => {
13 titles = [];
14});
15
16test.afterEach(async ({ api }) => {
17 for (const title of titles) {
18 for (const name of [await api.exists(REPLY, { title }), await api.exists(REPLY, { title: `${title} (Copy)` })]) {
19 if (name) await api.delete(REPLY, name);
20 }
21 }
22});
23
24test("a new reply takes field placeholders and actions, and refuses an empty action value", async ({ page, api }) => {
25 const title = track(`E2E Actions ${uid()}`);
26 await page.goto("/opendesk/tickets");
27 const dialog = await openSettings(page, "Saved Replies");
28 await dialog.getByRole("button", { name: "New" }).click();
29 await dialog.getByRole("textbox", { name: /^Name/ }).fill(title);
30
31 const editor = dialog.locator("[contenteditable=true]");
32 await editor.click();
33 await page.keyboard.type("About {{subj");
34 await expect(page.getByRole("button", { name: "Subject", exact: true })).toBeVisible();
35 await page.keyboard.press("Enter");
36 await page.keyboard.type("we are on it");
37 await expect(editor).toHaveText("About {{ subject }} we are on it");
38
39 await addAction(page, dialog, "Set priority");
40 await actionRow(dialog, "Set priority").getByRole("combobox").click();
41 await page.getByRole("option", { name: "High", exact: true }).click();
42 await addAction(page, dialog, "Assign to me");
43 // Only one assignment action is allowed per reply.
44 await dialog.getByRole("button", { name: "Add action" }).click();
45 await expect(page.getByRole("menuitem", { name: "Assign agent" })).toHaveCount(0);
46 await page.getByRole("menuitem", { name: "Add comment" }).click();
47
48 await dialog.getByRole("button", { name: "Save", exact: true }).click();
49 await expect(dialog.getByText("Values can't be empty")).toBeVisible();
50 expect(await api.exists(REPLY, { title })).toBeFalsy();
51
52 await actionRow(dialog, "Add comment").getByRole("button", { name: "Row actions" }).click();
53 await page.getByRole("menuitem", { name: "Delete" }).click();
54 await dialog.getByRole("button", { name: "Save", exact: true }).click();
55 await expect.poll(() => api.exists(REPLY, { title })).toBeTruthy();
56
57 const saved = await api.get(REPLY, (await api.exists(REPLY, { title }))!);
58 expect(saved.message).toContain("{{ subject }}");
59 expect(actionsOf(saved)).toEqual([
60 { action_type: "Set Priority", value: "High" },
61 { action_type: "Assign to Me", value: "" },
62 ]);
63});
64
65test("clicking a field suggestion inserts its placeholder", async ({ page }) => {
66 await page.goto("/opendesk/tickets");
67 const dialog = await openSettings(page, "Saved Replies");
68 await dialog.getByRole("button", { name: "New" }).click();
69 await dialog.locator("[contenteditable=true]").click();
70 await page.keyboard.type("About {{subj");
71 await page.getByRole("button", { name: "Subject", exact: true }).click();
72 await expect(dialog.locator("[contenteditable=true]")).toHaveText("About {{ subject }} ");
73});
74
75test("the preview renders a reply's placeholders against a chosen ticket", async ({ page, api, ticket }) => {
76 const title = track(`E2E Preview ${uid()}`);
77 await api.insert(REPLY, { title, message: "<p>Re: {{ subject }}</p>", scope: "Personal" });
78
79 await page.goto("/opendesk/tickets");
80 const dialog = await openSettings(page, "Saved Replies");
81 await openReply(dialog, title);
82 await dialog.getByRole("button", { name: "Preview", exact: true }).first().click();
83
84 const preview = page.getByRole("dialog", { name: "Preview" });
85 await preview.getByRole("button", { name: "Select ticket to preview" }).click();
86 await page.getByRole("combobox", { name: "Search ticket" }).fill(ticket.subject);
87 await page.getByRole("option", { name: new RegExp(ticket.subject) }).click();
88 await expect(preview.getByText(`Re: ${ticket.subject}`)).toBeVisible();
89});
90
91test("the list searches, filters by scope and duplicates a reply", async ({ page, api }) => {
92 const title = track(`E2E Listed ${uid()}`);
93 await api.insert(REPLY, { title, message: "<p>Listed</p>", scope: "Global" });
94
95 await page.goto("/opendesk/tickets");
96 const dialog = await openSettings(page, "Saved Replies");
97 // The list opens on Personal replies, so a global one needs the scope filter.
98 await dialog.getByPlaceholder("Search").fill(title);
99 await expect(dialog.getByText("No saved replies found")).toBeVisible();
100 await dialog.getByRole("button", { name: "Personal" }).click();
101 await page.getByRole("menuitem", { name: "Global" }).click();
102 await expect(replyRow(dialog, title)).toBeVisible();
103 await dialog.getByRole("button", { name: "Global" }).click();
104 await page.getByRole("menuitem", { name: "All" }).click();
105 await expect(replyRow(dialog, title)).toBeVisible();
106 await expect(page.getByRole("menu")).toHaveCount(0);
107
108 await replyRow(dialog, title).getByRole("button").last().click();
109 await page.getByRole("menuitem", { name: "Duplicate" }).click();
110 const duplicate = page.getByRole("dialog", { name: "Duplicate Saved reply" });
111 await expect(duplicate.getByRole("textbox")).toHaveValue(`${title} (Copy)`);
112 await duplicate.getByRole("button", { name: "Duplicate" }).click();
113 await expect.poll(() => api.exists(REPLY, { title: `${title} (Copy)` })).toBeTruthy();
114 expect(await api.exists(REPLY, { title })).toBeTruthy();
115});
116
117test("a reply is renamed and restricted to a team only once a team is picked", async ({ page, api }) => {
118 const title = track(`E2E Scoped ${uid()}`);
119 const renamed = track(`${title} Renamed`);
120 const team = `E2E Reply Team ${uid()}`;
121 await api.insert("OD Team", { team_name: team, users: [{ user: personas.agent.email }] });
122 await api.insert(REPLY, { title, message: "<p>Scoped</p>", scope: "Personal" });
123 try {
124 await page.goto("/opendesk/tickets");
125 const dialog = await openSettings(page, "Saved Replies");
126 await openReply(dialog, title);
127 await dialog.getByRole("textbox", { name: /^Name/ }).fill(renamed);
128 await dialog.getByRole("combobox", { name: /^Visibility/ }).click();
129 await page.getByRole("option", { name: "Team", exact: true }).click();
130 await dialog.getByRole("button", { name: "Save", exact: true }).click();
131 await expect(dialog.getByText("At least one team is required")).toBeVisible();
132
133 await dialog.getByRole("button", { name: "Select teams" }).click();
134 await page.getByRole("option", { name: team }).click();
135 await page.keyboard.press("Escape");
136 await dialog.getByRole("button", { name: "Save", exact: true }).click();
137
138 await expect.poll(() => api.exists(REPLY, { title: renamed })).toBeTruthy();
139 const saved = await api.get(REPLY, (await api.exists(REPLY, { title: renamed }))!);
140 expect(saved.scope).toBe("Team");
141 expect(saved.teams.map((row: { team: string }) => row.team)).toEqual([team]);
142 } finally {
143 const name = await api.exists(REPLY, { title: renamed });
144 if (name) await api.delete(REPLY, name);
145 await api.delete("OD Team", team);
146 }
147});
148
149function track(title: string) {
150 titles.push(title);
151 return title;
152}
153
154async function openReply(dialog: Locator, title: string) {
155 await dialog.getByPlaceholder("Search").fill(title);
156 await replyRow(dialog, title).getByText(title, { exact: true }).click();
157}
158
159function replyRow(dialog: Locator, title: string) {
160 return dialog.locator("div.grid-cols-12").filter({ has: dialog.page().getByText(title, { exact: true }) });
161}
162
163async function addAction(page: Page, dialog: Locator, label: string) {
164 await dialog.getByRole("button", { name: "Add action" }).click();
165 await page.getByRole("menuitem", { name: label }).click();
166}
167
168function actionRow(dialog: Locator, label: string) {
169 return dialog.locator("div.grid.items-start").filter({ has: dialog.page().getByRole("button", { name: label }) });
170}
171
172function actionsOf(reply: Record<string, any>) {
173 const actions = typeof reply.actions === "string" ? JSON.parse(reply.actions) : reply.actions;
174 return actions.map(({ action_type, value }: { action_type: string; value: string }) => ({
175 action_type,
176 value: value ?? "",
177 }));
178}
179