work-order.ts 8.5 KB

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