Просмотр исходного кода

Keep a half-written support request when the page reloads (#567)

* Keep a half-written support request when the page reloads

The support form held its subject and message only in memory, so a tab
that was sent to the sign-in page after its session ran out, or reloaded
for a new build, came back with an empty form.

The draft now lives in sessionStorage for the life of the tab. It is
restored when the form mounts, written as the person types, and cleared
once the request is sent or the form is reset. It carries the id of the
person who wrote it, and is dropped instead of shown when someone else
signs in on the same tab. Attachments are not kept.

* Keep a subject a page fills in when the support form reloads
Bernt Christian Egeland 4 дней назад
Родитель
Сommit
6af653e598

+ 163 - 0
src/__tests__/features/support/support-draft.test.tsx

@@ -0,0 +1,163 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SupportBubble } from '@/features/support/Components/SupportBubble'
+import {
+  SUPPORT_OPEN_EVENT,
+  type SupportOpenDetail,
+} from '@/features/support/Lib/supportVisibility'
+
+// Someone spent several minutes describing a problem in the support form. The
+// tab had lost its session, a pause in the typing let it leave for the sign-in
+// page, and they came back to an empty form. The page can be replaced under
+// the form at any time (sign-in, a reload for a new build), so what is typed
+// has to outlive the component. Unmounting and mounting again is that trip.
+
+function openForm() {
+  fireEvent.click(screen.getByRole('button', { name: 'Contact support' }))
+}
+
+function subjectField() {
+  return screen.getByLabelText('Subject')
+}
+
+function messageField() {
+  return screen.getByLabelText('Description')
+}
+
+function write(subject: string, message: string) {
+  fireEvent.change(subjectField(), { target: { value: subject } })
+  fireEvent.change(messageField(), { target: { value: message } })
+}
+
+/** What a page does when it opens the form, such as "Suggest an integration". */
+function openFromPage(detail: SupportOpenDetail) {
+  act(() => {
+    window.dispatchEvent(new CustomEvent<SupportOpenDetail>(SUPPORT_OPEN_EVENT, { detail }))
+  })
+}
+
+function answerSupportRequestWith(ok: boolean) {
+  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok, json: async () => ({}) }))
+}
+
+beforeEach(() => {
+  window.sessionStorage.clear()
+  window.localStorage.clear()
+})
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+describe('A half-written support request', () => {
+  it('is still in the form after the page is replaced', () => {
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('Invoices print without the logo', 'It started this morning, after I changed the design.')
+    first.unmount()
+
+    render(<SupportBubble userId="anna" />)
+    openForm()
+
+    expect(subjectField()).toHaveValue('Invoices print without the logo')
+    expect(messageField()).toHaveValue('It started this morning, after I changed the design.')
+  })
+
+  it('does not come back once it has been sent', async () => {
+    answerSupportRequestWith(true)
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('Invoices print without the logo', 'It started this morning.')
+    fireEvent.click(screen.getByRole('button', { name: 'Send request' }))
+    expect(await screen.findByText('Request sent')).toBeInTheDocument()
+    first.unmount()
+
+    render(<SupportBubble userId="anna" />)
+    openForm()
+
+    expect(subjectField()).toHaveValue('')
+    expect(messageField()).toHaveValue('')
+  })
+
+  it('is kept when sending fails, so it can be sent again after a reload', async () => {
+    answerSupportRequestWith(false)
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('Invoices print without the logo', 'It started this morning.')
+    fireEvent.click(screen.getByRole('button', { name: 'Send request' }))
+    expect(
+      await screen.findByText('The request could not be sent. Please try again.')
+    ).toBeInTheDocument()
+    first.unmount()
+
+    render(<SupportBubble userId="anna" />)
+    openForm()
+
+    expect(subjectField()).toHaveValue('Invoices print without the logo')
+    expect(messageField()).toHaveValue('It started this morning.')
+  })
+
+  it('keeps its subject when a page opens the form with one of its own', () => {
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('Invoices print without the logo', 'It started this morning.')
+    first.unmount()
+
+    render(<SupportBubble userId="anna" />)
+    openFromPage({ subject: 'Integration suggestion' })
+
+    expect(subjectField()).toHaveValue('Invoices print without the logo')
+    expect(messageField()).toHaveValue('It started this morning.')
+  })
+
+  it('still takes the subject a page hands over when only a description was written', () => {
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('', 'Could you connect to our parts supplier?')
+    first.unmount()
+
+    const second = render(<SupportBubble userId="anna" />)
+    openFromPage({ subject: 'Integration suggestion' })
+
+    expect(subjectField()).toHaveValue('Integration suggestion')
+    expect(messageField()).toHaveValue('Could you connect to our parts supplier?')
+
+    // The page is replaced again before another key is pressed.
+    second.unmount()
+    render(<SupportBubble userId="anna" />)
+    openForm()
+
+    expect(subjectField()).toHaveValue('Integration suggestion')
+    expect(messageField()).toHaveValue('Could you connect to our parts supplier?')
+  })
+
+  it('is not shown to someone else who signs in on the same tab', () => {
+    const first = render(<SupportBubble userId="anna" />)
+    openForm()
+    write('My payslip', 'Something only Anna should read.')
+    first.unmount()
+
+    render(<SupportBubble userId="bjorn" />)
+    openForm()
+
+    expect(subjectField()).toHaveValue('')
+    expect(messageField()).toHaveValue('')
+  })
+
+  it('can still be written where the browser refuses storage', () => {
+    const refuse = () => {
+      throw new DOMException('The operation is insecure.', 'SecurityError')
+    }
+    vi.spyOn(Storage.prototype, 'getItem').mockImplementation(refuse)
+    vi.spyOn(Storage.prototype, 'setItem').mockImplementation(refuse)
+    vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(refuse)
+
+    render(<SupportBubble userId="anna" />)
+    openForm()
+    write('Invoices print without the logo', 'It started this morning.')
+
+    expect(subjectField()).toHaveValue('Invoices print without the logo')
+    expect(messageField()).toHaveValue('It started this morning.')
+  })
+})

+ 1 - 1
src/app/(authenticated)/layout.tsx

@@ -432,7 +432,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                 isSuperAdmin={data.isSuperAdmin}
                 tireHotelEnabled={tireHotelEnabled}
               />
-              {supportEnabled && <SupportBubble />}
+              {supportEnabled && <SupportBubble userId={data.userId} />}
             </WhiteLabelCtaProvider>
           </LicenseExpiryProvider>
         </EngineSizeUnitProvider>

+ 30 - 1
src/features/support/Components/SupportBubble.tsx

@@ -24,6 +24,11 @@ import {
   MAX_SUBJECT_LENGTH,
   MAX_TOTAL_ATTACHMENT_BYTES,
 } from '@/features/support/Lib/supportRequest'
+import {
+  clearSupportDraft,
+  readSupportDraft,
+  writeSupportDraft,
+} from '@/features/support/Lib/supportDraft'
 import {
   SUPPORT_OPEN_EVENT,
   SUPPORT_VISIBILITY_EVENT,
@@ -60,7 +65,7 @@ function errorKey(code: unknown): string {
     : 'errors.sendFailed'
 }
 
-export function SupportBubble() {
+export function SupportBubble({ userId }: { userId: string }) {
   const t = useTranslations('support')
   const [isOpen, setIsOpen] = useState(false)
   // Assume hidden until localStorage has been read. Rendering the button and
@@ -111,12 +116,34 @@ export function SupportBubble() {
 
   const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
 
+  // What was being written when the page was last replaced under the form: a
+  // tab that lost its session comes back from the sign-in page, and a new
+  // build asks for a reload. Read after mount, like the visibility above, so
+  // the server and the first client render agree.
+  const [draftRestored, setDraftRestored] = useState(false)
+  useEffect(() => {
+    const draft = readSupportDraft(userId)
+    if (draft) {
+      setSubject((current) => current || draft.subject)
+      setMessage((current) => current || draft.message)
+    }
+    setDraftRestored(true)
+  }, [userId])
+
+  // Kept whenever the fields change, whether typed or handed over by a page
+  // that opened the form. Not before the stored draft has been read back, or
+  // the empty first render would wipe it.
+  useEffect(() => {
+    if (draftRestored) writeSupportDraft(userId, { subject, message })
+  }, [draftRestored, userId, subject, message])
+
   const reset = useCallback(() => {
     setSubject('')
     setMessage('')
     setFiles([])
     setError(null)
     setStatus('idle')
+    clearSupportDraft()
   }, [])
 
   // The settings page opens the widget for someone who cannot find it, and
@@ -230,6 +257,8 @@ export function SupportBubble() {
         setSubject('')
         setMessage('')
         setFiles([])
+        // Sent, so it must not come back as a draft on the next page load.
+        clearSupportDraft()
       } catch {
         setError(t('errors.sendFailed'))
         setStatus('idle')

+ 68 - 0
src/features/support/Lib/supportDraft.ts

@@ -0,0 +1,68 @@
+/**
+ * The support form's unsent subject and message, kept for as long as the tab.
+ *
+ * The form lives in React state, and the page under it can be replaced while
+ * someone is still writing: a tab whose session ran out is sent to the sign-in
+ * page and comes back, and a new build asks for a reload. Minutes of typing
+ * went with it. sessionStorage survives both and ends with the tab, so a draft
+ * does not follow the person around for weeks. Attachments are not kept; a
+ * file cannot go into storage.
+ */
+
+export const SUPPORT_DRAFT_STORAGE_KEY = 'torqvoice.support.draft'
+
+export interface SupportDraft {
+  subject: string
+  message: string
+}
+
+/**
+ * The draft this person left in this tab, or null.
+ *
+ * A tab outlives a sign-out, so the draft carries who wrote it. One written by
+ * somebody else is removed rather than handed to whoever signs in next on a
+ * shared computer.
+ */
+export function readSupportDraft(userId: string): SupportDraft | null {
+  if (typeof window === 'undefined') return null
+  try {
+    const raw = window.sessionStorage.getItem(SUPPORT_DRAFT_STORAGE_KEY)
+    if (!raw) return null
+    const parsed: unknown = JSON.parse(raw)
+    if (typeof parsed !== 'object' || parsed === null) return null
+    const { userId: owner, subject, message } = parsed as Record<string, unknown>
+    if (owner !== userId) {
+      window.sessionStorage.removeItem(SUPPORT_DRAFT_STORAGE_KEY)
+      return null
+    }
+    if (typeof subject !== 'string' || typeof message !== 'string') return null
+    return { subject, message }
+  } catch {
+    // Private browsing and locked-down profiles can throw on access, and a
+    // corrupt value does not parse. Either way the form just starts empty.
+    return null
+  }
+}
+
+/** Keeps what is in the form. An empty form leaves nothing behind. */
+export function writeSupportDraft(userId: string, draft: SupportDraft): void {
+  if (typeof window === 'undefined') return
+  try {
+    if (draft.subject || draft.message) {
+      window.sessionStorage.setItem(SUPPORT_DRAFT_STORAGE_KEY, JSON.stringify({ userId, ...draft }))
+    } else {
+      window.sessionStorage.removeItem(SUPPORT_DRAFT_STORAGE_KEY)
+    }
+  } catch {
+    // The draft simply will not survive a reload.
+  }
+}
+
+export function clearSupportDraft(): void {
+  if (typeof window === 'undefined') return
+  try {
+    window.sessionStorage.removeItem(SUPPORT_DRAFT_STORAGE_KEY)
+  } catch {
+    // Nothing was stored, so there is nothing to clear.
+  }
+}