* 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>
143 lines
6.1 KiB
TypeScript
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,/,
|
|
)
|
|
})
|
|
})
|