|
|
@@ -5,10 +5,16 @@
|
|
|
* guards leaving the site, so a retyped title followed by the back arrow was
|
|
|
* lost. The page now holds the click, saves, and lets the same click through
|
|
|
* once the save went through.
|
|
|
+ *
|
|
|
+ * A save the rules refuse (a required custom field left empty) used to keep
|
|
|
+ * the person on the page with no way out: every link answered with the same
|
|
|
+ * error. They now get the reason and a choice: stay and fix it, or leave
|
|
|
+ * without the unsaved changes.
|
|
|
*/
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
-import { act, renderHook } from '@testing-library/react'
|
|
|
+import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
|
|
import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
|
|
|
+import { LeaveUnsavedDialog } from '@/features/vehicles/Components/service-page/LeaveUnsavedDialog'
|
|
|
|
|
|
const initialData = {
|
|
|
id: 'rec-1',
|
|
|
@@ -37,6 +43,21 @@ function renderForm() {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+const REFUSED = 'Please fill in all required custom fields'
|
|
|
+
|
|
|
+/**
|
|
|
+ * The form the save submits through, with the rules refusing it: the page's
|
|
|
+ * handleSubmit records why and returns before marking anything saved.
|
|
|
+ */
|
|
|
+function refusingForm(result: { current: ReturnType<typeof useServiceFormState> }) {
|
|
|
+ const form = document.createElement('form')
|
|
|
+ document.body.appendChild(form)
|
|
|
+ result.current.formRef.current = form
|
|
|
+ return vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
|
|
|
+ result.current.lastSaveErrorRef.current = REFUSED
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
/** A link to another page of the app, in the document so the capture listener sees it. */
|
|
|
function link(href = '/vehicles/v-1') {
|
|
|
const anchor = document.createElement('a')
|
|
|
@@ -55,6 +76,8 @@ function click(anchor: HTMLAnchorElement) {
|
|
|
beforeEach(() => vi.useFakeTimers())
|
|
|
afterEach(() => {
|
|
|
vi.useRealTimers()
|
|
|
+ // Unmount first: the dialog renders in a portal under body.
|
|
|
+ cleanup()
|
|
|
document.body.innerHTML = ''
|
|
|
})
|
|
|
|
|
|
@@ -90,13 +113,9 @@ describe('leaving with unsaved changes', () => {
|
|
|
expect(result.current.hasUnsavedChanges).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('keeps the page when the save was refused', async () => {
|
|
|
+ it('keeps the page when the save was refused, and asks why not leave', async () => {
|
|
|
const { result } = renderForm()
|
|
|
- const form = document.createElement('form')
|
|
|
- document.body.appendChild(form)
|
|
|
- result.current.formRef.current = form
|
|
|
- // The rules refuse it: handleSubmit returns before marking anything saved.
|
|
|
- vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined)
|
|
|
+ refusingForm(result)
|
|
|
|
|
|
act(() => result.current.markDirty())
|
|
|
const anchor = link()
|
|
|
@@ -109,6 +128,166 @@ describe('leaving with unsaved changes', () => {
|
|
|
|
|
|
expect(released).not.toHaveBeenCalled()
|
|
|
expect(result.current.hasUnsavedChanges).toBe(true)
|
|
|
+ expect(result.current.blockedLeave?.reason).toBe(REFUSED)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leaves without saving when asked, through the same link', async () => {
|
|
|
+ const { result } = renderForm()
|
|
|
+ const requestSubmit = refusingForm(result)
|
|
|
+
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ const anchor = link()
|
|
|
+ const released = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ anchor.addEventListener('click', released)
|
|
|
+ click(anchor)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(200)
|
|
|
+ })
|
|
|
+ expect(result.current.blockedLeave).not.toBeNull()
|
|
|
+
|
|
|
+ act(() => result.current.leaveWithoutSaving())
|
|
|
+
|
|
|
+ expect(released).toHaveBeenCalledOnce()
|
|
|
+ expect(result.current.blockedLeave).toBeNull()
|
|
|
+ expect(result.current.hasUnsavedChanges).toBe(false)
|
|
|
+ // Nothing more is tried on the way out: the autosave timer is gone too.
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(10_000)
|
|
|
+ })
|
|
|
+ expect(requestSubmit).toHaveBeenCalledOnce()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stays with every edit when asked, and asks again on the next link', async () => {
|
|
|
+ const { result } = renderForm()
|
|
|
+ refusingForm(result)
|
|
|
+
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ const anchor = link()
|
|
|
+ const released = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ anchor.addEventListener('click', released)
|
|
|
+ click(anchor)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(200)
|
|
|
+ })
|
|
|
+
|
|
|
+ act(() => result.current.stayOnPage())
|
|
|
+ expect(result.current.blockedLeave).toBeNull()
|
|
|
+ expect(result.current.hasUnsavedChanges).toBe(true)
|
|
|
+ expect(released).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ click(anchor)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(200)
|
|
|
+ })
|
|
|
+ expect(result.current.blockedLeave?.reason).toBe(REFUSED)
|
|
|
+ expect(released).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('waits for a save already running instead of calling it refused', async () => {
|
|
|
+ const { result } = renderForm()
|
|
|
+ const requestSubmit = refusingForm(result)
|
|
|
+
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ // An autosave is on its way to the server.
|
|
|
+ result.current.isSavingRef.current = true
|
|
|
+ const anchor = link()
|
|
|
+ const released = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ anchor.addEventListener('click', released)
|
|
|
+ click(anchor)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(500)
|
|
|
+ })
|
|
|
+ expect(result.current.blockedLeave).toBeNull()
|
|
|
+ expect(released).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ // It lands.
|
|
|
+ act(() => {
|
|
|
+ result.current.setHasUnsavedChanges(false)
|
|
|
+ result.current.isSavingRef.current = false
|
|
|
+ })
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(200)
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(released).toHaveBeenCalledOnce()
|
|
|
+ expect(requestSubmit).not.toHaveBeenCalled()
|
|
|
+ expect(result.current.blockedLeave).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('saves an edit made while an autosave ran before leaving', async () => {
|
|
|
+ const { result } = renderForm()
|
|
|
+ const form = document.createElement('form')
|
|
|
+ document.body.appendChild(form)
|
|
|
+ result.current.formRef.current = form
|
|
|
+ // Each save covers the edits made before it started, as handleSubmit does.
|
|
|
+ const saves: number[] = []
|
|
|
+ const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
|
|
|
+ const revision = result.current.editRevisionRef.current
|
|
|
+ saves.push(revision)
|
|
|
+ result.current.isSavingRef.current = true
|
|
|
+ setTimeout(() => {
|
|
|
+ act(() => result.current.markSaved(revision))
|
|
|
+ result.current.isSavingRef.current = false
|
|
|
+ }, 100)
|
|
|
+ })
|
|
|
+
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ // The autosave goes out...
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(5000)
|
|
|
+ })
|
|
|
+ expect(requestSubmit).toHaveBeenCalledOnce()
|
|
|
+ // ...a note is typed while it is on its way, and a link is clicked.
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ const anchor = link()
|
|
|
+ const released = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ anchor.addEventListener('click', released)
|
|
|
+ click(anchor)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(400)
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(saves).toHaveLength(2)
|
|
|
+ expect(saves[1]).toBeGreaterThan(saves[0])
|
|
|
+ expect(released).toHaveBeenCalledOnce()
|
|
|
+ expect(result.current.hasUnsavedChanges).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('follows only the first link while a save is on its way', async () => {
|
|
|
+ const { result } = renderForm()
|
|
|
+ const form = document.createElement('form')
|
|
|
+ document.body.appendChild(form)
|
|
|
+ result.current.formRef.current = form
|
|
|
+ vi.spyOn(form, 'requestSubmit').mockImplementation(() => {
|
|
|
+ const revision = result.current.editRevisionRef.current
|
|
|
+ result.current.isSavingRef.current = true
|
|
|
+ setTimeout(() => {
|
|
|
+ act(() => result.current.markSaved(revision))
|
|
|
+ result.current.isSavingRef.current = false
|
|
|
+ }, 100)
|
|
|
+ })
|
|
|
+
|
|
|
+ act(() => result.current.markDirty())
|
|
|
+ // The autosave is on its way when both links are clicked.
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(5000)
|
|
|
+ })
|
|
|
+ expect(result.current.isSavingRef.current).toBe(true)
|
|
|
+ const first = link('/vehicles/v-1')
|
|
|
+ const second = link('/work-orders')
|
|
|
+ const toFirst = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ const toSecond = vi.fn((e: Event) => e.preventDefault())
|
|
|
+ first.addEventListener('click', toFirst)
|
|
|
+ second.addEventListener('click', toSecond)
|
|
|
+ click(first)
|
|
|
+ expect(click(second).defaultPrevented).toBe(true)
|
|
|
+ await act(async () => {
|
|
|
+ await vi.advanceTimersByTimeAsync(400)
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(toFirst).toHaveBeenCalledOnce()
|
|
|
+ expect(toSecond).not.toHaveBeenCalled()
|
|
|
+ expect(result.current.blockedLeave).toBeNull()
|
|
|
})
|
|
|
|
|
|
it('leaves links elsewhere, and clean pages, alone', () => {
|
|
|
@@ -124,3 +303,28 @@ describe('leaving with unsaved changes', () => {
|
|
|
expect(click(link()).defaultPrevented).toBe(false)
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+describe('the leave dialog', () => {
|
|
|
+ it('says why the save was refused and offers both ways', () => {
|
|
|
+ const onStay = vi.fn()
|
|
|
+ const onLeave = vi.fn()
|
|
|
+ render(<LeaveUnsavedDialog open reason={REFUSED} onStay={onStay} onLeave={onLeave} />)
|
|
|
+
|
|
|
+ expect(screen.getByText('Your changes are not saved')).toBeInTheDocument()
|
|
|
+ expect(screen.getByText(REFUSED)).toBeInTheDocument()
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: 'Leave without saving' }))
|
|
|
+ expect(onLeave).toHaveBeenCalledOnce()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stays by default', () => {
|
|
|
+ const onStay = vi.fn()
|
|
|
+ render(<LeaveUnsavedDialog open reason={null} onStay={onStay} onLeave={vi.fn()} />)
|
|
|
+
|
|
|
+ expect(screen.getByText('The latest changes could not be saved.')).toBeInTheDocument()
|
|
|
+ const stay = screen.getByRole('button', { name: 'Stay and fix' })
|
|
|
+ expect(stay).toHaveFocus()
|
|
|
+ fireEvent.click(stay)
|
|
|
+ expect(onStay).toHaveBeenCalled()
|
|
|
+ })
|
|
|
+})
|