work-order.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { expect, type Locator, type Page } from '@playwright/test'
  2. import { settle } from './hydration'
  3. /**
  4. * Driving a work order through the browser the way a workshop does.
  5. *
  6. * Everything here reads visible English and the placeholders the editors
  7. * print; the suite pins the locale so both hold. Clicks that add a row are
  8. * retried until the row is there: a click that lands before React has
  9. * hydrated the page does nothing, and the editor gives no other sign.
  10. */
  11. /** The address of one seeded vehicle, found through the list's own search. */
  12. export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<string> {
  13. await page.goto(`/vehicles?search=${encodeURIComponent(search)}`)
  14. await page
  15. .getByRole('link', { name: new RegExp(search, 'i') })
  16. .first()
  17. .click()
  18. await page.waitForURL(/\/vehicles\/[^/?]+$/)
  19. return page.url()
  20. }
  21. /** The work order's id, from an editor address. */
  22. export function jobIdOf(jobUrl: string): string {
  23. const id = new URL(jobUrl).pathname.split('/').pop()
  24. if (!id) throw new Error(`no job id in ${jobUrl}`)
  25. return id
  26. }
  27. /**
  28. * Retitles the job open in the editor: the title reads as text until its
  29. * pencil is pressed. Kept, not saved; it goes with the rest on Save.
  30. */
  31. export async function setTitle(page: Page, title: string): Promise<void> {
  32. const field = page.getByTestId('title-input')
  33. await expect(async () => {
  34. await page.getByTestId('edit-title').click()
  35. await expect(field).toBeVisible({ timeout: 2_000 })
  36. }).toPass({ timeout: 30_000 })
  37. await field.fill(title)
  38. await field.press('Enter')
  39. await expect(page.getByTestId('service-title').first()).toContainText(title)
  40. }
  41. /** A fresh draft work order on the vehicle, titled, open in the editor. */
  42. export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
  43. await page.goto(`${vehicleUrl}/service/new`)
  44. await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
  45. // `/service/new` makes the draft and redirects to it, and for a moment the
  46. // page being left and the page arriving are both in the document: two title
  47. // fields, and a strict-mode error instead of a retry. Settled, there is one.
  48. await settle(page)
  49. await setTitle(page, title)
  50. return page.url()
  51. }
  52. /** The editor row an input belongs to: the nearest ancestor that also holds the given input. */
  53. function rowContaining(field: Locator, siblingPlaceholder: string): Locator {
  54. return field.locator(`xpath=ancestor::*[.//input[@placeholder="${siblingPlaceholder}"]][1]`)
  55. }
  56. /**
  57. * The part rows, in the order the editor lists them. One locator per row: the
  58. * page used to draw the whole editor twice, once per breakpoint, and every row
  59. * came back doubled with half of them impossible to type into.
  60. * `specs/work-orders/layout.spec.ts` is what keeps it to one.
  61. */
  62. export function partRows(page: Page): Locator {
  63. return page.getByPlaceholder('Name *')
  64. }
  65. /** The labour rows, likewise. */
  66. export function laborRows(page: Page): Locator {
  67. return page.getByPlaceholder('Description *')
  68. }
  69. /** The whole row a part field sits in, buttons and figures included. */
  70. export function partRowOf(nameField: Locator): Locator {
  71. return rowContaining(nameField, 'Cost')
  72. }
  73. /** The whole row a labour field sits in. */
  74. export function laborRowOf(descriptionField: Locator): Locator {
  75. return rowContaining(descriptionField, 'Hours')
  76. }
  77. export interface PartInput {
  78. name: string
  79. quantity: number
  80. /** Vendor cost; with a markup the unit price is expected to follow from it. */
  81. cost?: number
  82. markupPercent?: number
  83. unitPrice?: number
  84. }
  85. /**
  86. * Adds a part line. The number inputs sit in the order the editor prints
  87. * them: quantity, cost, markup, unit price.
  88. */
  89. export async function addPart(page: Page, part: PartInput): Promise<void> {
  90. const rows = partRows(page)
  91. // Counted first, and waited for: a click before hydration adds nothing, and
  92. // a guard that only asked whether some name field was on screen would be
  93. // satisfied by the rows already there and type over the last of them.
  94. const before = await rows.count()
  95. await expect(async () => {
  96. // An empty list offers the button twice, in the toolbar and as the dashed
  97. // row beneath it; once there are rows, only the toolbar one is named.
  98. await page.getByRole('button', { name: 'Add Part' }).last().click()
  99. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  100. }).toPass({ timeout: 30_000 })
  101. // The toolbar button puts the new row at the top of the list, which is
  102. // where the workshop looks after clicking it; the dashed one appends, and
  103. // on an empty list either way leaves the row it made as the only one.
  104. const name = rows.first()
  105. await name.fill(part.name)
  106. const numbers = rowContaining(name, 'Cost').locator('input[type="number"]')
  107. await numbers.nth(0).fill(String(part.quantity))
  108. if (part.cost !== undefined) await numbers.nth(1).fill(String(part.cost))
  109. if (part.markupPercent !== undefined) await numbers.nth(2).fill(String(part.markupPercent))
  110. if (part.unitPrice !== undefined) await numbers.nth(3).fill(String(part.unitPrice))
  111. }
  112. /** What the editor worked out as the unit price of the part added last. */
  113. export async function lastPartUnitPrice(page: Page): Promise<string> {
  114. return partRowOf(partRows(page).first()).locator('input[type="number"]').nth(3).inputValue()
  115. }
  116. export interface LaborInput {
  117. description: string
  118. hours: number
  119. rate: number
  120. }
  121. /** Adds an hourly labour line: description, hours, rate. */
  122. export async function addLabor(page: Page, labor: LaborInput): Promise<void> {
  123. const rows = laborRows(page)
  124. const before = await rows.count()
  125. await expect(async () => {
  126. await page.getByRole('button', { name: 'Add Labor' }).last().click()
  127. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  128. }).toPass({ timeout: 30_000 })
  129. // Added at the top, like a part.
  130. const description = rows.first()
  131. await description.fill(labor.description)
  132. const numbers = rowContaining(description, 'Hours').locator('input[type="number"]')
  133. await numbers.nth(0).fill(String(labor.hours))
  134. await numbers.nth(1).fill(String(labor.rate))
  135. }
  136. /** Saves the work order and waits for the header to say so. */
  137. export async function saveWorkOrder(page: Page): Promise<void> {
  138. await page.getByRole('button', { name: 'Save', exact: true }).click()
  139. await expect(page.getByText('Saved', { exact: true })).toBeVisible()
  140. }
  141. /**
  142. * One line of the totals panel, found by its label. The panel prints each
  143. * line as a label and a figure side by side, so the row is the label's
  144. * parent and the figure is read from it.
  145. */
  146. export function totalsRow(page: Page, label: string): Locator {
  147. // Scoped to the totals rows: "Parts" and "Labor" are also section headings.
  148. const panel = page.getByTestId('totals')
  149. // The row is the nearest box that spreads label and figure apart; the tax
  150. // label sits one level deeper, beside its percentage input.
  151. return panel
  152. .getByText(label, { exact: true })
  153. .first()
  154. .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
  155. }
  156. /** Sets the discount on the open work order. */
  157. export async function setDiscount(
  158. page: Page,
  159. kind: 'None' | 'Percentage' | 'Fixed',
  160. value?: number
  161. ): Promise<void> {
  162. const row = totalsRow(page, 'Discount')
  163. await row.getByRole('combobox').click()
  164. await page.getByRole('option', { name: kind, exact: true }).click()
  165. if (value !== undefined) await row.locator('input[type="number"]').fill(String(value))
  166. }
  167. /** The public share link for the open work order, generating it if needed. */
  168. export async function shareLink(page: Page): Promise<string> {
  169. await page.getByRole('button', { name: 'Share', exact: true }).click()
  170. // A document whose invoice date has passed is offered a fresh one before it
  171. // goes out. Declined here: a spec that shares a seeded invoice must hand the
  172. // customer the document as it stands, not rewrite its dates on the way.
  173. const expired = page.getByRole('dialog', { name: /invoice dates expired/i })
  174. const asksAboutDates = await expired
  175. .waitFor({ state: 'visible', timeout: 3_000 })
  176. .then(() => true)
  177. .catch(() => false)
  178. if (asksAboutDates) {
  179. await expired.getByRole('button', { name: /proceed without changes/i }).click()
  180. }
  181. // By name: a workshop-wide announcement is a dialog too, and can be open on
  182. // the same page.
  183. const dialog = page.getByRole('dialog', { name: 'Share Invoice' })
  184. await expect(dialog).toBeVisible()
  185. const generate = dialog.getByRole('button', { name: /generate public link/i })
  186. if (await generate.isVisible()) {
  187. await generate.click()
  188. // A job that has gone to the customer is asked to leave the work board.
  189. const prompt = page.getByRole('alertdialog', { name: /mark this invoice as completed/i })
  190. const asked = await prompt
  191. .waitFor({ state: 'visible', timeout: 3_000 })
  192. .then(() => true)
  193. .catch(() => false)
  194. if (asked) await prompt.getByRole('button', { name: 'Mark completed', exact: true }).click()
  195. }
  196. const field = dialog.locator('input[readonly]')
  197. await expect(field).toHaveValue(/\/share\/invoice\//)
  198. const url = await field.inputValue()
  199. await page.keyboard.press('Escape')
  200. return url
  201. }