Skip to content

Commit 0e79384

Browse files
committed
use load more button pattern
1 parent 2d76e5f commit 0e79384

5 files changed

Lines changed: 120 additions & 85 deletions

File tree

src/features/dashboard/templates/builds/table-cells.tsx

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import CopyButtonInline from '@/ui/copy-button-inline'
1818
import { Badge } from '@/ui/primitives/badge'
1919
import { Button } from '@/ui/primitives/button'
2020
import { CheckIcon, CloseIcon } from '@/ui/primitives/icons'
21-
import { Loader } from '@/ui/primitives/loader'
2221

2322
export function BuildId({ id }: { id: string }) {
2423
return (
@@ -61,42 +60,6 @@ export function Template({
6160
)
6261
}
6362

64-
export function LoadMoreButton({
65-
isLoading,
66-
onLoadMore,
67-
}: {
68-
isLoading: boolean
69-
onLoadMore: () => void
70-
}) {
71-
if (isLoading) {
72-
return (
73-
<span className="inline-flex items-center gap-1">
74-
Loading
75-
<Loader variant="dots" />
76-
</span>
77-
)
78-
}
79-
return (
80-
<button
81-
onClick={onLoadMore}
82-
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
83-
>
84-
Load more
85-
</button>
86-
)
87-
}
88-
89-
export function BackToTopButton({ onBackToTop }: { onBackToTop: () => void }) {
90-
return (
91-
<button
92-
onClick={onBackToTop}
93-
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
94-
>
95-
Back to top
96-
</button>
97-
)
98-
}
99-
10063
export function Duration({
10164
createdAt,
10265
finishedAt,

src/features/dashboard/templates/builds/table.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import { useRouteParams } from '@/lib/hooks/use-route-params'
1717
import { cn } from '@/lib/utils/ui'
1818
import { useTRPC } from '@/trpc/client'
19+
import { BackToTopButton, LoadMoreButton } from '@/ui/pagination-buttons'
1920
import { ArrowDownIcon } from '@/ui/primitives/icons'
2021
import { Loader } from '@/ui/primitives/loader'
2122
import {
@@ -28,10 +29,8 @@ import {
2829
} from '@/ui/primitives/table'
2930
import BuildsEmpty from './empty'
3031
import {
31-
BackToTopButton,
3232
BuildId,
3333
Duration,
34-
LoadMoreButton,
3534
Reason,
3635
StartedAt,
3736
Status,

src/features/dashboard/templates/list/table-body.tsx

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { flexRender, type Table } from '@tanstack/react-table'
2-
import { type RefObject, useEffect } from 'react'
2+
import type { RefObject } from 'react'
33
import type { Template } from '@/core/modules/templates/models'
44
import { useVirtualRows } from '@/lib/hooks/use-virtual-rows'
5+
import { cn } from '@/lib/utils'
56
import { DataTableBody, DataTableCell, DataTableRow } from '@/ui/data-table'
67
import Empty from '@/ui/empty'
8+
import { LoadMoreButton } from '@/ui/pagination-buttons'
79
import { Button } from '@/ui/primitives/button'
810
import { CloseIcon, ExternalLinkIcon } from '@/ui/primitives/icons'
911
import { useTemplateTableStore } from './stores/table-store'
1012

1113
const ROW_HEIGHT_PX = 32
1214
const VIRTUAL_OVERSCAN = 8
1315
const INITIAL_FALLBACK_ROW_COUNT = 100
14-
const PREFETCH_THRESHOLD = 8
1516

1617
interface TemplatesTableBodyProps {
1718
templates: Template[] | undefined
@@ -20,6 +21,7 @@ interface TemplatesTableBodyProps {
2021
hasNextPage: boolean
2122
isFetchingNextPage: boolean
2223
fetchNextPage: () => void
24+
isRefetching: boolean
2325
}
2426

2527
export function TemplatesTableBody({
@@ -29,6 +31,7 @@ export function TemplatesTableBody({
2931
hasNextPage,
3032
isFetchingNextPage,
3133
fetchNextPage,
34+
isRefetching,
3235
}: TemplatesTableBodyProps) {
3336
'use no memo'
3437

@@ -38,7 +41,6 @@ export function TemplatesTableBody({
3841
const centerRows = table.getCenterRows()
3942
const {
4043
virtualRows,
41-
virtualizer,
4244
totalHeight: virtualizedTotalHeight,
4345
paddingTop: virtualPaddingTop,
4446
} = useVirtualRows<Template>({
@@ -48,26 +50,6 @@ export function TemplatesTableBody({
4850
overscan: VIRTUAL_OVERSCAN,
4951
})
5052

51-
const virtualItems = virtualizer.getVirtualItems()
52-
const lastVisibleIndex = virtualItems[virtualItems.length - 1]?.index ?? -1
53-
54-
// Load the next page as the user scrolls near the bottom of the list.
55-
useEffect(() => {
56-
if (
57-
hasNextPage &&
58-
!isFetchingNextPage &&
59-
lastVisibleIndex >= centerRows.length - PREFETCH_THRESHOLD
60-
) {
61-
fetchNextPage()
62-
}
63-
}, [
64-
hasNextPage,
65-
isFetchingNextPage,
66-
lastVisibleIndex,
67-
centerRows.length,
68-
fetchNextPage,
69-
])
70-
7153
const rows =
7254
virtualRows.length > 0
7355
? virtualRows
@@ -115,21 +97,35 @@ export function TemplatesTableBody({
11597
}
11698

11799
return (
118-
<DataTableBody virtualizedTotalHeight={virtualizedTotalHeight}>
119-
{virtualPaddingTop > 0 && <div style={{ height: virtualPaddingTop }} />}
120-
{rows.map((row) => (
121-
<DataTableRow
122-
key={row.id}
123-
isSelected={row.getIsSelected()}
124-
className="h-8 border-b"
125-
>
126-
{row.getVisibleCells().map((cell) => (
127-
<DataTableCell key={cell.id} cell={cell}>
128-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
129-
</DataTableCell>
130-
))}
131-
</DataTableRow>
132-
))}
133-
</DataTableBody>
100+
<>
101+
<DataTableBody
102+
virtualizedTotalHeight={virtualizedTotalHeight}
103+
className={cn(isRefetching && 'opacity-70 transition-opacity')}
104+
>
105+
{virtualPaddingTop > 0 && <div style={{ height: virtualPaddingTop }} />}
106+
{rows.map((row) => (
107+
<DataTableRow
108+
key={row.id}
109+
isSelected={row.getIsSelected()}
110+
className="h-8 border-b"
111+
>
112+
{row.getVisibleCells().map((cell) => (
113+
<DataTableCell key={cell.id} cell={cell}>
114+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
115+
</DataTableCell>
116+
))}
117+
</DataTableRow>
118+
))}
119+
</DataTableBody>
120+
121+
{hasNextPage && (
122+
<div className="flex items-center justify-center py-3 text-fg-tertiary max-md:sticky max-md:left-0 max-md:w-[calc(100svw-1.5rem)]">
123+
<LoadMoreButton
124+
isLoading={isFetchingNextPage}
125+
onLoadMore={fetchNextPage}
126+
/>
127+
</div>
128+
)}
129+
</>
134130
)
135131
}

src/features/dashboard/templates/list/table.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type TableOptions,
1111
useReactTable,
1212
} from '@tanstack/react-table'
13-
import { useEffect, useMemo, useRef } from 'react'
13+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1414
import { useLocalStorage } from 'usehooks-ts'
1515
import type {
1616
DefaultTemplate,
@@ -38,8 +38,6 @@ import { fallbackData, templatesTableConfig, useColumns } from './table-config'
3838

3939
const PAGE_SIZE = 50
4040

41-
// Maps a table column id to its server sort-token base. Only mapped columns are
42-
// sortable server-side; anything else falls back to updated_at.
4341
const COLUMN_TO_SORT_BASE: Record<string, string> = {
4442
name: 'name',
4543
cpuCount: 'cpu_count',
@@ -71,6 +69,7 @@ export default function TemplatesTable() {
7169
fetchNextPage,
7270
hasNextPage,
7371
isFetchingNextPage,
72+
isFetching,
7473
} = useSuspenseInfiniteQuery(
7574
trpc.templates.getTemplates.infiniteQueryOptions(
7675
{
@@ -79,7 +78,6 @@ export default function TemplatesTable() {
7978
cpuCount,
8079
memoryMB,
8180
public: isPublic,
82-
// The search input already debounces before writing to the store.
8381
search: globalFilter || undefined,
8482
sort,
8583
},
@@ -96,6 +94,22 @@ export default function TemplatesTable() {
9694
[data]
9795
)
9896

97+
const { isRefetching, clearRefetching } = useTemplatesRefetchTracking(
98+
sort,
99+
globalFilter,
100+
cpuCount,
101+
memoryMB,
102+
isPublic
103+
)
104+
105+
useEffect(() => {
106+
if (!isFetching && isRefetching) {
107+
clearRefetching()
108+
}
109+
}, [isFetching, isRefetching, clearRefetching])
110+
111+
const isListDimmed = isRefetching && templates.length > 0
112+
99113
const scrollRef = useRef<HTMLDivElement>(null)
100114

101115
const [columnSizing, setColumnSizing] = useLocalStorage<ColumnSizingState>(
@@ -125,9 +139,6 @@ export default function TemplatesTable() {
125139

126140
const columnSizeVars = useColumnSizeVars(table)
127141

128-
// The query refetches from the first page whenever sort/filter/search change,
129-
// so reset the scroll position to the top to match. These values are
130-
// intentional triggers even though the effect body only touches the ref.
131142
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll reset is triggered by query-input changes
132143
useEffect(() => {
133144
if (scrollRef.current) {
@@ -207,9 +218,34 @@ export default function TemplatesTable() {
207218
hasNextPage={hasNextPage}
208219
isFetchingNextPage={isFetchingNextPage}
209220
fetchNextPage={fetchNextPage}
221+
isRefetching={isListDimmed}
210222
/>
211223
</DataTable>
212224
</div>
213225
</ClientOnly>
214226
)
215227
}
228+
229+
function useTemplatesRefetchTracking(
230+
sort: string,
231+
globalFilter: string,
232+
cpuCount: number | undefined,
233+
memoryMB: number | undefined,
234+
isPublic: boolean | undefined
235+
) {
236+
const [isRefetching, setIsRefetching] = useState(false)
237+
const isFirstRender = useRef(true)
238+
239+
// biome-ignore lint/correctness/useExhaustiveDependencies: these are change triggers, not values read in the effect
240+
useEffect(() => {
241+
if (isFirstRender.current) {
242+
isFirstRender.current = false
243+
return
244+
}
245+
setIsRefetching(true)
246+
}, [sort, globalFilter, cpuCount, memoryMB, isPublic])
247+
248+
const clearRefetching = useCallback(() => setIsRefetching(false), [])
249+
250+
return { isRefetching, clearRefetching }
251+
}

src/ui/pagination-buttons.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client'
2+
3+
import { Loader } from '@/ui/primitives/loader'
4+
5+
export function LoadMoreButton({
6+
isLoading,
7+
onLoadMore,
8+
}: {
9+
isLoading: boolean
10+
onLoadMore: () => void
11+
}) {
12+
if (isLoading) {
13+
return (
14+
<span className="inline-flex items-center gap-1">
15+
Loading
16+
<Loader variant="dots" />
17+
</span>
18+
)
19+
}
20+
return (
21+
<button
22+
type="button"
23+
onClick={onLoadMore}
24+
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
25+
>
26+
Load more
27+
</button>
28+
)
29+
}
30+
31+
export function BackToTopButton({ onBackToTop }: { onBackToTop: () => void }) {
32+
return (
33+
<button
34+
type="button"
35+
onClick={onBackToTop}
36+
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
37+
>
38+
Back to top
39+
</button>
40+
)
41+
}

0 commit comments

Comments
 (0)