designer.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { expect, test } from '@playwright/test'
  2. import { emailTemplateNames, forgetEmailTemplates } from '../../support/db'
  3. import { settle } from '../../support/hydration'
  4. import {
  5. openPreset,
  6. openSubjectAndTheme,
  7. preview,
  8. railBlock,
  9. railOrder,
  10. saveDesign,
  11. subjectField,
  12. } from '../../support/email-designer'
  13. /**
  14. * The email designer, from the gallery to a saved template.
  15. *
  16. * Every mail a workshop sends is built from one of these, so the failure
  17. * modes are worth naming: a block hidden in the designer that still prints in
  18. * the mail, a misspelt tag saved and then sent to a customer as
  19. * "{custmer_name}", or a template saved under a name that already exists and
  20. * quietly replacing the other one.
  21. *
  22. * The preview is a real iframe of the rendered mail, which is why it can be
  23. * asserted against at all: what the frame holds is what the mail client gets.
  24. */
  25. test.describe.configure({ mode: 'serial' })
  26. const stamp = Date.now()
  27. const TEMPLATE = `E2E invoice mail ${stamp}`
  28. const SECOND = `E2E second mail ${stamp}`
  29. /** A sentence nothing else in the app says, to find in the preview and the mail. */
  30. const SENTENCE = `Your invoice is ready, ${stamp}`
  31. test.afterAll(async () => {
  32. // No test's design is left sending the workshop's mail.
  33. await forgetEmailTemplates('E2E ')
  34. })
  35. test.describe('the gallery', () => {
  36. test('offers every kind of mail, each on its built-in template', async ({ page }) => {
  37. await page.goto('/settings/email-templates')
  38. await settle(page)
  39. // The kinds come from EMAIL_KINDS; the page groups them, and each group
  40. // names the kinds under it.
  41. for (const kind of [
  42. 'Invoice sent',
  43. 'Quote sent',
  44. 'Inspection report sent',
  45. 'Customer message',
  46. 'Portal sign-in',
  47. ]) {
  48. await expect(
  49. page.getByRole('heading', { name: kind, exact: true }),
  50. `${kind} has a section`
  51. ).toBeVisible()
  52. }
  53. // Nothing is designed yet, so every kind sends with its preset and says so.
  54. const builtIn = page.getByText('Built-in', { exact: true })
  55. expect(await builtIn.count(), 'a built-in card per kind').toBeGreaterThanOrEqual(5)
  56. expect(await page.getByText('In use', { exact: true }).count()).toBeGreaterThanOrEqual(5)
  57. })
  58. test('opens the designer in a tab of its own', async ({ page }) => {
  59. await page.goto('/settings/email-templates')
  60. await settle(page)
  61. // A card is a link, and it opens beside the settings page rather than
  62. // taking the workshop out of it.
  63. const card = page.getByRole('link').filter({ hasText: 'Built-in' }).first()
  64. await expect(card).toHaveAttribute('target', '_blank')
  65. await expect(card).toHaveAttribute('href', /\/email-designer\?kind=\w+&preset=1/)
  66. })
  67. })
  68. test.describe('the designer', () => {
  69. test('shows the mail it is designing, block by block', async ({ page }) => {
  70. await openPreset(page, 'invoice_sent')
  71. // The rail and the mail point at each other through these marks, which is
  72. // how a click in one selects in the other.
  73. const blocks = await railOrder(page)
  74. expect(blocks, 'the preset is the mail a workshop expects').toEqual([
  75. 'header',
  76. 'heading',
  77. 'intro',
  78. 'message',
  79. 'summary',
  80. 'cta',
  81. 'attachment',
  82. 'outro',
  83. 'divider',
  84. 'footer',
  85. ])
  86. for (const id of blocks.filter((block) => block !== 'divider')) {
  87. await expect(
  88. preview(page).locator(`[data-block="${id}"]`),
  89. `${id} is in the mail`
  90. ).toBeAttached()
  91. }
  92. // The exception, and it is deliberate: the footer is lifted out of the
  93. // card, and a rule immediately above it would underline nothing, so it
  94. // goes with it. Moving the footer up brings the rule back.
  95. await expect(
  96. preview(page).locator('[data-block="divider"]'),
  97. 'a rule at the foot of the card is dropped with the footer'
  98. ).toHaveCount(0)
  99. })
  100. test('a block clicked in the rail is the one the inspector edits', async ({ page }) => {
  101. await openPreset(page, 'invoice_sent')
  102. await expect(async () => {
  103. await railBlock(page, 'intro').click()
  104. await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'true', {
  105. timeout: 2_000,
  106. })
  107. }).toPass({ timeout: 30_000 })
  108. // The inspector swaps from "Subject and theme" to that block's own fields.
  109. await expect(page.getByLabel('Text', { exact: true })).toBeVisible()
  110. await expect(subjectField(page), 'the subject is put away while a block is open').toHaveCount(0)
  111. // And the way back, which is how a workshop reaches the subject again.
  112. await openSubjectAndTheme(page)
  113. await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'false')
  114. })
  115. test('line and paragraph spacing set in the theme reach the mail', async ({ page }) => {
  116. // Every line of body text used to sit at one fixed height and every
  117. // paragraph at one fixed gap; a workshop that found the mail airy had
  118. // nothing to turn. The theme has two steps for it now.
  119. await openPreset(page, 'invoice_sent')
  120. await openSubjectAndTheme(page)
  121. const intro = preview(page).locator('[data-block="intro"] td').first()
  122. await expect(intro).toHaveAttribute('style', /line-height:1\.6;/)
  123. await page.getByRole('combobox', { name: 'Line spacing' }).click()
  124. await page.getByRole('option', { name: 'Relaxed', exact: true }).click()
  125. await expect(intro, 'the body lines open up').toHaveAttribute('style', /line-height:1\.8;/)
  126. await page.getByRole('combobox', { name: 'Paragraph spacing' }).click()
  127. await page.getByRole('option', { name: 'Tight', exact: true }).click()
  128. await expect(intro, 'the text blocks close ranks').toHaveAttribute(
  129. 'style',
  130. /padding:0 0 10px 0;/
  131. )
  132. await page.getByRole('combobox', { name: 'Line spacing' }).click()
  133. await page.getByRole('option', { name: 'Compact', exact: true }).click()
  134. await expect(intro).toHaveAttribute('style', /line-height:1\.4;/)
  135. })
  136. test('a block hidden in the rail leaves the mail', async ({ page }) => {
  137. await openPreset(page, 'invoice_sent')
  138. const row = railBlock(page, 'outro')
  139. await expect(preview(page).locator('[data-block="outro"]')).toBeAttached()
  140. await expect(async () => {
  141. await row.getByRole('button', { name: /Shown in the email/ }).click()
  142. await expect(
  143. preview(page).locator('[data-block="outro"]'),
  144. 'the hidden block is not in the mail'
  145. ).toHaveCount(0, { timeout: 3_000 })
  146. }).toPass({ timeout: 30_000 })
  147. // And the row says what it is now, so the eye is not a mystery toggle.
  148. await expect(row.getByRole('button', { name: /Hidden from the email/ })).toBeVisible()
  149. })
  150. test('the plain text half says the same as the mail', async ({ page }) => {
  151. await openPreset(page, 'invoice_sent')
  152. // Every mail goes out with both halves; a client that shows text only
  153. // must still be able to read it, and follow the button.
  154. await page.getByRole('button', { name: 'Show the plain-text version' }).click()
  155. const text = await page.locator('pre').first().innerText()
  156. expect(text.length, 'the text half has words in it').toBeGreaterThan(40)
  157. // A button cannot be clicked in plain text, so it is spelled out.
  158. expect(text).toMatch(/https?:\/\//)
  159. })
  160. test('a misspelt tag is a problem, and the problem blocks the save', async ({ page }) => {
  161. await openPreset(page, 'invoice_sent')
  162. const subject = subjectField(page)
  163. await expect(subject).toBeVisible()
  164. await subject.fill('Invoice for {custmer_name}')
  165. // The badge counts what is wrong and the tooltip names the tag; either
  166. // way Save is out of reach until it is fixed.
  167. await expect(page.getByText(/problem/)).toBeVisible()
  168. await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
  169. // Spelled correctly, it is a tag again and the mail fills it in.
  170. await subject.fill('Invoice for {customer_name}')
  171. await expect(page.getByText(/problem/)).toHaveCount(0)
  172. await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled()
  173. })
  174. test('an invalid colour blocks the save and says why', async ({ page }) => {
  175. await openPreset(page, 'invoice_sent')
  176. const hex = page.getByLabel('Primary as hex')
  177. await hex.fill('#zzz')
  178. await expect(page.getByText('A colour is not a valid hex value')).toBeVisible()
  179. await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
  180. await hex.fill('#1d4ed8')
  181. await expect(page.getByText('A colour is not a valid hex value')).toHaveCount(0)
  182. })
  183. })
  184. test.describe('saving a design', () => {
  185. test('the first save asks for a name and takes over the kind', async ({ page }) => {
  186. await openPreset(page, 'invoice_sent')
  187. // A sentence of the workshop's own, so the saved template is recognisable
  188. // in the gallery and in the mail that goes out later.
  189. await expect(async () => {
  190. await railBlock(page, 'intro').click()
  191. await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
  192. }).toPass({ timeout: 30_000 })
  193. await page.getByLabel('Text', { exact: true }).fill(SENTENCE)
  194. // In the block it was typed into. It is also in the mail's preheader, the
  195. // hidden line an inbox shows beside the subject, which is drawn from the
  196. // first paragraph.
  197. await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
  198. await saveDesign(page, TEMPLATE)
  199. // The tab is now on the saved template rather than the preset it started
  200. // from: saving again updates it instead of making a second one.
  201. await expect(page).toHaveURL(/[?&]template=/)
  202. // The button is named for what it would do, so "Saved" is also the check
  203. // that there is nothing left unsaved.
  204. await expect(page.getByRole('button', { name: 'Saved', exact: true })).toBeVisible()
  205. expect(await emailTemplateNames('invoice_sent')).toContain(TEMPLATE)
  206. })
  207. test('the gallery shows it in use, and offers the way back', async ({ page }) => {
  208. await page.goto('/settings/email-templates')
  209. await settle(page)
  210. const card = page
  211. .locator('div')
  212. .filter({ has: page.getByText(TEMPLATE, { exact: true }) })
  213. .last()
  214. await expect(card.getByText('In use', { exact: true })).toBeVisible()
  215. // The built-in card is no longer the one sending, so it offers itself.
  216. await expect(page.getByRole('button', { name: 'Use built-in' }).first()).toBeVisible()
  217. })
  218. test('a second template cannot take the first one’s name', async ({ page }) => {
  219. await openPreset(page, 'invoice_sent')
  220. await subjectField(page).fill(`Second ${stamp}`)
  221. await page.getByRole('button', { name: 'Save', exact: true }).click()
  222. const dialog = page.getByRole('dialog', { name: 'Save this template' })
  223. await dialog.getByPlaceholder('Template name').fill(TEMPLATE)
  224. // Refused on the spot, before the save is even attempted: a workshop with
  225. // two templates called the same thing cannot tell them apart afterwards.
  226. await expect(dialog.getByRole('alert')).toHaveText(/already exists/)
  227. await expect(dialog.getByRole('button', { name: 'Save template', exact: true })).toBeDisabled()
  228. // Under its own name it saves.
  229. await dialog.getByPlaceholder('Template name').fill(SECOND)
  230. await dialog.getByRole('button', { name: 'Save template', exact: true }).click()
  231. await expect(
  232. page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
  233. ).toBeVisible({ timeout: 30_000 })
  234. expect(await emailTemplateNames('invoice_sent')).toEqual(
  235. expect.arrayContaining([TEMPLATE, SECOND])
  236. )
  237. })
  238. test('deleting the one in use puts the kind back on its preset', async ({ page }) => {
  239. await page.goto('/settings/email-templates')
  240. await settle(page)
  241. // The second save took the kind over, so this is the one in use.
  242. const card = page
  243. .locator('div')
  244. .filter({ has: page.getByText(SECOND, { exact: true }) })
  245. .last()
  246. await expect(async () => {
  247. await card.getByRole('button', { name: 'Delete' }).click()
  248. await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 2_000 })
  249. }).toPass({ timeout: 30_000 })
  250. await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click()
  251. await expect(page.getByText(SECOND, { exact: true })).toHaveCount(0, { timeout: 30_000 })
  252. expect(await emailTemplateNames('invoice_sent')).not.toContain(SECOND)
  253. })
  254. })