quote.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import { expect, type Locator, type Page } from '@playwright/test'
  2. /**
  3. * Driving a quote through the browser.
  4. *
  5. * The quote editor is close to the work order's and not the same: it renders
  6. * once at any width (a JavaScript breakpoint rather than two hidden copies),
  7. * and its Add buttons append rather than prepend, so the row just made is the
  8. * last one. The placeholders are shared, which is why these read the same as
  9. * the work order helpers next door.
  10. */
  11. /** A fresh quote against a seeded vehicle, open in the editor. */
  12. export async function newQuote(page: Page, title: string, vehicle = 'Camry'): Promise<string> {
  13. // The list opens its dialog from the query string, which saves hunting for
  14. // a button that moves between the toolbar and a mobile icon.
  15. await page.goto('/quotes?create=true')
  16. await page.locator('#new-quote-title').fill(title)
  17. // The picker is a button in the combobox role whose only name is its
  18. // placeholder, so it is found by what it says.
  19. await page
  20. .getByRole('combobox')
  21. .filter({ hasText: /select vehicle/i })
  22. .click()
  23. await page.getByPlaceholder('Select vehicle...').fill(vehicle)
  24. await page
  25. .getByRole('option', { name: new RegExp(vehicle, 'i') })
  26. .first()
  27. .click()
  28. await page.getByRole('button', { name: 'Create Quote' }).click()
  29. await page.waitForURL(/\/quotes\/[^/]+$/)
  30. await expect(page.locator('#title')).toHaveValue(title)
  31. return page.url()
  32. }
  33. /** The row an editor input belongs to: the nearest ancestor holding its sibling. */
  34. function rowContaining(field: Locator, siblingPlaceholder: string): Locator {
  35. return field.locator(`xpath=ancestor::*[.//input[@placeholder="${siblingPlaceholder}"]][1]`)
  36. }
  37. /**
  38. * The row a part field sits in. Unlike the work order's, the quote's number
  39. * inputs carry no placeholders at all, so the row is found as the nearest
  40. * ancestor that holds one.
  41. */
  42. function partRowOf(nameField: Locator): Locator {
  43. return nameField.locator('xpath=ancestor::div[.//input[@type="number"]][1]')
  44. }
  45. export interface QuotePart {
  46. name: string
  47. quantity: number
  48. unitPrice: number
  49. }
  50. /** Adds a part line. The numbers sit in the order the editor prints them. */
  51. export async function addQuotePart(page: Page, part: QuotePart): Promise<void> {
  52. const rows = page.getByPlaceholder('Name *')
  53. const before = await rows.count()
  54. await expect(async () => {
  55. await page.getByRole('button', { name: 'Add Part', exact: true }).first().click()
  56. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  57. }).toPass({ timeout: 30_000 })
  58. // Appended, so the new row is the last.
  59. const name = rows.last()
  60. await name.fill(part.name)
  61. // Quantity, cost, markup, unit price, in the order the editor prints them.
  62. const numbers = partRowOf(name).locator('input[type="number"]')
  63. await numbers.nth(0).fill(String(part.quantity))
  64. await numbers.nth(3).fill(String(part.unitPrice))
  65. }
  66. export interface QuoteLabor {
  67. description: string
  68. hours: number
  69. rate: number
  70. }
  71. /** Adds an hourly labour line. */
  72. export async function addQuoteLabor(page: Page, labor: QuoteLabor): Promise<void> {
  73. const rows = page.getByPlaceholder('Description *')
  74. const before = await rows.count()
  75. await expect(async () => {
  76. await page.getByRole('button', { name: 'Add Labor', exact: true }).first().click()
  77. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  78. }).toPass({ timeout: 30_000 })
  79. const description = rows.last()
  80. await description.fill(labor.description)
  81. const numbers = rowContaining(description, 'Hours').locator('input[type="number"]')
  82. await numbers.nth(0).fill(String(labor.hours))
  83. await numbers.nth(1).fill(String(labor.rate))
  84. }
  85. /** Saves the quote and waits for the header to say so. */
  86. export async function saveQuote(page: Page): Promise<void> {
  87. await page.getByRole('button', { name: 'Save', exact: true }).click()
  88. await expect(page.getByText('Quote saved')).toBeVisible()
  89. }
  90. /** The public link for the open quote, generating it if there is none yet. */
  91. export async function quoteShareLink(page: Page): Promise<string> {
  92. await expect(async () => {
  93. await page.getByRole('button', { name: 'Share', exact: true }).click()
  94. await expect(page.getByRole('dialog', { name: 'Share Quote' })).toBeVisible({ timeout: 2_000 })
  95. }).toPass({ timeout: 30_000 })
  96. const dialog = page.getByRole('dialog', { name: 'Share Quote' })
  97. const generate = dialog.getByRole('button', { name: /generate public link/i })
  98. if (await generate.isVisible().catch(() => false)) await generate.click()
  99. const field = dialog.locator('input[readonly]')
  100. await expect(field).toHaveValue(/\/share\/quote\//)
  101. const url = await field.inputValue()
  102. await page.keyboard.press('Escape')
  103. return url
  104. }
  105. /** The public quote PDF behind a share link. */
  106. export function quotePdfUrl(shareUrl: string): string {
  107. const [orgId, token] = new URL(shareUrl).pathname.split('/').slice(-2)
  108. return `/api/public/share/quote/${orgId}/${token}/pdf`
  109. }