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>
This commit is contained in:
Jakob Wennberg
2026-08-16 18:44:02 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 4dbd19aeb0
commit c897a906df
14 changed files with 385 additions and 43 deletions
+44 -3
View File
@@ -191,7 +191,20 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
const ALL_PAGE_SIZE = 100000
export default function JournalEntryList({ pristineSlot }: { pristineSlot?: ReactNode } = {}) {
export default function JournalEntryList({
pristineSlot,
refreshToken,
}: {
pristineSlot?: ReactNode
/**
* Parent-driven refresh: bump to re-fetch IN PLACE (list stays mounted,
* dims at opacity-60). Replaces the old key={refreshKey} remount on
* /bookkeeping, which reset hasLoaded and blanked the whole journal to a
* spinner after every created verifikat, destroying expanded rows,
* selection, pagination and scroll position.
*/
refreshToken?: number
} = {}) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -498,11 +511,15 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
// response would overwrite the current sort's rows after they rendered.
const fetchGenRef = useRef(0)
async function fetchEntries() {
// `preserveSelection` marks a parent-driven background refresh (the
// refreshToken contract: refresh in place, don't disturb the user's
// working state). User-initiated reloads (filter/sort/page/mode changes,
// commit, storno, retry) keep the default reset: selection is page-scoped.
async function fetchEntries({ preserveSelection = false }: { preserveSelection?: boolean } = {}) {
const gen = ++fetchGenRef.current
const isCurrent = () => fetchGenRef.current === gen
setLoading(true)
setSelectedIds(new Set()) // selection is page-scoped, reset on reload
if (!preserveSelection) setSelectedIds(new Set()) // selection is page-scoped, reset on reload
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
@@ -539,6 +556,16 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
const loadedEntries = data || []
setEntries(loadedEntries)
setCount(total || 0)
if (preserveSelection) {
// Reconcile with the refreshed page: rows that left it (recommitted
// elsewhere, filtered out by the new data) must leave the selection
// too, or the bulk bar would act on rows no longer on screen.
const visibleIds = new Set(loadedEntries.map((e: JournalEntry) => e.id))
setSelectedIds((prev) => {
const next = new Set([...prev].filter((id) => visibleIds.has(id)))
return next.size === prev.size ? prev : next
})
}
// The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges
// on draftCount. When the committed list comes back empty, resolve the draft
@@ -617,6 +644,20 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
fetchEntries()
}, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
// Parent-driven in-place refresh (see the refreshToken prop). Skips the
// mount value: the main effect above owns the initial fetch, and a token
// bump before hydration is covered by that same initial fetch.
const lastRefreshTokenRef = useRef(refreshToken)
useEffect(() => {
if (refreshToken === undefined || refreshToken === lastRefreshTokenRef.current) return
lastRefreshTokenRef.current = refreshToken
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
// In-place refresh: the user didn't ask for a reload, so their current
// selection survives (reconciled against the refreshed page).
fetchEntries({ preserveSelection: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken, sortHydrated, periodHydrated, pageSizeHydrated])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
@@ -24,6 +24,7 @@ export default function SkattekontoInboxCard({
row,
matchSuggestion,
bookingSuggestion,
isExiting = false,
processing,
selectable,
isSelected,
@@ -34,6 +35,9 @@ export default function SkattekontoInboxCard({
row: StoredSkattekontoTransaction
matchSuggestion?: SkattekontoMatchSuggestion | null
bookingSuggestion?: SkattekontoBookingSuggestion | null
/** The row was just booked and is animating out during the page's 350ms
* removal window (.row-exit collapse; instant under reduced motion). */
isExiting?: boolean
processing: boolean
selectable?: boolean
isSelected?: boolean
@@ -60,7 +64,12 @@ export default function SkattekontoInboxCard({
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
isExiting && 'row-exit',
)}
// .row-exit only blocks pointer input; `inert` also drops keyboard
// focus and activation (booking/matching controls) during the 350ms
// removal window.
inert={isExiting || undefined}
>
{/* Hover-revealed selection checkbox (concept .cb) */}
{/* Zero-width cell: the checkbox hangs in the left page margin so
@@ -84,7 +93,7 @@ export default function SkattekontoInboxCard({
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="row-collapsible flex min-w-0 items-center gap-2">
<span className="truncate">{row.transaktionstext}</span>
<Badge variant="outline" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px] font-normal">
<Landmark className="h-3 w-3" />
@@ -121,7 +130,7 @@ export default function SkattekontoInboxCard({
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-3">
<span className="row-collapsible inline-flex items-center justify-end gap-3">
{matchSuggestion ? (
<>
{/* Likely duplicate: linking beats re-booking, so it leads. */}
@@ -46,6 +46,9 @@ type HistoryRow =
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
skvRows?: SkattekontoTransactionWithSuggestion[]
/** Rows animating out during the page's 350ms removal window (delete):
* rendered with .row-exit so the departure is visible, not a hard jump. */
exitingIds?: Set<string>
searchTerm?: string
/** Page-level source chip selection (the toolbar ContextPicker on
* /transactions, shared with the inbox mode). */
@@ -78,6 +81,7 @@ interface TransactionHistoryListProps {
export default function TransactionHistoryList({
transactions,
skvRows = [],
exitingIds,
searchTerm = '',
sourceFilter,
jeUnderlagStatus,
@@ -182,6 +186,7 @@ export default function TransactionHistoryList({
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
isExiting={exitingIds?.has(item.data.id) ?? false}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
@@ -228,6 +233,7 @@ export default function TransactionHistoryList({
function BankHistoryRow({
transaction,
isExiting = false,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
@@ -236,6 +242,7 @@ function BankHistoryRow({
onDelete,
}: {
transaction: TransactionWithInvoice
isExiting?: boolean
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
@@ -282,14 +289,21 @@ function BankHistoryRow({
return (
<tr
data-tx-id={transaction.id}
className="group transition-colors duration-150 hover:bg-secondary/35"
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
isExiting && 'row-exit',
)}
// .row-exit only blocks pointer input; `inert` also drops keyboard
// focus and activation (booking, delete, the ⋯ menu) during the 350ms
// removal window.
inert={isExiting || undefined}
>
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="row-collapsible flex min-w-0 items-center gap-2">
<span className="truncate">{transaction.description}</span>
<TransactionAttachmentIndicator
documentId={transaction.document_id}
@@ -337,7 +351,7 @@ function BankHistoryRow({
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
<span className="row-collapsible inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
<span className="text-muted-foreground">
@@ -47,6 +47,10 @@ interface TransactionInboxCardProps {
/** When set, this bank tx looks like the bank side of a 1930↔1630
* transfer that the user will later see on /skattekonto. */
skvCounterpartDate?: string
/** The row was just booked/ignored/deleted and is animating out during the
* page's 350ms removal window: .row-exit fades and collapses it, and
* pointer events are off. Instant removal under prefers-reduced-motion. */
isExiting?: boolean
processingId: string | null
isSelected: boolean
/** Row expansion (concept foldout): controlled by the page so only one
@@ -98,6 +102,7 @@ interface TransactionInboxCardProps {
export default function TransactionInboxCard({
transaction,
skvCounterpartDate,
isExiting = false,
processingId,
isSelected,
isExpanded,
@@ -231,7 +236,9 @@ export default function TransactionInboxCard({
Boolean(skvCounterpartDate) ||
(HAS_AI_EXTRACTION && (extraction.status === 'running' || extraction.status === 'failed'))
const canExpand = hasFoldoutContent
const expanded = isExpanded && canExpand
// An exiting row's foldout closes with it: the foldout <tr> has no exit
// styling of its own and would otherwise linger un-animated.
const expanded = isExpanded && canExpand && !isExiting
return (
<>
@@ -243,7 +250,12 @@ export default function TransactionInboxCard({
expanded ? 'bg-secondary/25' : 'hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
isDisabled && 'opacity-50',
isExiting && 'row-exit',
)}
// .row-exit only blocks pointer input; `inert` also drops keyboard
// focus and activation (row expand, Bokför, the ⋯ menu) during the
// 350ms removal window.
inert={isExiting || undefined}
role={canExpand ? 'button' : undefined}
tabIndex={canExpand ? 0 : undefined}
aria-expanded={canExpand ? expanded : undefined}
@@ -288,7 +300,7 @@ export default function TransactionInboxCard({
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="row-collapsible flex min-w-0 items-center gap-2">
<span className="truncate">{transaction.description}</span>
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
{transaction.title_edited_at && (
@@ -325,7 +337,7 @@ export default function TransactionInboxCard({
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'relative whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
<span className="row-collapsible inline-flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -75,11 +75,13 @@ describe('transactions page booking feedback', () => {
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, and the
// duplicate-dialog "Ignorera transaktionen" tail.
// 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(6)
).toHaveLength(7)
})
it('ships the undo strings it renders in both locales', () => {