modern-page.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { expect, type Locator, type Page, test } from '@playwright/test'
  2. import {
  3. deleteTechnicians,
  4. insertTechnician,
  5. jobAssignment,
  6. ownerOrganizationId,
  7. paymentsFor,
  8. } from '../../support/db'
  9. import { fillSettled, settle } from '../../support/hydration'
  10. import { makePdf, TINY_PNG } from '../../support/pdf'
  11. import { setInvoiceLock } from '../../support/settings'
  12. import {
  13. addPart,
  14. jobIdOf,
  15. newWorkOrder,
  16. partRows,
  17. saveWorkOrder,
  18. seededVehicleUrl,
  19. shareLink,
  20. } from '../../support/work-order'
  21. /**
  22. * What the work order page does, beyond laying the fields out.
  23. * `modern-layout.spec.ts` covers the one-copy-of-each-field rule; this file
  24. * drives the things that exist
  25. * only here or behave differently here: the job facts and the header's type,
  26. * the concern story (condition, cause, correction, confirm) and the question
  27. * before completing a job with a concern nobody confirmed, the files card,
  28. * the technician list, the notes, and a locked invoice that still takes a
  29. * status, a payment and an internal note.
  30. *
  31. * The jobs are made first, each with a part: `/service/new` hands back an
  32. * untouched draft younger than five seconds instead of making a new one.
  33. */
  34. test.describe.configure({ mode: 'serial' })
  35. const stamp = Date.now()
  36. let vehicleUrl = ''
  37. let factsJob = ''
  38. let filesJob = ''
  39. let scheduleJob = ''
  40. test.beforeAll(async ({ browser }) => {
  41. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  42. vehicleUrl = await seededVehicleUrl(page)
  43. factsJob = await jobWithPart(page, `E2E modern facts ${stamp}`)
  44. filesJob = await jobWithPart(page, `E2E modern files ${stamp}`)
  45. scheduleJob = await jobWithPart(page, `E2E modern schedule ${stamp}`)
  46. await page.close()
  47. })
  48. async function jobWithPart(page: Page, title: string): Promise<string> {
  49. const url = await newWorkOrder(page, vehicleUrl, title)
  50. await addPart(page, { name: `${title} part`, quantity: 1, unitPrice: 500 })
  51. await saveWorkOrder(page)
  52. return url
  53. }
  54. /** Opens a job on the overhauled page and waits for it to be usable. */
  55. async function openModern(page: Page, url: string): Promise<void> {
  56. await page.goto(url)
  57. await settle(page)
  58. await expect(page.getByTestId('service-layout-modern')).toBeVisible()
  59. }
  60. /**
  61. * Clicks low in a notes box, well below the first line, and types. The box is
  62. * taller than what is written in it, and a click in that empty space has to
  63. * put the cursor in the text the way a textarea does.
  64. */
  65. async function typeInNotes(page: Page, card: Locator, text: string): Promise<void> {
  66. const area = card.getByTestId('rich-text-area')
  67. const editor = card.locator('.ProseMirror')
  68. await expect(async () => {
  69. const box = await area.boundingBox()
  70. if (!box) throw new Error('notes box not on screen')
  71. await area.click({ position: { x: box.width / 2, y: box.height - 12 } })
  72. await expect(editor).toBeFocused({ timeout: 2_000 })
  73. }).toPass({ timeout: 30_000 })
  74. await page.keyboard.type(text)
  75. await expect(editor).toContainText(text)
  76. }
  77. function stepper(page: Page): Locator {
  78. return page.getByTestId('status-stepper')
  79. }
  80. async function expectStatus(page: Page, name: RegExp): Promise<void> {
  81. await expect(stepper(page).getByRole('button', { name })).toHaveAttribute('aria-current', 'step')
  82. }
  83. test.describe('the overhauled work order page', () => {
  84. test('keeps the type, set in the header, and the mileage, set on the facts card', async ({
  85. page,
  86. }) => {
  87. await openModern(page, factsJob)
  88. const facts = page.getByTestId('job-facts')
  89. await expect(facts).toContainText('Camry')
  90. await expect(facts.getByRole('link', { name: 'Open customer' })).toBeVisible()
  91. const type = page.getByTestId('service-type')
  92. await expect(async () => {
  93. await type.click()
  94. await expect(page.getByRole('option', { name: 'Repair' })).toBeVisible({ timeout: 2_000 })
  95. }).toPass({ timeout: 30_000 })
  96. await page.getByRole('option', { name: 'Repair' }).click()
  97. await fillSettled(page.locator('#mileage'), '123456')
  98. await saveWorkOrder(page)
  99. await page.reload()
  100. await settle(page)
  101. await expect(page.getByTestId('service-type')).toContainText('Repair')
  102. await expect(page.locator('#mileage')).toHaveValue('123456')
  103. })
  104. test('shows the whole customer and vehicle under "More info", with the VIN one click from the clipboard', async ({
  105. page,
  106. context,
  107. baseURL,
  108. }) => {
  109. await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
  110. origin: baseURL ?? 'http://127.0.0.1:3100',
  111. })
  112. await openModern(page, factsJob)
  113. const facts = page.getByTestId('job-facts')
  114. const details = facts.getByTestId('vehicle-details')
  115. await expect(details).toHaveCount(0)
  116. await expect(async () => {
  117. await facts.getByRole('button', { name: 'More info' }).click()
  118. await expect(details).toBeVisible({ timeout: 2_000 })
  119. }).toPass({ timeout: 30_000 })
  120. await expect(details.getByTestId('vehicle-detail-vehicle')).toContainText('Camry')
  121. // The same switch opens the customer's half: every customer has a "since".
  122. await expect(facts.getByTestId('customer-details')).toContainText('Customer since')
  123. // The reason for the list: the VIN, copied without leaving the job.
  124. const vinRow = details.getByTestId('vehicle-detail-vin')
  125. const vin = (await vinRow.locator('dd span').first().innerText()).trim()
  126. expect(vin).toMatch(/^[A-HJ-NPR-Z0-9]{17}$/)
  127. await vinRow.getByRole('button', { name: 'Copy VIN' }).click()
  128. await expect(vinRow.getByRole('button', { name: 'Copied' })).toBeVisible()
  129. expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(vin)
  130. // The plate on the card copies too; the vehicle's name is the link.
  131. const plate = facts.getByTestId('copy-plate')
  132. const plateText = (await plate.innerText()).trim()
  133. await plate.click()
  134. await expect(plate).toContainText('Copied')
  135. expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(plateText)
  136. await expect(plate).toHaveText(plateText)
  137. // Left open, it opens again next time; closed, it stays closed.
  138. await page.reload()
  139. await settle(page)
  140. await expect(page.getByTestId('vehicle-details')).toBeVisible()
  141. await page.getByTestId('job-facts').getByRole('button', { name: 'Less info' }).click()
  142. await expect(page.getByTestId('vehicle-details')).toHaveCount(0)
  143. await expect(page.getByTestId('customer-details')).toHaveCount(0)
  144. })
  145. test('writes in both notes from a click anywhere in the box, and saves them', async ({
  146. page,
  147. }) => {
  148. await openModern(page, factsJob)
  149. const internal = `Pads measured 2 mm on the left ${stamp}`
  150. const customer = `Front pads replaced, discs within limits ${stamp}`
  151. await typeInNotes(page, page.getByTestId('notes-internal'), internal)
  152. await typeInNotes(page, page.getByTestId('notes-customer'), customer)
  153. await saveWorkOrder(page)
  154. await page.reload()
  155. await settle(page)
  156. await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(internal)
  157. await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toContainText(customer)
  158. })
  159. test('tells a concern as condition, cause, correction and confirmation', async ({ page }) => {
  160. await openModern(page, factsJob)
  161. const concerns = page.getByTestId('customer-concerns')
  162. const story = concerns.getByTestId('concern-story').first()
  163. const words = {
  164. condition: `Squeal from the front left when braking ${stamp}`,
  165. cause: 'Inner pad worn to the backing plate',
  166. correction: 'Replaced front pads and cleaned the slides',
  167. confirm: 'Test drive, no noise',
  168. }
  169. await fillSettled(
  170. story.getByPlaceholder('What the customer reported, or what you observed'),
  171. words.condition
  172. )
  173. await story.getByTestId('concern-step-cause').click()
  174. await story.getByPlaceholder('What testing showed the root cause to be').fill(words.cause)
  175. await story.getByTestId('concern-step-correction').click()
  176. await story.getByPlaceholder('What was done to fix it').fill(words.correction)
  177. await story.getByTestId('concern-step-confirm').click()
  178. await story
  179. .getByPlaceholder('How the fix was checked: test drive, re-scan, symptom gone')
  180. .fill(words.confirm)
  181. await story.getByTestId('concern-confirmed').click()
  182. await expect(story).toContainText('Stamped with your name when you save')
  183. await saveWorkOrder(page)
  184. await page.reload()
  185. await settle(page)
  186. const saved = page.getByTestId('customer-concerns').getByTestId('concern-story').first()
  187. await expect(saved.getByTestId('concern-step-condition')).toContainText(words.condition)
  188. await expect(saved.getByTestId('concern-step-cause')).toContainText(words.cause)
  189. await expect(saved.getByTestId('concern-step-correction')).toContainText(words.correction)
  190. await expect(saved.getByTestId('concern-step-confirm')).toContainText(words.confirm)
  191. await saved.getByTestId('concern-step-confirm').click()
  192. // The stamp is the server's: who ticked it, and when.
  193. await expect(saved).toContainText('Confirmed by')
  194. await expect(page.getByTestId('concerns-confirmed')).toHaveText('1 of 1 confirmed')
  195. })
  196. test('asks before completing a job with a concern nobody confirmed', async ({ page }) => {
  197. await openModern(page, factsJob)
  198. const concerns = page.getByTestId('customer-concerns')
  199. const stories = concerns.getByTestId('concern-story')
  200. const add = concerns.getByRole('button', { name: 'Add another' })
  201. // A blank row may already be waiting; otherwise ask for one.
  202. if (await add.isEnabled()) {
  203. const before = await stories.count()
  204. await expect(async () => {
  205. await add.click()
  206. await expect(stories).toHaveCount(before + 1, { timeout: 2_000 })
  207. }).toPass({ timeout: 30_000 })
  208. }
  209. const open = `Aircon smells musty ${stamp}`
  210. await stories
  211. .last()
  212. .getByPlaceholder('What the customer reported, or what you observed')
  213. .fill(open)
  214. await saveWorkOrder(page)
  215. const question = page.getByRole('alertdialog', { name: 'Complete with unconfirmed concerns?' })
  216. await stepper(page)
  217. .getByRole('button', { name: /Completed/ })
  218. .click()
  219. await expect(question).toContainText(open)
  220. await question.getByRole('button', { name: 'Cancel' }).click()
  221. await expect(question).toBeHidden()
  222. await expect(stepper(page).getByRole('button', { name: /Completed/ })).not.toHaveAttribute(
  223. 'aria-current',
  224. 'step'
  225. )
  226. await stepper(page)
  227. .getByRole('button', { name: /Completed/ })
  228. .click()
  229. await question.getByRole('button', { name: 'Complete anyway' }).click()
  230. await expectStatus(page, /Completed/)
  231. await saveWorkOrder(page)
  232. await page.reload()
  233. await settle(page)
  234. await expectStatus(page, /Completed/)
  235. })
  236. test('files photos and video on the job, shown to the customer until hidden', async ({
  237. page,
  238. }) => {
  239. await openModern(page, filesJob)
  240. const files = page.getByTestId('files-media')
  241. const tiles = files.getByTestId('media-tile')
  242. // A photo: shown to the customer from the moment it lands, then hidden.
  243. await files.locator('input[type="file"]').setInputFiles({
  244. name: `e2e-photo-${stamp}.png`,
  245. mimeType: 'image/png',
  246. buffer: TINY_PNG,
  247. })
  248. await expect(tiles).toHaveCount(1)
  249. await expect(tiles.first()).toContainText('Customer can see')
  250. await expect(async () => {
  251. await tiles.first().getByRole('button', { name: 'Hide from the customer' }).click()
  252. await expect(tiles.first()).not.toContainText('Customer can see', { timeout: 2_000 })
  253. }).toPass({ timeout: 30_000 })
  254. // Read back: the switch turns before the write lands.
  255. await expect(async () => {
  256. await page.reload()
  257. await settle(page)
  258. await expect(page.getByTestId('media-tile').first()).not.toContainText('Customer can see', {
  259. timeout: 2_000,
  260. })
  261. }).toPass({ timeout: 30_000 })
  262. // A video goes to the customer too, as on the classic page: the shared
  263. // link plays it. And it plays here, not in a new tab.
  264. const clip = `e2e-clip-${stamp}.mp4`
  265. await files.getByRole('tab', { name: /^Video/ }).click()
  266. await files.locator('input[type="file"]').setInputFiles({
  267. name: clip,
  268. mimeType: 'video/mp4',
  269. // The server keeps the bytes as they came when it cannot re-encode them.
  270. buffer: Buffer.from(`not really a video ${stamp}`),
  271. })
  272. await expect(tiles).toHaveCount(1)
  273. await expect(tiles.first()).toContainText('Customer can see')
  274. await tiles.first().getByRole('button', { name: clip }).click()
  275. const player = page.getByRole('dialog', { name: clip })
  276. await expect(player.locator('video')).toHaveAttribute('src', /\/api\/protected\/files\//)
  277. await page.keyboard.press('Escape')
  278. await expect(player).toBeHidden()
  279. })
  280. test('lands a link to an old tab on that tab of the files card', async ({ page }) => {
  281. await page.goto(`${filesJob}?tab=documents`)
  282. await settle(page)
  283. const files = page.getByTestId('files-media')
  284. await expect(files.getByRole('tab', { name: /^Documents/ })).toHaveAttribute(
  285. 'aria-selected',
  286. 'true'
  287. )
  288. await expect(files).toBeInViewport()
  289. })
  290. test("takes photos on a phone that scanned the desk's code, and shows them at the desk as they land", async ({
  291. page,
  292. browser,
  293. }) => {
  294. await openModern(page, factsJob)
  295. const files = page.getByTestId('files-media')
  296. const before = await files.getByTestId('media-tile').count()
  297. const dialog = page.getByTestId('photo-handoff-dialog')
  298. await expect(async () => {
  299. await files.getByTestId('photo-handoff-open').click()
  300. await expect(dialog).toBeVisible({ timeout: 2_000 })
  301. }).toPass({ timeout: 30_000 })
  302. const link = dialog.getByTestId('photo-handoff-link')
  303. await expect(link).toHaveValue(/\/p\/ph1\./)
  304. const url = await link.inputValue()
  305. // The phone: another browser, signed in to nothing.
  306. const phone = await browser.newContext({
  307. storageState: { cookies: [], origins: [] },
  308. viewport: { width: 390, height: 844 },
  309. })
  310. const phonePage = await phone.newPage()
  311. await phonePage.goto(url)
  312. await settle(phonePage)
  313. await expect(phonePage.getByRole('heading', { name: 'Add photos and documents' })).toBeVisible()
  314. await expect(phonePage.getByRole('button', { name: 'Take photo' })).toBeVisible()
  315. // The workshop's own page, as far as the customer holding the phone can tell.
  316. await expect(phonePage.getByTestId('photo-handoff-workshop')).not.toBeEmpty()
  317. await phonePage.getByTestId('photo-handoff-library').setInputFiles({
  318. name: `e2e-phone-${stamp}.png`,
  319. mimeType: 'image/png',
  320. buffer: TINY_PNG,
  321. })
  322. await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('1 file added')
  323. // And a customer's paperwork: a PDF goes on the job as a document.
  324. await phonePage.getByTestId('photo-handoff-document').setInputFiles({
  325. name: `e2e-registration-${stamp}.pdf`,
  326. mimeType: 'application/pdf',
  327. buffer: await makePdf([`Registration ${stamp}`]),
  328. })
  329. await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('2 files added')
  330. // A link somebody changed opens nothing.
  331. await phonePage.goto(url.replace(/.$/, (last) => (last === 'A' ? 'B' : 'A')))
  332. await expect(phonePage.getByTestId('photo-handoff-problem')).toContainText(
  333. 'This link does not work'
  334. )
  335. await phone.close()
  336. // At the desk the photo arrives without anybody reloading.
  337. await expect(dialog.getByTestId('photo-handoff-received')).toHaveText('2 files received', {
  338. timeout: 15_000,
  339. })
  340. await dialog.getByRole('button', { name: 'Done' }).click()
  341. await expect(files.getByTestId('media-tile')).toHaveCount(before + 1)
  342. await files.getByRole('tab', { name: /^Documents/ }).click()
  343. // A document's name is its caption until somebody writes one.
  344. await expect(files.getByPlaceholder(`e2e-registration-${stamp}.pdf`)).toHaveCount(1)
  345. })
  346. test.describe('the technician list', () => {
  347. let technicianId = ''
  348. const technicianName = `E2E Mechanic ${stamp}`
  349. test.beforeAll(async () => {
  350. technicianId = await insertTechnician(await ownerOrganizationId(), technicianName)
  351. })
  352. test.afterAll(async () => {
  353. await deleteTechnicians([technicianId])
  354. })
  355. test('puts a technician on the job with one click', async ({ page }) => {
  356. await openModern(page, scheduleJob)
  357. const list = page.getByRole('radiogroup', { name: 'Technician' })
  358. const technician = list.getByRole('radio', { name: new RegExp(technicianName) })
  359. await expect(async () => {
  360. await technician.click()
  361. await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
  362. }).toPass({ timeout: 30_000 })
  363. await expect
  364. .poll(async () => (await jobAssignment(jobIdOf(scheduleJob))).technicianId)
  365. .toBe(technicianId)
  366. await page.reload()
  367. await settle(page)
  368. await expect(
  369. page
  370. .getByRole('radiogroup', { name: 'Technician' })
  371. .getByRole('radio', { name: new RegExp(technicianName) })
  372. ).toHaveAttribute('aria-checked', 'true')
  373. // The way to the workshop's defaults is here, as on the classic picker.
  374. await expect(page.getByRole('link', { name: 'Set Defaults' })).toHaveAttribute(
  375. 'href',
  376. '/settings/workshop'
  377. )
  378. })
  379. })
  380. test.describe('on a locked invoice', () => {
  381. let lockedJob = ''
  382. test.beforeAll(async ({ browser }) => {
  383. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  384. // On first, so the share below is a send the lock counts.
  385. await setInvoiceLock(page, { enabled: true, trigger: 'sent' })
  386. lockedJob = await jobWithPart(page, `E2E modern locked ${stamp}`)
  387. await shareLink(page)
  388. await page.close()
  389. })
  390. test.afterAll(async ({ browser }) => {
  391. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  392. await setInvoiceLock(page, { enabled: false, trigger: 'sent' })
  393. await page.close()
  394. })
  395. test('freezes what is owed and nothing else', async ({ page }) => {
  396. await openModern(page, lockedJob)
  397. await expect(
  398. page.getByText('This invoice is locked because it has been sent', { exact: true })
  399. ).toBeVisible()
  400. // Frozen: the lines, the mileage, and the notes the invoice prints.
  401. await expect(partRows(page).first()).toBeDisabled()
  402. await expect(page.locator('#mileage')).toBeDisabled()
  403. // Still readable: the vehicle's details open on a locked invoice too.
  404. await page.getByTestId('job-facts').getByRole('button', { name: 'More info' }).click()
  405. await expect(page.getByTestId('vehicle-details')).toBeVisible()
  406. await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toHaveAttribute(
  407. 'contenteditable',
  408. 'false'
  409. )
  410. // Still moving: the status, saved on its own. "Waiting Parts" is filed
  411. // under "In Progress", in the menu behind that stage's arrow, and the
  412. // stage then reads "In Progress: Waiting Parts".
  413. await expect(async () => {
  414. await page.getByTestId('status-menu-in-progress').click()
  415. await expect(page.getByRole('menuitem', { name: 'Waiting Parts' })).toBeVisible({
  416. timeout: 2_000,
  417. })
  418. }).toPass({ timeout: 30_000 })
  419. await page.getByRole('menuitem', { name: 'Waiting Parts' }).click()
  420. await expectStatus(page, /Waiting Parts/)
  421. // Still written: the internal notes, saved on their own.
  422. const note = `Customer will collect on Friday ${stamp}`
  423. await typeInNotes(page, page.getByTestId('notes-internal'), note)
  424. await expect(page.getByText('Saved', { exact: true })).toBeVisible()
  425. // Still paid: the bar's button opens a form that works, and Enter in
  426. // the amount records the payment rather than saving the job.
  427. const bar = page.getByTestId('money-bar')
  428. await bar.getByRole('button', { name: 'Take payment' }).click()
  429. const amount = page.locator('#paymentAmount')
  430. await expect(amount).toBeEnabled()
  431. await expect(amount).toBeFocused()
  432. await amount.fill('100')
  433. await amount.press('Enter')
  434. await expect(page.getByText('Payment recorded', { exact: true })).toBeVisible()
  435. // Recording a payment offers to tell the customer; not today.
  436. const notify = page.getByRole('dialog', { name: /^Notify / })
  437. await expect(notify).toBeVisible()
  438. await page.keyboard.press('Escape')
  439. await expect(notify).toBeHidden()
  440. await expect(bar.getByTestId('money-paid')).toContainText('100')
  441. await expect.poll(async () => (await paymentsFor(jobIdOf(lockedJob))).length).toBe(1)
  442. expect((await paymentsFor(jobIdOf(lockedJob)))[0].amount).toBe(100)
  443. await page.reload()
  444. await settle(page)
  445. await expectStatus(page, /Waiting Parts/)
  446. await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(note)
  447. const activity = page
  448. .getByRole('heading', { name: 'Activity', exact: true })
  449. .locator('xpath=ancestor::*[.//ol][1]')
  450. await expect(activity).toContainText('Invoice link shared')
  451. await expect(activity).toContainText('Payment of')
  452. })
  453. })
  454. })