From e6ff5c3f0bf1d3eb4d9517f00d6ce6b969693a97 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 11 Oct 2025 20:37:14 +0200 Subject: [PATCH] fix pagination if endpoint's total is not available when using a connection that has softscans disabled (e.g. D1) pagination failed. Fixing it by overfetching and slicing --- app/src/ui/client/api/use-api.ts | 6 +- app/src/ui/components/table/DataTable.tsx | 87 ++++++++++++------- .../ui/elements/media/DropzoneContainer.tsx | 69 +++++++++------ app/src/ui/hooks/use-search.ts | 5 +- .../ui/modules/data/components/EntityForm.tsx | 14 +-- .../fields/EntityRelationalFormField.tsx | 2 +- app/src/ui/routes/data/data.$entity.index.tsx | 2 +- 7 files changed, 110 insertions(+), 75 deletions(-) diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 72feeeb..4571bd9 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -35,7 +35,7 @@ export const useApiInfiniteQuery = < RefineFn extends (data: ResponseObject) => unknown = (data: ResponseObject) => Data, >( fn: (api: Api, page: number) => FetchPromise, - options?: SWRConfiguration & { refine?: RefineFn }, + options?: SWRConfiguration & { refine?: RefineFn; pageSize?: number }, ) => { const [endReached, setEndReached] = useState(false); const api = useApi(); @@ -47,14 +47,14 @@ export const useApiInfiniteQuery = < // @ts-ignore const swr = useSWRInfinite( (index, previousPageData: any) => { - if (previousPageData && !previousPageData.length) { + if (index > 0 && previousPageData && previousPageData.length < (options?.pageSize ?? 0)) { setEndReached(true); return null; // reached the end } return promise(index).request.url; }, (url: string) => { - return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute(); + return new FetchPromise(new Request(url), { fetcher: api.fetcher }).execute(); }, { revalidateFirstPage: false, diff --git a/app/src/ui/components/table/DataTable.tsx b/app/src/ui/components/table/DataTable.tsx index 2ce2a42..ce691f9 100644 --- a/app/src/ui/components/table/DataTable.tsx +++ b/app/src/ui/components/table/DataTable.tsx @@ -53,7 +53,7 @@ export type DataTableProps = { }; export function DataTable = Record>({ - data = [], + data: _data = [], columns, checkable, onClickRow, @@ -71,11 +71,14 @@ export function DataTable = Record renderValue, onClickNew, }: DataTableProps) { + const hasTotal = !!total; + const data = Array.isArray(_data) ? _data.slice(0, perPage) : _data; total = total || data?.length || 0; page = page || 1; const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {}); const pages = Math.max(Math.ceil(total / perPage), 1); + const hasNext = hasTotal ? pages > page : (_data?.length || 0) > perPage; const CellRender = renderValue || CellValue; return ( @@ -202,7 +205,7 @@ export function DataTable = Record perPage={perPage} page={page} items={data?.length || 0} - total={total} + total={hasTotal ? total : undefined} />
@@ -222,11 +225,17 @@ export function DataTable = Record
)}
- Page {page} of {pages} + Page {page} + {hasTotal ? <> of {pages} : ""}
{onClickPage && (
- +
)} @@ -268,17 +277,23 @@ const SortIndicator = ({ }; const TableDisplay = ({ perPage, page, items, total }) => { - if (total === 0) { + if (items === 0 && page === 1) { return <>No rows to show; } - if (total === 1) { - return <>Showing 1 row; + const start = Math.max(perPage * (page - 1), 1); + + if (!total) { + return ( + <> + Showing {start}-{perPage * (page - 1) + items} + + ); } return ( <> - Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows + Showing {start}-{perPage * (page - 1) + items} of {total} rows ); }; @@ -287,30 +302,44 @@ type TableNavProps = { current: number; total: number; onClick?: (page: number) => void; + hasLast?: boolean; }; -const TableNav: React.FC = ({ current, total, onClick }: TableNavProps) => { +const TableNav: React.FC = ({ + current, + total, + onClick, + hasLast = true, +}: TableNavProps) => { const navMap = [ - { value: 1, Icon: TbChevronsLeft, disabled: current === 1 }, - { value: current - 1, Icon: TbChevronLeft, disabled: current === 1 }, - { value: current + 1, Icon: TbChevronRight, disabled: current === total }, - { value: total, Icon: TbChevronsRight, disabled: current === total }, + { enabled: true, value: 1, Icon: TbChevronsLeft, disabled: current === 1 }, + { enabled: true, value: current - 1, Icon: TbChevronLeft, disabled: current === 1 }, + { + enabled: true, + value: current + 1, + Icon: TbChevronRight, + disabled: current === total, + }, + { enabled: hasLast, value: total, Icon: TbChevronsRight, disabled: current === total }, ] as const; - return navMap.map((nav, key) => ( - - )); + return navMap.map( + (nav, key) => + nav.enabled && ( + + ), + ); }; diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 46cef55..2a99a5f 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -77,7 +77,9 @@ export function DropzoneContainer({ }); const $q = infinite - ? useApiInfiniteQuery(selectApi, {}) + ? useApiInfiniteQuery(selectApi, { + pageSize, + }) : useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems, revalidateOnFocus: false, @@ -108,31 +110,48 @@ export function DropzoneContainer({ []) as MediaFieldSchema[]; const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl }); - const key = id + JSON.stringify(_initialItems); + const key = id + JSON.stringify(initialItems); + + // check if endpoint reeturns a total, then reaching end is easy + const total = "_data" in $q ? $q._data?.[0]?.body.meta.count : undefined; + let placeholderLength = 0; + if (infinite && "setSize" in $q) { + placeholderLength = + typeof total === "number" + ? total + : $q.endReached + ? _initialItems.length + : _initialItems.length + pageSize; + + // in case there is no total, we overfetch but SWR don't reflect an empty result + // therefore we check if it stopped loading, but has a bigger page size than the total. + // if that's the case, we assume we reached the end. + if (!total && !$q.isValidating && pageSize * $q.size >= placeholderLength) { + placeholderLength = _initialItems.length; + } + } + return ( - $q.setSize($q.size + 1)} - /> - ) - } - {...props} - /> + <> + $q.setSize($q.size + 1)} + /> + ) + } + {...props} + /> + ); } diff --git a/app/src/ui/hooks/use-search.ts b/app/src/ui/hooks/use-search.ts index 40967c1..be0cad0 100644 --- a/app/src/ui/hooks/use-search.ts +++ b/app/src/ui/hooks/use-search.ts @@ -14,10 +14,6 @@ export function useSearch( ) { const searchString = useWouterSearch(); const [location, navigate] = useLocation(); - const [value, setValue] = useState>( - options?.defaultValue ?? ({} as any), - ); - const defaults = useMemo(() => { return mergeObject( // @ts-ignore @@ -25,6 +21,7 @@ export function useSearch( options?.defaultValue ?? {}, ); }, [JSON.stringify({ schema, dflt: options?.defaultValue })]); + const [value, setValue] = useState>(defaults); useEffect(() => { const initial = diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 63e608e..ff778a1 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -301,19 +301,9 @@ function EntityJsonFormField({ onChange={handleUpdate} onBlur={fieldApi.handleBlur} minHeight="100" - /*required={field.isRequired()}*/ {...props} /> - {/**/} ); } @@ -340,8 +330,8 @@ function EntityEnumFormField({ {...props} > {!field.isRequired() && } - {field.getOptions().map((option) => ( - ))} diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index 6b4ee77..aa331bd 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -44,7 +44,7 @@ export function EntityRelationalFormField({ const ref = useRef(null); const $q = useEntityQuery(field.target(), undefined, { select: query.select, - limit: query.limit, + limit: query.limit + 1 /* overfetch for softscan=false */, offset: (query.page - 1) * query.limit, }); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 10b74fa..5945f37 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -61,7 +61,7 @@ function DataEntityListImpl({ params }) { (api) => api.data.readMany(entity?.name as any, { select: search.value.select, - limit: search.value.perPage, + limit: search.value.perPage + 1 /* overfetch for softscan=false */, offset: (search.value.page - 1) * search.value.perPage, sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`, }),