settings.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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. * Tax on only some kinds of charge (parts and not labor). Given, "Tax only
  30. * some charges" is turned on with these three answers; left out, it is
  31. * turned off and the workshop is back to taxing everything.
  32. */
  33. appliesTo?: { parts: boolean; labor: boolean; shopFee: boolean }
  34. }
  35. /** Flips a switch to the wanted state, retried because a click before hydration does nothing. */
  36. async function setSwitch(toggle: Locator, on: boolean): Promise<void> {
  37. await expect(toggle).toBeVisible()
  38. await expect(async () => {
  39. if ((await toggle.getAttribute('aria-checked')) === String(on)) return
  40. await toggle.click()
  41. await expect(toggle).toHaveAttribute('aria-checked', String(on))
  42. }).toPass()
  43. }
  44. /** Settings → Tax, saved. */
  45. export async function setTax(page: Page, tax: TaxSetup): Promise<void> {
  46. await page.goto('/settings/tax')
  47. await setSwitch(page.getByRole('switch').first(), tax.enabled)
  48. if (tax.enabled) {
  49. await setSwitch(page.locator('#taxSplit'), Boolean(tax.components))
  50. if (tax.components) {
  51. const rows = page.getByTestId('tax-component-row')
  52. while ((await rows.count()) > tax.components.length) {
  53. await rows.last().getByRole('button', { name: 'Remove this tax' }).click()
  54. }
  55. while ((await rows.count()) < tax.components.length) {
  56. await page.getByRole('button', { name: 'Add a tax' }).click()
  57. }
  58. for (const [i, component] of tax.components.entries()) {
  59. await fillSettled(page.locator(`#taxComponentName-${i}`), component.name)
  60. await fillSettled(page.locator(`#taxComponentRate-${i}`), String(component.rate))
  61. await fillSettled(
  62. page.locator(`#taxComponentRegistration-${i}`),
  63. component.registrationNumber ?? ''
  64. )
  65. }
  66. } else {
  67. await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
  68. await page.locator('#taxLabel').fill(tax.label ?? '')
  69. }
  70. await setSwitch(page.locator('#taxSelective'), Boolean(tax.appliesTo))
  71. if (tax.appliesTo) {
  72. await setSwitch(page.locator('#taxOnParts'), tax.appliesTo.parts)
  73. await setSwitch(page.locator('#taxOnLabor'), tax.appliesTo.labor)
  74. await setSwitch(page.locator('#taxOnShopFee'), tax.appliesTo.shopFee)
  75. }
  76. await page
  77. .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
  78. .click()
  79. }
  80. await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
  81. await expect(page.getByText('Settings saved', { exact: true })).toBeVisible()
  82. }
  83. export interface NumberingSetup {
  84. /** The invoice number format, `{year}` and `{month}` included. */
  85. prefix: string
  86. /** The number the next invoice takes. Empty leaves the sequence alone. */
  87. startNumber?: string
  88. }
  89. /** Settings → Invoice, the numbering half of it, saved. */
  90. export async function setInvoiceNumbering(
  91. page: Page,
  92. { prefix, startNumber = '' }: NumberingSetup
  93. ): Promise<void> {
  94. await page.goto('/settings/invoice')
  95. await fillSettled(page.locator('#invoicePrefix'), prefix)
  96. await fillSettled(page.locator('#invoiceStartNumber'), startNumber)
  97. await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
  98. await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
  99. }
  100. export interface QuoteLockSetup {
  101. enabled: boolean
  102. /** When a quote stops being editable: once it goes to the customer, or once they accept. */
  103. trigger: 'sent' | 'accepted'
  104. }
  105. /**
  106. * Settings → Invoice, the quote half of "Locking finished documents", saved.
  107. * Off by default, so a spec that turns it on turns it off again.
  108. */
  109. export async function setQuoteLock(
  110. page: Page,
  111. { enabled, trigger }: QuoteLockSetup
  112. ): Promise<void> {
  113. await page.goto('/settings/invoice')
  114. await settle(page)
  115. // The trigger select is disabled while the switch is off, so the switch goes
  116. // on first and is set to what was asked for after the trigger is chosen.
  117. const toggle = page.locator('#quoteLockEnabled')
  118. await setSwitch(toggle, true)
  119. const wanted = trigger === 'sent' ? 'Once the quote is sent' : 'Once the quote is accepted'
  120. const select = page.locator('#quoteLockTrigger')
  121. await expect(async () => {
  122. await select.click()
  123. await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
  124. }).toPass({ timeout: 30_000 })
  125. await page.getByRole('option', { name: wanted }).click()
  126. await expect(select).toContainText(wanted)
  127. await setSwitch(toggle, enabled)
  128. await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
  129. await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
  130. }
  131. export interface InvoiceLockSetup {
  132. enabled: boolean
  133. trigger: 'sent' | 'paid'
  134. }
  135. /**
  136. * Settings → Invoice, the invoice half of "Locking finished documents", saved.
  137. * Off by default, so a spec that turns it on turns it off again. Invoices sent
  138. * before it was turned on stay editable, so a spec sends its job afterwards.
  139. */
  140. export async function setInvoiceLock(
  141. page: Page,
  142. { enabled, trigger }: InvoiceLockSetup
  143. ): Promise<void> {
  144. await page.goto('/settings/invoice')
  145. await settle(page)
  146. // As with the quote lock: the trigger is disabled while the switch is off.
  147. const toggle = page.locator('#invoiceLockEnabled')
  148. await setSwitch(toggle, true)
  149. const wanted =
  150. trigger === 'sent' ? 'Once the invoice is sent' : 'Once the invoice is paid in full'
  151. const select = page.locator('#invoiceLockTrigger')
  152. await expect(async () => {
  153. await select.click()
  154. await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
  155. }).toPass({ timeout: 30_000 })
  156. await page.getByRole('option', { name: wanted }).click()
  157. await expect(select).toContainText(wanted)
  158. await setSwitch(toggle, enabled)
  159. await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
  160. await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
  161. }
  162. /** What the settings page currently offers as the next invoice number. */
  163. export async function invoiceStartNumber(page: Page): Promise<string> {
  164. await page.goto('/settings/invoice')
  165. const field = page.locator('#invoiceStartNumber')
  166. await expect(field).toBeVisible()
  167. return field.inputValue()
  168. }
  169. /**
  170. * Settings → Localisation: the workshop's clock.
  171. *
  172. * The timezone decides what a booking's wall clock means, and the format
  173. * decides how it is written. Both are set together because a test that reads
  174. * a time off one screen and looks for it on another needs them to agree: the
  175. * calendar prints the workshop's chosen format, while a schedule field prints
  176. * a 24-hour clock whatever the setting says.
  177. *
  178. * Pass an empty timezone to hand it back to the browser's own, which is what
  179. * a workshop that has never chosen sees.
  180. */
  181. export async function setWorkshopClock(
  182. page: Page,
  183. { timezone, format }: { timezone: string; format?: '12h' | '24h' }
  184. ): Promise<void> {
  185. await page.goto('/settings/localization')
  186. await settle(page)
  187. // By label, not by what it currently reads: the date-format select a few
  188. // rows up also shows a value full of slashes.
  189. const picker = page.getByLabel('Timezone')
  190. await expect(async () => {
  191. await picker.click()
  192. await expect(page.getByPlaceholder('Search timezone...')).toBeVisible({ timeout: 2_000 })
  193. }).toPass({ timeout: 30_000 })
  194. if (timezone) {
  195. // The list is long, so it is narrowed to the city, written the way the
  196. // list writes it: "Los Angeles", not "Los_Angeles".
  197. const city = (timezone.split('/').pop() ?? timezone).replace(/_/g, ' ')
  198. await page.getByPlaceholder('Search timezone...').fill(city)
  199. await page
  200. .getByRole('option', { name: timezone.replace(/_/g, ' '), exact: true })
  201. .first()
  202. .click()
  203. } else {
  204. await page.getByRole('option', { name: 'Auto-detect (browser)' }).first().click()
  205. }
  206. if (format) {
  207. const wanted = format === '24h' ? '24-hour (14:30)' : '12-hour (2:30 PM)'
  208. const clock = page.getByLabel('Time Format')
  209. await expect(async () => {
  210. await clock.click()
  211. await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
  212. }).toPass({ timeout: 30_000 })
  213. await page.getByRole('option', { name: wanted }).click()
  214. }
  215. await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
  216. await expect(page.getByText('Localization settings saved')).toBeVisible({ timeout: 30_000 })
  217. }
  218. /**
  219. * Settings → Payment, the bank account.
  220. *
  221. * The payment panel on a sheet prints nothing at all unless it has a line to
  222. * print, and the seeded workshop has no bank details, no org number and no
  223. * terms. A spec that wants the panel has to give it one, and put it back
  224. * afterwards: every other sheet in the suite is printed from these settings.
  225. */
  226. export async function setBankAccount(page: Page, account: string): Promise<void> {
  227. await page.goto('/settings/payment')
  228. await fillSettled(page.locator('#bankAccount'), account)
  229. await page.getByRole('button', { name: 'Save Payment Settings', exact: true }).click()
  230. await expect(page.getByText('Payment settings saved', { exact: true })).toBeVisible()
  231. }
  232. /** What the workshop currently has as its bank account, which may be nothing. */
  233. export async function bankAccount(page: Page): Promise<string> {
  234. await page.goto('/settings/payment')
  235. const field = page.locator('#bankAccount')
  236. await expect(field).toBeVisible()
  237. return field.inputValue()
  238. }
  239. export interface WarrantySetup {
  240. /** What a new quote or work order says before anybody touches it. */
  241. newDocumentsSay: 'Nothing' | 'Warranty included' | 'No warranty'
  242. months?: number
  243. distance?: number
  244. terms?: string
  245. notIncludedText?: string
  246. /** Whether the defaults reach new quotes, and new work orders. On unless said otherwise. */
  247. onQuotes?: boolean
  248. onWorkOrders?: boolean
  249. }
  250. /**
  251. * Settings → Warranty, saved. Nothing is set on a seeded workshop, so a spec
  252. * that writes these removes them afterwards (`forgetWorkshopSetting`): every
  253. * other quote and invoice in the suite is printed without a warranty panel.
  254. */
  255. export async function setWarrantyDefaults(page: Page, warranty: WarrantySetup): Promise<void> {
  256. await page.goto('/settings/warranty')
  257. await settle(page)
  258. const choice = page.getByRole('radio', { name: warranty.newDocumentsSay, exact: true })
  259. await expect(async () => {
  260. await choice.click()
  261. await expect(choice).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
  262. }).toPass({ timeout: 30_000 })
  263. const text = (value: number | string | undefined) => (value === undefined ? '' : String(value))
  264. await fillSettled(page.locator('#warrantyDefaultMonths'), text(warranty.months))
  265. await fillSettled(page.locator('#warrantyDefaultMileage'), text(warranty.distance))
  266. await fillSettled(page.locator('#warrantyDefaultTerms'), text(warranty.terms))
  267. await fillSettled(page.locator('#warrantyNotIncludedText'), text(warranty.notIncludedText))
  268. await setSwitch(page.locator('#warrantyApplyToQuotes'), warranty.onQuotes ?? true)
  269. await setSwitch(page.locator('#warrantyApplyToWorkOrders'), warranty.onWorkOrders ?? true)
  270. await page.getByRole('button', { name: 'Save warranty settings', exact: true }).click()
  271. await expect(page.getByText('Warranty settings saved', { exact: true })).toBeVisible()
  272. }