93541d7186
* 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>
65 lines
2.1 KiB
TypeScript
65 lines
2.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { mapWithConcurrency } from '@/lib/concurrency'
|
|
|
|
describe('mapWithConcurrency', () => {
|
|
it('preserves input order in the result', async () => {
|
|
const items = [50, 10, 30, 0, 20]
|
|
const result = await mapWithConcurrency(items, 3, async (ms) => {
|
|
await new Promise((r) => setTimeout(r, ms))
|
|
return ms * 2
|
|
})
|
|
expect(result).toEqual([100, 20, 60, 0, 40])
|
|
})
|
|
|
|
it('never runs more than `limit` workers at once', async () => {
|
|
let inFlight = 0
|
|
let peak = 0
|
|
await mapWithConcurrency(Array.from({ length: 20 }, (_, i) => i), 4, async () => {
|
|
inFlight++
|
|
peak = Math.max(peak, inFlight)
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
inFlight--
|
|
})
|
|
expect(peak).toBeLessThanOrEqual(4)
|
|
expect(peak).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('processes every item exactly once', async () => {
|
|
const seen: number[] = []
|
|
await mapWithConcurrency(Array.from({ length: 13 }, (_, i) => i), 5, async (i) => {
|
|
seen.push(i)
|
|
})
|
|
expect(seen.slice().sort((a, b) => a - b)).toEqual(Array.from({ length: 13 }, (_, i) => i))
|
|
})
|
|
|
|
it('handles an empty input', async () => {
|
|
expect(await mapWithConcurrency([], 4, async () => 1)).toEqual([])
|
|
})
|
|
|
|
it('caps the pool at the item count', async () => {
|
|
// 2 items with limit 10 must not spin up idle workers that read past the
|
|
// end; the result stays correct.
|
|
expect(await mapWithConcurrency([1, 2], 10, async (n) => n + 1)).toEqual([2, 3])
|
|
})
|
|
|
|
it('passes the item index to the worker', async () => {
|
|
const idx = await mapWithConcurrency(['a', 'b', 'c'], 2, async (_item, i) => i)
|
|
expect(idx).toEqual([0, 1, 2])
|
|
})
|
|
|
|
it('rejects the whole map when a worker rejects (Promise.all semantics)', async () => {
|
|
await expect(
|
|
mapWithConcurrency([1, 2, 3], 2, async (n) => {
|
|
if (n === 2) throw new Error('boom')
|
|
return n
|
|
}),
|
|
).rejects.toThrow('boom')
|
|
})
|
|
|
|
it('rejects a non-positive limit instead of hanging', async () => {
|
|
await expect(mapWithConcurrency([1], 0, async (n) => n)).rejects.toThrow(
|
|
'limit must be >= 1',
|
|
)
|
|
})
|
|
})
|