feat(ux): one silhouette from route fallback to detail content (#1944)
Opening a row on the customers, invoices, verifikat, supplier-invoice, supplier, article and salary-run lists flashed three unrelated layouts: the segment's list-shaped loading.tsx (or, for suppliers and articles, the Hem-shaped dashboard fallback) during the RSC round trip, then the client page's bare centred spinner in an h-64 box while it fetched, then the content with a full layout change. Two flashes per click on the most travelled drill-down path. - components/common/DetailPageSkeleton.tsx: back link + title row + card grid + line block, the silhouette of a document/register detail page; InvoiceEditorSkeleton for the editor routes (same shape the Ny faktura dialog shows while its chunk loads). - loading.tsx for every [id] segment (customers, invoices, invoices/edit, invoices/credit, bookkeeping, supplier-invoices, suppliers, articles, salary/runs) and for the two list segments that had none (suppliers, articles, cloned from customers/loading.tsx). - The client pages render the same skeleton while they fetch instead of the centred Loader2, so the RSC fallback to client fallback handoff is invisible. The reference-data gates are already gone (A1 to A6); this only covers the primary-entity fetch. - app/(dashboard)/__tests__/detail-loading-states.test.ts pins both. 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
Jakob Wennberg
Claude Fable 5
parent
3ee3565d6d
commit
41424a1650
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Every [id] detail segment ships a route-level loading.tsx, and its client
|
||||
* page no longer collapses into the bare centred spinner while it fetches:
|
||||
* the RSC fallback and the client fallback share one silhouette
|
||||
* (components/common/DetailPageSkeleton), so opening a row never flashes
|
||||
* three unrelated layouts.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const APP = path.resolve(__dirname, '..')
|
||||
const DETAIL_SEGMENTS = [
|
||||
'customers/[id]',
|
||||
'invoices/[id]',
|
||||
'invoices/[id]/edit',
|
||||
'invoices/[id]/credit',
|
||||
'bookkeeping/[id]',
|
||||
'supplier-invoices/[id]',
|
||||
'suppliers/[id]',
|
||||
'articles/[id]',
|
||||
'salary/runs/[id]',
|
||||
]
|
||||
const LIST_SEGMENTS = ['customers', 'invoices', 'suppliers', 'articles', 'supplier-invoices']
|
||||
|
||||
describe('detail and list segments have a route-level loading state', () => {
|
||||
for (const segment of [...DETAIL_SEGMENTS, ...LIST_SEGMENTS]) {
|
||||
it(`${segment}/loading.tsx exists`, () => {
|
||||
expect(fs.existsSync(path.join(APP, segment, 'loading.tsx'))).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('detail pages do not gate on the bare centred spinner', () => {
|
||||
for (const segment of DETAIL_SEGMENTS) {
|
||||
it(`${segment}/page.tsx uses the shared skeleton, not h-64 + Loader2`, () => {
|
||||
const source = fs.readFileSync(path.join(APP, segment, 'page.tsx'), 'utf8')
|
||||
expect(source).not.toMatch(/justify-center h-64">\s*<Loader2/)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the article detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { parseArticleHouseworkType, workTypeLabel } from '@/lib/invoices/rot-rut-rules'
|
||||
import type { Article, ArticleType, CreateArticleInput } from '@/types'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
const ARTICLE_TYPE_KEY: Record<ArticleType, string> = {
|
||||
vara: 'type_vara',
|
||||
@@ -198,11 +199,7 @@ export default function ArticleDetailPage({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
|
||||
if (!article) return null
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ArticlesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + export menu + "Ny artikel" action (pills) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<Skeleton className="h-9 w-full" />
|
||||
|
||||
{/* Borderless table: header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the verifikat detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { listContextKey } from '@/lib/navigation/list-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
// Snapshot of a struck line, as stored in journal_entry_rattelse_log.
|
||||
type StruckLineSnapshot = {
|
||||
@@ -344,12 +345,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
}, [fetchData])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
|
||||
if (error || !entry) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the customer detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
const CUSTOMER_TYPE_KEY: Record<CustomerType, string> = {
|
||||
individual: 'type_individual',
|
||||
@@ -201,11 +202,7 @@ export default function CustomerDetailPage({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
|
||||
if (!customer) return null
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the credit-note segment: the editor's own silhouette. */
|
||||
export default function Loading() {
|
||||
return <InvoiceEditorSkeleton />
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
import { creditConfirmNumber } from '@/lib/invoices/display'
|
||||
import type { Invoice, InvoiceItem, Customer } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
@@ -160,11 +161,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
return <InvoiceEditorSkeleton />
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the invoice edit segment: the editor's own silhouette. */
|
||||
export default function Loading() {
|
||||
return <InvoiceEditorSkeleton />
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import InvoiceEditor, { type InvoiceForEdit } from '@/components/invoices/InvoiceEditor'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/**
|
||||
* Edit an existing DRAFT invoice. Loads the invoice + items, guards that it is
|
||||
@@ -76,11 +76,7 @@ export default function EditInvoicePage({ params }: { params: Promise<{ id: stri
|
||||
}, [id])
|
||||
|
||||
if (isLoading || !invoice) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
return <InvoiceEditorSkeleton />
|
||||
}
|
||||
|
||||
return <InvoiceEditor mode="edit" initial={invoice} />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the invoice detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton cards={3} />
|
||||
}
|
||||
@@ -98,6 +98,7 @@ const PEPPOL_STATUS_KEYS = new Set([
|
||||
])
|
||||
const PEPPOL_SENDABLE_STATUSES = new Set<InvoiceStatus>(['draft', 'sent', 'overdue'])
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
// Why the downloaded file is not the invoice the customer received. One key
|
||||
// per reason: "no archived copy exists" and "the archive could not be reached"
|
||||
@@ -1171,11 +1172,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton cards={3} />
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** Route-level fallback for a salary run: mirrors the page's own loading state (wide two-column layout). */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy="true" aria-live="polite">
|
||||
<Skeleton className="h-9 w-60" />
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_280px] gap-8 space-y-6 lg:space-y-0">
|
||||
<Skeleton className="rounded-lg h-48" />
|
||||
<Skeleton className="rounded-lg h-64 hidden lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the supplier-invoice detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton cards={3} />
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { DetailPager } from '@/components/common/DetailPager'
|
||||
import { listContextKey } from '@/lib/navigation/list-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
interface EditableLine {
|
||||
account_number: string
|
||||
@@ -510,12 +511,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton cards={3} />
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
/** Route-level fallback for the supplier detail segment: same silhouette the page renders while it fetches. */
|
||||
export default function Loading() {
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
@@ -19,6 +18,7 @@ import SupplierForm from '@/components/suppliers/SupplierForm'
|
||||
import Link from 'next/link'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
|
||||
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -145,12 +145,7 @@ export default function SupplierDetailPage() {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
|
||||
if (!supplier) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function SuppliersLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Title (24px) + export menu + "Ny leverantör" action (pills) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<Skeleton className="h-9 w-full" />
|
||||
|
||||
{/* Borderless table: header row + single-line rows */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DetailPageSkeletonProps {
|
||||
/** Number of summary cards under the title row. */
|
||||
cards?: 2 | 3
|
||||
/** Full-width variant for MainContainer's wide routes (max-w-7xl). */
|
||||
wide?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Silhouette of a register/document detail page: back link, title row with
|
||||
* action pills, then a card grid. Used both as the route-level loading.tsx of
|
||||
* the [id] segments and as the client page's own loading state, so the
|
||||
* handoff from the RSC fallback to the client fetch is a no-op visually
|
||||
* instead of skeleton -> centred spinner -> content (three unrelated layouts
|
||||
* on the most-travelled drill-down path).
|
||||
*/
|
||||
export function DetailPageSkeleton({ cards = 2, wide = false, className }: DetailPageSkeletonProps) {
|
||||
return (
|
||||
<div className={cn('space-y-8', className)} aria-busy="true" aria-live="polite">
|
||||
{/* Back link */}
|
||||
<Skeleton className="h-4 w-24" />
|
||||
|
||||
{/* Title (24px) + status pill + primary actions (pills) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card grid */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-6',
|
||||
cards === 3 ? 'md:grid-cols-3' : 'md:grid-cols-2',
|
||||
wide && 'lg:grid-cols-[minmax(0,1fr)_280px]',
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: cards }, (_, i) => (
|
||||
<div key={i} className="space-y-3 rounded-lg border border-border p-5">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Line/table block */}
|
||||
<div>
|
||||
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="ml-auto h-3 w-14" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Silhouette of the invoice editor (single-column snabbflöde): heading, the
|
||||
* customer/details block and the lines block. Matches the fallback the
|
||||
* "Ny faktura" dialog shows while the editor chunk loads.
|
||||
*/
|
||||
export function InvoiceEditorSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)} aria-busy="true" aria-live="polite">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user