feat: improve object browser lazy loading (#152)

This commit is contained in:
cxymds
2026-07-08 10:57:22 +08:00
committed by GitHub
parent 10105ebd66
commit 65b68de197
19 changed files with 295 additions and 140 deletions
+141 -95
View File
@@ -10,10 +10,10 @@ import {
RiRefreshLine,
RiFolderLine,
RiFileLine,
RiArrowLeftSLine,
RiArrowRightSLine,
RiEyeLine,
RiEdit2Line,
RiArrowUpSLine,
RiArrowDownSLine,
} from "@remixicon/react"
import {
AlertDialog,
@@ -36,10 +36,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SearchInput } from "@/components/search-input"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table/data-table"
import { Spinner } from "@/components/ui/spinner"
import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
import { useBucket } from "@/hooks/use-bucket"
@@ -57,11 +57,7 @@ import {
shouldApplyObjectListResponse,
shouldResetObjectListPagination,
} from "@/lib/object-list-state"
import {
OBJECT_LIST_DEFAULT_PAGE_SIZE,
OBJECT_LIST_PAGE_SIZE_OPTIONS,
normalizeObjectListPageSize,
} from "@/lib/object-list-pagination"
import { OBJECT_LIST_DEFAULT_PAGE_SIZE, resolveObjectListPageSize } from "@/lib/object-list-pagination"
import {
resolveBucketVersioningState,
shouldForceDeleteObjects,
@@ -125,15 +121,10 @@ export function ObjectList({
const [searchTerm, setSearchTerm] = React.useState("")
const [showDeleted, setShowDeleted] = useLocalStorage("object-list-show-deleted", false)
const [storedPageSize, setStoredPageSize] = useLocalStorage(
"object-list-page-size",
normalizeObjectListPageSize(pageSize),
)
const [loading, setLoading] = React.useState(false)
const [data, setData] = React.useState<ObjectRow[]>([])
const [continuationToken, setContinuationToken] = React.useState<string | undefined>()
const [tokenHistory, setTokenHistory] = React.useState<string[]>([])
const [nextToken, setNextToken] = React.useState<string | undefined>()
const [showScrollShortcuts, setShowScrollShortcuts] = React.useState(false)
const [bucketVersioningState, setBucketVersioningState] = React.useState<BucketVersioningState>("unknown")
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [deleteDialogKeys, setDeleteDialogKeys] = React.useState<string[]>([])
@@ -144,7 +135,7 @@ export function ObjectList({
const [renameSubmitting, setRenameSubmitting] = React.useState(false)
const prefix = decodeURIComponent(path)
const resolvedPageSize = normalizeObjectListPageSize(storedPageSize)
const resolvedPageSize = resolveObjectListPageSize(pageSize)
const listScope = React.useMemo(
() =>
createObjectListScope({
@@ -162,17 +153,52 @@ export function ObjectList({
const requestIdRef = React.useRef(0)
const activeScopeRef = React.useRef(listScope)
const previousScopeRef = React.useRef(listScope)
const loadingRef = React.useRef(false)
const loadMoreRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
activeScopeRef.current = listScope
}, [listScope])
React.useEffect(() => {
loadingRef.current = loading
}, [loading])
const updateScrollShortcutVisibility = React.useCallback(() => {
setShowScrollShortcuts(document.documentElement.scrollHeight > window.innerHeight)
}, [])
React.useEffect(() => {
updateScrollShortcutVisibility()
}, [data.length, loading, updateScrollShortcutVisibility])
React.useEffect(() => {
updateScrollShortcutVisibility()
window.addEventListener("resize", updateScrollShortcutVisibility)
window.addEventListener("scroll", updateScrollShortcutVisibility, { passive: true })
return () => {
window.removeEventListener("resize", updateScrollShortcutVisibility)
window.removeEventListener("scroll", updateScrollShortcutVisibility)
}
}, [updateScrollShortcutVisibility])
const scrollToTop = React.useCallback(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" })
}, [])
const scrollToBottom = React.useCallback(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, left: 0, behavior: "auto" })
}, [])
const fetchObjects = React.useCallback(
async (options?: { token?: string; resetPagination?: boolean }) => {
const token = options?.resetPagination ? undefined : (options?.token ?? continuationToken)
async (options?: { token?: string; resetPagination?: boolean; append?: boolean }) => {
const token = options?.resetPagination ? undefined : options?.token
const shouldAppend = Boolean(options?.append && token)
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
const requestScope = activeScopeRef.current
loadingRef.current = true
setLoading(true)
try {
const response = await listObject(bucket, prefix || undefined, resolvedPageSize, token, {
@@ -212,7 +238,12 @@ export function ObjectList({
LastModified: normalizeDateToIso(item.LastModified),
}))
setData([...prefixItems, ...objectItems])
const rows = [...prefixItems, ...objectItems]
if (shouldAppend) {
setData((currentRows) => [...currentRows, ...rows])
} else {
setData(rows)
}
} catch (error) {
console.error("Failed to fetch objects:", error)
message.error((error as Error)?.message ?? t("Failed to load objects"))
@@ -225,7 +256,9 @@ export function ObjectList({
})
) {
setNextToken(undefined)
setData([])
if (!shouldAppend) {
setData([])
}
}
} finally {
window.setTimeout(() => {
@@ -237,14 +270,21 @@ export function ObjectList({
activeScope: activeScopeRef.current,
})
) {
loadingRef.current = false
setLoading(false)
}
}, 200)
}
},
[bucket, prefix, resolvedPageSize, continuationToken, showDeleted, listObject, message, t],
[bucket, prefix, resolvedPageSize, showDeleted, listObject, message, t],
)
const resetAndFetchObjects = React.useCallback(() => {
setNextToken(undefined)
setData([])
void fetchObjects({ resetPagination: true })
}, [fetchObjects])
const prevRefreshTriggerRef = React.useRef(refreshTrigger)
React.useEffect(() => {
@@ -254,13 +294,11 @@ export function ObjectList({
prevRefreshTriggerRef.current = refreshTrigger
if (isRefresh || shouldResetPagination) {
setContinuationToken(undefined)
setTokenHistory([])
void fetchObjects({ resetPagination: true })
resetAndFetchObjects()
} else {
void fetchObjects()
void fetchObjects({ resetPagination: true })
}
}, [listScope, bucket, prefix, resolvedPageSize, continuationToken, showDeleted, refreshTrigger, fetchObjects])
}, [listScope, refreshTrigger, fetchObjects, resetAndFetchObjects])
const prevDeleteTaskIdsRef = React.useRef<Set<string>>(new Set())
@@ -274,12 +312,12 @@ export function ObjectList({
const anyActive = currentDeleteTasks.some((t) => ["pending", "running"].includes(t.status))
if (!anyActive) {
// No more active delete tasks, refresh the list
void fetchObjects({ resetPagination: true })
resetAndFetchObjects()
}
}
prevDeleteTaskIdsRef.current = new Set(completedDeleteTasks.map((t) => t.id))
}, [tasks, fetchObjects])
}, [tasks, resetAndFetchObjects])
React.useEffect(() => {
let cancelled = false
@@ -306,11 +344,6 @@ export function ObjectList({
}
}, [bucket, getBucketVersioning])
React.useEffect(() => {
setContinuationToken(undefined)
setTokenHistory([])
}, [showDeleted])
const displayKey = React.useCallback(
(key: string) => {
if (!prefix) return key
@@ -553,7 +586,7 @@ export function ObjectList({
setRenameDialogOpen(false)
setRenameSourceKey("")
setRenameName("")
void fetchObjects({ resetPagination: true })
resetAndFetchObjects()
} catch (err) {
message.error((err as Error)?.message ?? t("Rename Failed"))
} finally {
@@ -640,32 +673,36 @@ export function ObjectList({
openDeleteDialog([...checkedKeys])
}
const goToNextPage = () => {
if (!nextToken) return
setTokenHistory((h) => [...h, continuationToken as string])
setContinuationToken(nextToken)
}
const goToPreviousPage = () => {
if (tokenHistory.length === 0) return
const prevToken = tokenHistory[tokenHistory.length - 1]
setContinuationToken(prevToken)
setTokenHistory((h) => h.slice(0, -1))
}
const handlePageSizeChange = (value: string) => {
const parsed = Number.parseInt(value, 10)
const nextPageSize = normalizeObjectListPageSize(Number.isNaN(parsed) ? undefined : parsed)
setStoredPageSize(nextPageSize)
setContinuationToken(undefined)
setTokenHistory([])
}
React.useEffect(() => {
const col = table.getColumn("object")
if (col) col.setFilterValue(searchTerm || undefined)
}, [searchTerm, table])
const loadNextBatch = React.useCallback(() => {
if (!nextToken || loadingRef.current) return
void fetchObjects({ token: nextToken, append: true })
}, [fetchObjects, nextToken])
React.useEffect(() => {
const node = loadMoreRef.current
if (!node || !nextToken || typeof IntersectionObserver === "undefined") return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadNextBatch()
}
},
{ rootMargin: "320px 0px" },
)
observer.observe(node)
return () => {
observer.disconnect()
}
}, [loadNextBatch, nextToken])
return (
<div className="space-y-6">
<PageHeader
@@ -696,10 +733,7 @@ export function ObjectList({
) : null}
</>
) : null}
<Button
variant="outline"
onClick={() => (onRefresh ? onRefresh() : void fetchObjects({ resetPagination: true }))}
>
<Button variant="outline" onClick={() => (onRefresh ? onRefresh() : resetAndFetchObjects())}>
<RiRefreshLine className="size-4" aria-hidden />
<span>{t("Refresh")}</span>
</Button>
@@ -710,7 +744,7 @@ export function ObjectList({
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder={t("Filter current page")}
placeholder={t("Filter loaded objects")}
clearable
className="lg:max-w-sm"
/>
@@ -730,48 +764,60 @@ export function ObjectList({
<DataTable
table={table}
isLoading={loading}
isLoading={loading && data.length === 0}
emptyTitle={t("No Objects")}
emptyDescription={t("Upload files or create folders to populate this bucket.")}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>
{t("Loaded {count} of up to {pageSize} keys on this page", {
count: data.length,
pageSize: resolvedPageSize,
})}
</span>
<span>{t("Filtering and sorting apply to the current page")}</span>
<div className="flex items-center gap-2">
<span>{t("Rows per page")}</span>
<Select value={String(resolvedPageSize)} onValueChange={(value) => handlePageSizeChange(value ?? "")}>
<SelectTrigger className="h-9 w-24" aria-label={t("Rows per page")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OBJECT_LIST_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{String(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={tokenHistory.length === 0} onClick={goToPreviousPage}>
<RiArrowLeftSLine className="me-2 size-4 rtl:-scale-x-100" aria-hidden />
<span>{t("Previous Page")}</span>
</Button>
<Button variant="outline" disabled={!nextToken} onClick={goToNextPage}>
<span>{t("Next Page")}</span>
<RiArrowRightSLine className="ms-2 size-4 rtl:-scale-x-100" aria-hidden />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
{t("Loaded {count} objects", {
count: data.length,
})}
</span>
<span>{t("Filtering and sorting apply to loaded objects")}</span>
</div>
{nextToken ? (
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center text-sm text-muted-foreground">
{loading && data.length > 0 ? (
<span className="inline-flex items-center gap-2">
<Spinner className="size-4" />
{t("Loading more objects")}
</span>
) : (
<span>{t("Scroll to load more objects")}</span>
)}
</div>
) : null}
{showScrollShortcuts ? (
<div className="fixed end-4 bottom-4 z-40 flex flex-col gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="bg-background/95 shadow-none backdrop-blur"
onClick={scrollToTop}
aria-label={t("Back to top")}
title={t("Back to top")}
>
<RiArrowUpSLine className="size-5" aria-hidden />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="bg-background/95 shadow-none backdrop-blur"
onClick={scrollToBottom}
aria-label={t("Go to bottom")}
title={t("Go to bottom")}
>
<RiArrowDownSLine className="size-5" aria-hidden />
</Button>
</div>
) : null}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "إعادة التسمية",
"Rename Failed": "فشلت إعادة التسمية",
"Rename Object": "إعادة تسمية الكائن",
"Renaming object": "جارٍ إعادة تسمية الكائن"
"Renaming object": "جارٍ إعادة تسمية الكائن",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Umbenennen",
"Rename Failed": "Umbenennen fehlgeschlagen",
"Rename Object": "Objekt umbenennen",
"Renaming object": "Objekt wird umbenannt"
"Renaming object": "Objekt wird umbenannt",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Rename",
"Rename Failed": "Rename Failed",
"Rename Object": "Rename Object",
"Renaming object": "Renaming object"
"Renaming object": "Renaming object",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Renombrar",
"Rename Failed": "Error al renombrar",
"Rename Object": "Renombrar objeto",
"Renaming object": "Renombrando objeto"
"Renaming object": "Renombrando objeto",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Renommer",
"Rename Failed": "Échec du renommage",
"Rename Object": "Renommer lobjet",
"Renaming object": "Renommage de lobjet"
"Renaming object": "Renommage de lobjet",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Ganti nama",
"Rename Failed": "Gagal mengganti nama",
"Rename Object": "Ganti nama objek",
"Renaming object": "Mengganti nama objek"
"Renaming object": "Mengganti nama objek",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Rinomina",
"Rename Failed": "Rinomina non riuscita",
"Rename Object": "Rinomina oggetto",
"Renaming object": "Rinomina oggetto in corso"
"Renaming object": "Rinomina oggetto in corso",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "名前を変更",
"Rename Failed": "名前の変更に失敗しました",
"Rename Object": "オブジェクト名を変更",
"Renaming object": "オブジェクト名を変更中"
"Renaming object": "オブジェクト名を変更中",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "이름 변경",
"Rename Failed": "이름 변경 실패",
"Rename Object": "객체 이름 변경",
"Renaming object": "객체 이름 변경 중"
"Renaming object": "객체 이름 변경 중",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Renomear",
"Rename Failed": "Falha ao renomear",
"Rename Object": "Renomear objeto",
"Renaming object": "Renomeando objeto"
"Renaming object": "Renomeando objeto",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Переименовать",
"Rename Failed": "Не удалось переименовать",
"Rename Object": "Переименовать объект",
"Renaming object": "Переименование объекта"
"Renaming object": "Переименование объекта",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Yeniden adlandır",
"Rename Failed": "Yeniden adlandırma başarısız",
"Rename Object": "Nesneyi yeniden adlandır",
"Renaming object": "Nesne yeniden adlandırılıyor"
"Renaming object": "Nesne yeniden adlandırılıyor",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "Đổi tên",
"Rename Failed": "Đổi tên thất bại",
"Rename Object": "Đổi tên đối tượng",
"Renaming object": "Đang đổi tên đối tượng"
"Renaming object": "Đang đổi tên đối tượng",
"Filter loaded objects": "Filter loaded objects",
"Loaded {count} objects": "Loaded {count} objects",
"Filtering and sorting apply to loaded objects": "Filtering and sorting apply to loaded objects",
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
}
+8 -1
View File
@@ -1360,5 +1360,12 @@
"Rename": "重命名",
"Rename Failed": "重命名失败",
"Rename Object": "重命名对象",
"Renaming object": "正在重命名对象"
"Renaming object": "正在重命名对象",
"Filter loaded objects": "筛选已加载对象",
"Loaded {count} objects": "已加载 {count} 个对象",
"Filtering and sorting apply to loaded objects": "筛选和排序作用于已加载对象",
"Loading more objects": "正在加载更多对象",
"Scroll to load more objects": "滚动以加载更多对象",
"Back to top": "回到顶部",
"Go to bottom": "直达底部"
}
+1 -5
View File
@@ -1,5 +1 @@
export {
OBJECT_LIST_DEFAULT_PAGE_SIZE,
OBJECT_LIST_PAGE_SIZE_OPTIONS,
normalizeObjectListPageSize,
} from "./object-list-pagination.ts"
export { OBJECT_LIST_DEFAULT_PAGE_SIZE, resolveObjectListPageSize } from "./object-list-pagination.ts"
+4 -8
View File
@@ -1,12 +1,8 @@
export const OBJECT_LIST_DEFAULT_PAGE_SIZE = 100
export const OBJECT_LIST_DEFAULT_PAGE_SIZE = 1000
export const OBJECT_LIST_PAGE_SIZE_OPTIONS = [25, 50, 100, 500, 1000] as const
export type ObjectListPageSize = (typeof OBJECT_LIST_PAGE_SIZE_OPTIONS)[number]
export function normalizeObjectListPageSize(value: unknown): ObjectListPageSize {
if (typeof value === "number" && OBJECT_LIST_PAGE_SIZE_OPTIONS.includes(value as ObjectListPageSize)) {
return value as ObjectListPageSize
export function resolveObjectListPageSize(value: unknown): number {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
return value
}
return OBJECT_LIST_DEFAULT_PAGE_SIZE
+11 -18
View File
@@ -1,26 +1,19 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
OBJECT_LIST_DEFAULT_PAGE_SIZE,
OBJECT_LIST_PAGE_SIZE_OPTIONS,
normalizeObjectListPageSize,
} from "../../lib/object-list-pagination.js"
import { OBJECT_LIST_DEFAULT_PAGE_SIZE, resolveObjectListPageSize } from "../../lib/object-list-pagination.js"
test("object list pagination defaults to 100 keys per page", () => {
assert.equal(OBJECT_LIST_DEFAULT_PAGE_SIZE, 100)
test("object list pagination defaults to 1000 keys per request", () => {
assert.equal(OBJECT_LIST_DEFAULT_PAGE_SIZE, 1000)
})
test("object list pagination supports S3-safe page size options", () => {
assert.deepEqual([...OBJECT_LIST_PAGE_SIZE_OPTIONS], [25, 50, 100, 500, 1000])
test("resolveObjectListPageSize keeps valid positive integer values", () => {
assert.equal(resolveObjectListPageSize(500), 500)
assert.equal(resolveObjectListPageSize(1000), 1000)
})
test("normalizeObjectListPageSize keeps supported values", () => {
assert.equal(normalizeObjectListPageSize(25), 25)
assert.equal(normalizeObjectListPageSize(1000), 1000)
})
test("normalizeObjectListPageSize falls back for unsupported values", () => {
assert.equal(normalizeObjectListPageSize(10), OBJECT_LIST_DEFAULT_PAGE_SIZE)
assert.equal(normalizeObjectListPageSize("100"), OBJECT_LIST_DEFAULT_PAGE_SIZE)
assert.equal(normalizeObjectListPageSize(undefined), OBJECT_LIST_DEFAULT_PAGE_SIZE)
test("resolveObjectListPageSize falls back for invalid values", () => {
assert.equal(resolveObjectListPageSize(0), OBJECT_LIST_DEFAULT_PAGE_SIZE)
assert.equal(resolveObjectListPageSize(1.5), OBJECT_LIST_DEFAULT_PAGE_SIZE)
assert.equal(resolveObjectListPageSize("1000"), OBJECT_LIST_DEFAULT_PAGE_SIZE)
assert.equal(resolveObjectListPageSize(undefined), OBJECT_LIST_DEFAULT_PAGE_SIZE)
})
+26
View File
@@ -17,3 +17,29 @@ test("object list falls back to an empty table instead of crashing the page on f
assert.equal(source.includes('message.error((error as Error)?.message ?? t("Failed to load objects"))'), true)
assert.equal(source.includes("setData([])"), true)
})
test("object list lazy loads additional object batches instead of showing a paginator", () => {
const source = fs.readFileSync("components/object/list.tsx", "utf8")
assert.equal(source.includes("IntersectionObserver"), true)
assert.equal(source.includes("setData((currentRows) => [...currentRows, ...rows])"), true)
assert.equal(source.includes('t("Rows per page")'), false)
assert.equal(source.includes('t("Previous Page")'), false)
assert.equal(source.includes('t("Next Page")'), false)
})
test("object list shows fixed scroll shortcut buttons only when content overflows", () => {
const source = fs.readFileSync("components/object/list.tsx", "utf8")
assert.equal(source.includes("RiArrowUpSLine"), true)
assert.equal(source.includes("RiArrowDownSLine"), true)
assert.equal(
source.includes("setShowScrollShortcuts(document.documentElement.scrollHeight > window.innerHeight)"),
true,
)
assert.equal(source.includes('window.scrollTo({ top: 0, left: 0, behavior: "auto" })'), true)
assert.equal(
source.includes('window.scrollTo({ top: document.documentElement.scrollHeight, left: 0, behavior: "auto" })'),
true,
)
})