Files
accounted/components/transactions/__tests__/booking-feedback-parity.test.ts
T
93541d7186 fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges (#1633)
* fix(ux): smoothness follow-ups - detail pages, batches, toasts, and the last edges

Follow-up batch to #1629: the six documented deferred items from
dev_docs/loading_states_analysis.md, in the same vocabulary (first-load-only
takeovers, background reconcile behind mounted content, row/button-level
pending, sequence guards).

- Invoice detail pages: kundfaktura and leverantorsfaktura detail no longer
  blank the whole page for one-field changes. fetchInvoice shows the blocking
  spinner/skeleton only before the first paint (or when the pager steps to a
  different invoice); Bokfor / status / finalize / payment / send / Attestera /
  Markera betald / kreditera refetch behind the mounted page, the acting
  button shows a spinner-in-button, and the handlers await the refetch so
  pending covers until the content reflects the new state. The supplier
  detail's single isProcessing boolean became processingAction so the spinner
  lands on the clicked button only. (The leverantorsfakturor LIST
  try/catch/res.ok item was already fixed by #1629.)

- useDestructiveConfirm: confirm(opts, action?) can now carry the destructive
  operation, so the dialog's existing isLoading spinner actually shows while
  it runs, dismissal is blocked meanwhile, and confirm resolves false if the
  action throws. Adopted at the /transactions row delete and the supplier-
  invoice detail delete (which previously permitted duplicate DELETEs with
  zero feedback).

- Batch parallelization: new lib/concurrency.ts mapWithConcurrency (bounded
  worker pool, order-preserving, tested). /transactions batch categorize /
  ignore / delete run per-row requests 5 at a time instead of strictly
  sequentially; the bulkbar counter ticks per completed row.

- Toast-spam reduction: batch categorize rows run silent (exit animation,
  count decrement and state patch stay; no per-row Bokford or generic failure
  toast) and ONE aggregate toast reports "N bokforda[, M misslyckades]" with
  a single Angra alla action that pools the same /uncategorize endpoint over
  every booked row (per-row undo is feasible today, so the aggregate is too).
  Interactive escalations (SI/CI match suggestions, duplicate warning,
  activate-account) deliberately keep their dialogs.

- Underlag row-click flash: InvoiceInboxWorkspace handleSelect seeds the
  detail pane synchronously from the clicked list row and starts the document
  load in parallel with the detail GET (which hydrates on arrival), so a row
  click never flashes the onboarding/empty state, and a stale-response guard
  keeps a slow fetch from overwriting a newer selection.

- #1629 round-2 edges: /pending holds the loading state when a fetch for a
  not-yet-loaded tab FAILS (never renders the previous tab's rows under the
  new tab's header, and never fakes an empty state); /transactions clears
  transactions/skvRows (+ count/paging) and bumps both fetch sequences on
  company switch, and loadSkvRows got the same sequence-guard pattern as
  fetchTransactions.

Gates: full vitest suite green (14772 passed), tsc byte-identical to the
origin/main baseline (stash-diffed), eslint 0 errors on touched files
(warnings identical to baseline), check:guards green, package-lock untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): harden action feedback against stale responses and failures

Address the seven CodeRabbit findings on #1633:

- invoices/[id] + supplier-invoices/[id]: latest-request guard in
  fetchInvoice (sequence token) so a mutation refresh overlapping pager
  navigation can never commit invoice A's state under invoice B's URL;
  the deferred related-document writes are guarded too
- supplier-invoices/[id]: try/catch/finally in approve/book/mark-paid/
  credit/uncredit so a rejected fetch()/json() clears processingAction
  instead of leaving every invoice action disabled until reload
- transactions: extend the skattekonto sequence guard to the
  connection-status write so a status response started under the
  previous company cannot flip the reconnect banner for the new one
- transactions: runCategorize resolves { ok, journalEntryId } so the
  batch aggregate counts a 200-with-null-journal-entry booking (flag
  flip) as success instead of narrating it as misslyckades; Angra alla
  only targets rows with an actual verifikat, since the storno endpoint
  rejects rows without one
- transactions: shared undoneIdsRef lets "Angra alla" cancel a pending
  finishBooking state patch; a fresh booking clears its row's entry so
  re-booked rows still get their delayed patch
- InvoiceInboxWorkspace: monotonic request tokens for the detail and
  document reads so a same-item reload cannot resolve out of order and
  paint a stale snapshot or document URL
- messages: ICU plural for the success part of both partial batch
  descriptions in sv and en (1 bokford, not 1 bokforda)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:30:25 +02:00

150 lines
6.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
/**
* Booking-feedback parity between the two booking paths on the transactions
* page.
*
* The counterparty-template path (Bokför → "Tidigare motparter") used to end a
* successful booking with nothing but `setExitingIds().add(id)`: no "Bokförd"
* toast, no Ångra action, no unbooked-count decrement, and the id was never
* removed from exitingIds again, so an undo would have restored the row's data
* while leaving it filtered out of the inbox. Every other booking path ran
* runCategorize's success tail.
*
* Both now go through one `finishBooking`. This repo runs Vitest in the `node`
* environment and never renders components, so, like the sibling
* invoice-match-dialog tests, these are file-level assertions: the two paths
* must not drift apart again.
*/
const PAGE_SRC = fs.readFileSync(
path.resolve(__dirname, '../../../app/(dashboard)/transactions/page.tsx'),
'utf8',
)
const readMessages = (locale: 'sv' | 'en', namespace: string) =>
(
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../messages/${locale}.json`), 'utf8'),
) as Record<string, Record<string, string>>
)[namespace]
describe('transactions page booking feedback', () => {
it('defines exactly one success tail', () => {
expect(PAGE_SRC).toContain('function finishBooking(')
// One undo implementation, and one place that calls the storno endpoint.
expect(PAGE_SRC.match(/altText="Ångra kategorisering"/g) ?? []).toHaveLength(1)
expect(PAGE_SRC.match(/uncategorize`, \{ method: 'POST' \}/g) ?? []).toHaveLength(1)
})
it('routes every successful booking through it', () => {
// runCategorize (category / catalog template / library template), the
// counterparty-template booking, the counterparty activate-and-retry, and
// the counterparty duplicate-warning "Bokför ändå" retry.
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(4)
})
it('no longer ends the counterparty path on a bare exitingIds add', () => {
// The old tail: setExitingIds(...) immediately followed by `journalEntryId = cpJeId`.
expect(PAGE_SRC).not.toMatch(
/setExitingIds\(\(prev\) => new Set\(prev\)\.add\(id\)\)\s*\n\s*journalEntryId = cpJeId/,
)
})
it('clears the id from exitingIds so an undo puts the row back', () => {
// Without the delete, an undone booking restores is_business: null but the
// row stays filtered out of the inbox (see uncategorizedTransactions).
expect(PAGE_SRC).toMatch(/next\.delete\(id\)/)
})
it('lets a completed undo win over the delayed booked-state patch', () => {
// The 350ms animation timer must not re-apply journal_entry_id after an
// Ångra has already storno-reversed the verifikat server-side. The
// closure-local flag covers the per-row Ångra; the shared undoneIdsRef
// covers "Ångra alla", which runs outside finishBooking's closure.
expect(PAGE_SRC).toMatch(/let undone = false/)
expect(PAGE_SRC).toMatch(/undone = true/)
expect(PAGE_SRC).toMatch(/if \(!undone && !undoneIdsRef\.current\.has\(id\)\) \{/)
// Both undo paths record into the shared ref, and a fresh booking clears
// its row's entry again so a re-booked row still gets its delayed patch.
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.add\(id\)/)
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.add\(undoneId\)/)
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.delete\(id\)/)
})
it('clears only the finished row\'s spinner', () => {
// The shared tail runs for rows that never set processingId; an
// unconditional clear would wipe an unrelated in-flight row.
expect(PAGE_SRC).toMatch(/setProcessingId\(\(prev\) => \(prev === id \? null : prev\)\)/)
})
it('decrements the unbooked count on every path that removes a row', () => {
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
// match), the three other single-row exits already on the page, the
// duplicate-dialog "Ignorera transaktionen" tail, and
// handleDeleteTransaction (deleting a pending row must not leave the
// inbox badge stale: the realtime echo is not guaranteed for DELETE).
expect(
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
).toHaveLength(7)
})
it('ships the undo strings it renders in both locales', () => {
for (const locale of ['sv', 'en'] as const) {
const messages = readMessages(locale, 'transactions')
for (const key of [
'undone_title',
'undone_description',
'undo_failed_title',
'undo_failed_description',
'partially_booked_title',
'partially_booked_description',
]) {
expect(messages[key], `${locale}.transactions.${key}`).toBeTruthy()
}
}
})
})
/**
* Duplicate-guard feedback parity: every client of POST /categorize must route
* a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which
* offers match / ignore / book-anyway), never into a destructive toast that
* names no way forward. Two clients used to dead-end: the counterparty-
* template branch of handleQuickReviewConfirm, and BankReconciliationView's
* quick-book.
*/
const BANK_RECON_SRC = fs.readFileSync(
path.resolve(__dirname, '../../reports/BankReconciliationView.tsx'),
'utf8',
)
describe('duplicate-guard 409 routing parity', () => {
it('handles the duplicate code on both booking paths of the transactions page', () => {
// runCategorize AND the counterparty-template branch.
expect(
PAGE_SRC.match(/error\?\.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'/g) ?? [],
).toHaveLength(2)
})
it('opens the dialog from the counterparty branch with a force-bound retry', () => {
// The branch must set the shared duplicateWarning state (the dialog) and
// bind the retry to the reviewed candidate's voucher.
expect(PAGE_SRC).toMatch(
/TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
it('routes the quick-book 409 on the reconciliation page into the shared dialog', () => {
expect(BANK_RECON_SRC).toContain("'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'")
expect(BANK_RECON_SRC).toContain('<DuplicateBookingDialog')
expect(BANK_RECON_SRC).toMatch(/setDuplicateWarning\(\{/)
// The retry re-runs the quick-book with force bound to the candidate.
expect(BANK_RECON_SRC).toMatch(
/handleQuickBook\(transactionId, templateId, \{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
})