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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
4dbd19aeb0
commit
c897a906df
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user