Files
accounted/components/transactions/__tests__/booking-feedback-parity.test.ts
T
c897a906df fix(ux): actions update lists in place - no more takeovers, jumps and dead air (#1629)
* fix(ux): update lists in place on actions instead of takeover spinners and jumps

Founder report: the app feels glitchy when clicking around, especially
when deleting a row or booking something. The repo-wide anti-pattern
behind it: single-row actions trigger whole-list skeleton/spinner
takeovers (layout collapse, scroll jump, full stagger-enter replay),
deletes give zero feedback then hard-jump, and the /transactions exit
"animation" was filter-only and never animated.

Per surface:

- Never take over a rendered list for a background refresh. The
  skeleton/spinner swap is now reserved for an empty (or foreign) list
  on /transactions (fetchTransactions), /pending (fetchOperations,
  covering both listed Granskning findings, one file), kundfakturor
  (fetchInvoices), leverantörsfakturor (fetchInvoices, plus
  try/catch/finally so a failed fetch can no longer stick the skeleton
  or masquerade as an empty register) and the verifikat list
  (JournalEntryList now takes a refreshToken prop and refetches in
  place; /bookkeeping no longer key-remounts it into a spinner, so
  expansion/selection/pagination/scroll survive a created verifikat).
  Quiet inline Loader2 cues near the list headers on /transactions and
  /pending signal a background reconcile.

- /transactions row exit: exiting rows (booked/ignored/deleted) stay
  rendered through the existing 350ms window with a real exit
  transition (.row-exit: fast fade, then the space closes by
  transitioning cell paddings/line metrics and a numeric max-height on
  the fixed-height cell spans) and pointer-events off. Instant removal
  under prefers-reduced-motion. Applied to the inbox cards, the
  skattekonto card and the history rows.

- /transactions delete: routes through processingId (row spinner) and
  the exitingIds path, and decrements totalUncategorizedCount when the
  deleted row was pending (the realtime echo is not guaranteed for
  DELETE on a filtered subscription).

- FyPicker double-fetch: the initial fetch now waits for FyPicker's
  onReady (fires after its persisted-scope restore), so mount does one
  correctly scoped fetch instead of racing an unscoped fetch against
  the restore refetch (list -> skeleton -> list on every visit). Period
  changes refetch background-only behind the client-filtered list.

- Pagination survives realtime echoes: background refreshes re-fetch
  range(0, pagedCountRef) instead of resetting to the first 200 rows,
  so "Visa fler" pages no longer collapse after any action.

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

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

* fix(ui): apply review round on action-feedback smoothness

- /pending: sequence-guard fetchOperations so a stale previous-tab
  response can't overwrite the current tab's rows, counts, or loading cues
- /pending: check res.ok on the pending fetch and both history fetches
  before applying payloads; failures keep current rows and surface the
  existing error toast
- /transactions: reset fiscal scope (fyReady/fyPeriodId/fyPeriod) during
  render on company switch so FyPicker re-runs its persisted restore and
  stale bounds never scope a fetch for the wrong company
- /transactions: drop a deleted row's id from selectedIds so the bulk bar
  can't act on a deleted row
- row exit: add the inert attribute on exiting row wrappers alongside
  pointer-events so keyboard focus and activation are blocked too
- JournalEntryList: preserve selection on refreshToken background
  refreshes (reconciled against the refreshed page); user-initiated
  reloads still clear it

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-16 18:44:02 +02:00

143 lines
6.1 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.
expect(PAGE_SRC).toMatch(/let undone = false/)
expect(PAGE_SRC).toMatch(/undone = true/)
expect(PAGE_SRC).toMatch(/if \(!undone\) \{/)
})
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,/,
)
})
})