settings.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { expect, type Locator, type Page } from '@playwright/test'
  2. import { fillSettled, settle } from './hydration'
  3. /**
  4. * The workshop settings a spec has to move before it can assert anything: tax,
  5. * and the rules that decide an invoice number.
  6. *
  7. * These are one workshop's settings, shared by every test in the run, so a
  8. * spec that changes one puts it back at the end. Both pages are controlled
  9. * React forms, which is why the fields are typed into through `fillSettled`
  10. * rather than `fill`.
  11. */
  12. export interface TaxComponentSetup {
  13. name: string
  14. rate: number
  15. registrationNumber?: string
  16. }
  17. export interface TaxSetup {
  18. enabled: boolean
  19. rate?: number
  20. inclusive?: boolean
  21. label?: string
  22. /**
  23. * More than one tax on a document (Québec's GST and QST). Given, the split
  24. * switch is turned on and these rows replace the single rate; left out, the
  25. * switch is turned off and the workshop is back to one rate.
  26. */
  27. components?: TaxComponentSetup[]
  28. }
  29. /** Flips a switch to the wanted state, retried because a click before hydration does nothing. */
  30. async function setSwitch(toggle: Locator, on: boolean): Promise<void> {
  31. await expect(toggle).toBeVisible()
  32. await expect(async () => {
  33. if ((await toggle.getAttribute('aria-checked')) === String(on)) return
  34. await toggle.click()
  35. await expect(toggle).toHaveAttribute('aria-checked', String(on))
  36. }).toPass()
  37. }
  38. /** Settings → Tax, saved. */
  39. export async function setTax(page: Page, tax: TaxSetup): Promise<void> {
  40. await page.goto('/settings/tax')
  41. await setSwitch(page.getByRole('switch').first(), tax.enabled)
  42. if (tax.enabled) {
  43. await setSwitch(page.locator('#taxSplit'), Boolean(tax.components))
  44. if (tax.components) {
  45. const rows = page.getByTestId('tax-component-row')
  46. while ((await rows.count()) > tax.components.length) {
  47. await rows.last().getByRole('button', { name: 'Remove this tax' }).click()
  48. }
  49. while ((await rows.count()) < tax.components.length) {
  50. await page.getByRole('button', { name: 'Add a tax' }).click()
  51. }
  52. for (const [i, component] of tax.components.entries()) {
  53. await fillSettled(page.locator(`#taxComponentName-${i}`), component.name)
  54. await fillSettled(page.locator(`#taxComponentRate-${i}`), String(component.rate))
  55. await fillSettled(
  56. page.locator(`#taxComponentRegistration-${i}`),
  57. component.registrationNumber ?? ''
  58. )
  59. }
  60. } else {
  61. await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
  62. await page.locator('#taxLabel').fill(tax.label ?? '')
  63. }
  64. await page
  65. .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
  66. .click()
  67. }
  68. await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
  69. await expect(page.getByText('Settings saved', { exact: true })).toBeVisible()
  70. }
  71. export interface NumberingSetup {
  72. /** The invoice number format, `{year}` and `{month}` included. */
  73. prefix: string
  74. /** The number the next invoice takes. Empty leaves the sequence alone. */
  75. startNumber?: string
  76. }
  77. /** Settings → Invoice, the numbering half of it, saved. */
  78. export async function setInvoiceNumbering(
  79. page: Page,
  80. { prefix, startNumber = '' }: NumberingSetup
  81. ): Promise<void> {
  82. await page.goto('/settings/invoice')
  83. await fillSettled(page.locator('#invoicePrefix'), prefix)
  84. await fillSettled(page.locator('#invoiceStartNumber'), startNumber)
  85. await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
  86. await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
  87. }
  88. /** What the settings page currently offers as the next invoice number. */
  89. export async function invoiceStartNumber(page: Page): Promise<string> {
  90. await page.goto('/settings/invoice')
  91. const field = page.locator('#invoiceStartNumber')
  92. await expect(field).toBeVisible()
  93. return field.inputValue()
  94. }
  95. /**
  96. * Settings → Localisation: the workshop's clock.
  97. *
  98. * The timezone decides what a booking's wall clock means, and the format
  99. * decides how it is written. Both are set together because a test that reads
  100. * a time off one screen and looks for it on another needs them to agree: the
  101. * calendar prints the workshop's chosen format, while a schedule field prints
  102. * a 24-hour clock whatever the setting says.
  103. *
  104. * Pass an empty timezone to hand it back to the browser's own, which is what
  105. * a workshop that has never chosen sees.
  106. */
  107. export async function setWorkshopClock(
  108. page: Page,
  109. { timezone, format }: { timezone: string; format?: '12h' | '24h' }
  110. ): Promise<void> {
  111. await page.goto('/settings/localization')
  112. await settle(page)
  113. // By label, not by what it currently reads: the date-format select a few
  114. // rows up also shows a value full of slashes.
  115. const picker = page.getByLabel('Timezone')
  116. await expect(async () => {
  117. await picker.click()
  118. await expect(page.getByPlaceholder('Search timezone...')).toBeVisible({ timeout: 2_000 })
  119. }).toPass({ timeout: 30_000 })
  120. if (timezone) {
  121. // The list is long, so it is narrowed to the city, written the way the
  122. // list writes it: "Los Angeles", not "Los_Angeles".
  123. const city = (timezone.split('/').pop() ?? timezone).replace(/_/g, ' ')
  124. await page.getByPlaceholder('Search timezone...').fill(city)
  125. await page
  126. .getByRole('option', { name: timezone.replace(/_/g, ' '), exact: true })
  127. .first()
  128. .click()
  129. } else {
  130. await page.getByRole('option', { name: 'Auto-detect (browser)' }).first().click()
  131. }
  132. if (format) {
  133. const wanted = format === '24h' ? '24-hour (14:30)' : '12-hour (2:30 PM)'
  134. const clock = page.getByLabel('Time Format')
  135. await expect(async () => {
  136. await clock.click()
  137. await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
  138. }).toPass({ timeout: 30_000 })
  139. await page.getByRole('option', { name: wanted }).click()
  140. }
  141. await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
  142. await expect(page.getByText('Localization settings saved')).toBeVisible({ timeout: 30_000 })
  143. }
  144. /**
  145. * Settings → Payment, the bank account.
  146. *
  147. * The payment panel on a sheet prints nothing at all unless it has a line to
  148. * print, and the seeded workshop has no bank details, no org number and no
  149. * terms. A spec that wants the panel has to give it one, and put it back
  150. * afterwards: every other sheet in the suite is printed from these settings.
  151. */
  152. export async function setBankAccount(page: Page, account: string): Promise<void> {
  153. await page.goto('/settings/payment')
  154. await fillSettled(page.locator('#bankAccount'), account)
  155. await page.getByRole('button', { name: 'Save Payment Settings', exact: true }).click()
  156. await expect(page.getByText('Payment settings saved', { exact: true })).toBeVisible()
  157. }
  158. /** What the workshop currently has as its bank account, which may be nothing. */
  159. export async function bankAccount(page: Page): Promise<string> {
  160. await page.goto('/settings/payment')
  161. const field = page.locator('#bankAccount')
  162. await expect(field).toBeVisible()
  163. return field.inputValue()
  164. }