mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: improve object browser lazy loading (#152)
This commit is contained in:
+141
-95
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1360,5 +1360,12 @@
|
||||
"Rename": "Renommer",
|
||||
"Rename Failed": "Échec du renommage",
|
||||
"Rename Object": "Renommer l’objet",
|
||||
"Renaming object": "Renommage de l’objet"
|
||||
"Renaming object": "Renommage de l’objet",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user