first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import React, {ReactNode, useEffect, useRef, useState} from 'react';
import clsx from 'clsx';
import {UseInfiniteQueryResult} from '@tanstack/react-query/src/types';
import {Trans} from '@common/i18n/trans';
import {Button} from '@common/ui/buttons/button';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {ProgressCircle} from '@common/ui/progress/progress-circle';
export interface InfiniteScrollSentinelProps {
loaderMarginTop?: string;
children?: ReactNode;
loadMoreExtraContent?: ReactNode;
query: UseInfiniteQueryResult;
style?: React.CSSProperties;
className?: string;
variant?: 'infiniteScroll' | 'loadMore';
size?: 'sm' | 'md';
}
export function InfiniteScrollSentinel({
query: {isInitialLoading, fetchNextPage, isFetchingNextPage, hasNextPage},
children,
loaderMarginTop = 'mt-24',
style,
className,
variant: _variant = 'infiniteScroll',
loadMoreExtraContent,
size = 'md',
}: InfiniteScrollSentinelProps) {
const sentinelRef = useRef<HTMLDivElement>(null);
const isLoading = isFetchingNextPage || isInitialLoading;
const [loadMoreClickCount, setLoadMoreClickCount] = useState(0);
const innerVariant =
_variant === 'loadMore' && loadMoreClickCount < 3
? 'loadMore'
: 'infiniteScroll';
useEffect(() => {
const sentinelEl = sentinelRef.current;
if (!sentinelEl || innerVariant === 'loadMore') return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isLoading) {
fetchNextPage();
}
});
observer.observe(sentinelEl);
return () => {
observer.unobserve(sentinelEl);
};
}, [fetchNextPage, hasNextPage, isLoading, innerVariant]);
let content: ReactNode;
if (children) {
// children might already be wrapped in AnimatePresence, so only wrap default loader with it
content = isFetchingNextPage ? children : null;
} else if (innerVariant === 'loadMore') {
content = !isInitialLoading && hasNextPage && (
<div className={clsx('flex items-center gap-8', loaderMarginTop)}>
{loadMoreExtraContent}
<Button
size={size === 'md' ? 'sm' : 'xs'}
className={clsx(
size === 'sm' ? 'min-h-24 min-w-96' : 'min-h-36 min-w-112'
)}
variant="outline"
color="primary"
onClick={() => {
fetchNextPage();
setLoadMoreClickCount(loadMoreClickCount + 1);
}}
disabled={isLoading}
>
{loadMoreClickCount >= 2 && !isFetchingNextPage ? (
<Trans message="Load all" />
) : (
<Trans message="Show more" />
)}
</Button>
</div>
);
} else {
content = (
<AnimatePresence>
{isFetchingNextPage && (
<m.div
className={clsx('flex justify-center w-full', loaderMarginTop)}
{...opacityAnimation}
>
<ProgressCircle size={size} isIndeterminate aria-label="loading" />
</m.div>
)}
</AnimatePresence>
);
}
return (
<div
style={style}
className={clsx('w-full', className, hasNextPage && 'min-h-36')}
role="presentation"
>
<div ref={sentinelRef} aria-hidden />
{content}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import {
hashKey,
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {
hasNextPage,
PaginationResponse,
} from '@common/http/backend-response/pagination-response';
import {useMemo, useRef, useState} from 'react';
import {SortDescriptor} from '@common/ui/tables/types/sort-descriptor';
import {GetDatatableDataParams} from '@common/datatable/requests/paginated-resources';
import {QueryKey} from '@tanstack/query-core/src/types';
export type UseInfiniteDataResult<
T,
E extends object = object,
> = UseInfiniteQueryResult<InfiniteData<PaginationResponse<T> & E>> & {
items: T[];
totalItems: number | null;
// initial load is done and no results were returned from backend
noResults: boolean;
// true when changing filters or sorting, not on initial load, background fetch or infinite load
isReloading: boolean;
sortDescriptor: SortDescriptor;
setSortDescriptor: (sortDescriptor: SortDescriptor) => void;
searchQuery: string;
setSearchQuery: (searchQuery: string) => void;
};
function buildQueryKey(
{
queryKey,
defaultOrderDir,
defaultOrderBy,
queryParams,
}: UseInfiniteDataProps<any>,
sortDescriptor: SortDescriptor,
searchQuery: string = '',
) {
// make sure to always set default order dir and col so query keys are consistent
if (!sortDescriptor.orderBy) {
sortDescriptor.orderBy = defaultOrderBy;
}
if (!sortDescriptor.orderDir) {
sortDescriptor.orderDir = defaultOrderDir;
}
return [...queryKey, sortDescriptor, searchQuery, queryParams];
}
interface Response<T> extends BackendResponse {
pagination: PaginationResponse<T>;
}
export interface UseInfiniteDataProps<T> {
initialPage?: PaginationResponse<T> | null;
queryKey: QueryKey;
queryParams?: Record<string, string | number | null>;
endpoint: string;
defaultOrderBy?: SortDescriptor['orderBy'];
defaultOrderDir?: SortDescriptor['orderDir'];
// whether user can sort items manually (table header, dropdown, etc)
willSortOrFilter?: boolean;
// ordering is not available with cursor pagination
paginate?: 'simple' | 'lengthAware' | 'cursor';
transformResponse?: (response: Response<T>) => Response<T>;
}
export function useInfiniteData<T, E extends object = {}>(
props: UseInfiniteDataProps<T>,
): UseInfiniteDataResult<T, E> {
const {
initialPage,
endpoint,
defaultOrderBy,
defaultOrderDir,
queryParams,
paginate,
transformResponse,
willSortOrFilter = false,
} = props;
const [searchQuery, setSearchQuery] = useState('');
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
orderBy: defaultOrderBy,
orderDir: defaultOrderDir,
});
const queryKey = buildQueryKey(props, sortDescriptor, searchQuery);
const initialQueryKey = useRef(hashKey(queryKey)).current;
const query = useInfiniteQuery({
placeholderData: willSortOrFilter ? keepPreviousData : undefined,
queryKey,
queryFn: ({pageParam, signal}) => {
const params: GetDatatableDataParams = {
...queryParams,
perPage: initialPage?.per_page || queryParams?.perPage,
query: (queryParams?.query as string) ?? searchQuery,
paginate,
...sortDescriptor,
};
if (paginate === 'cursor') {
params.cursor = pageParam;
} else {
params.page = pageParam || 1;
}
return fetchData<T>(endpoint, params, transformResponse, signal);
},
initialPageParam: paginate === 'cursor' ? '' : 1,
getNextPageParam: lastResponse => {
if (!hasNextPage(lastResponse.pagination)) {
return null;
}
if ('next_cursor' in lastResponse.pagination) {
return lastResponse.pagination.next_cursor;
}
return lastResponse.pagination.current_page + 1;
},
initialData: () => {
// initial data will be for initial query key only, remove
// initial data if query key changes, so query is reset
if (!initialPage || hashKey(queryKey) !== initialQueryKey) {
return undefined;
}
return {
pageParams: [undefined, 1],
pages: [{pagination: initialPage}],
};
},
});
const items = useMemo(() => {
return query.data?.pages.flatMap(p => p.pagination.data) || [];
}, [query.data?.pages]);
const firstPage = query.data?.pages[0].pagination;
const totalItems =
firstPage && 'total' in firstPage && firstPage.total
? firstPage.total
: null;
return {
...query,
items,
totalItems,
noResults: query.data?.pages?.[0].pagination.data.length === 0,
// can't use "isRefetching", it's true for some reason when changing sorting or filters
isReloading:
query.isFetching && !query.isFetchingNextPage && query.isPlaceholderData,
sortDescriptor,
setSortDescriptor,
searchQuery,
setSearchQuery,
} as UseInfiniteDataResult<T, E>;
}
async function fetchData<T>(
endpoint: string,
params: GetDatatableDataParams,
transformResponse?: UseInfiniteDataProps<T>['transformResponse'],
signal?: AbortSignal,
): Promise<Response<T>> {
if (params.query) {
await new Promise(resolve => setTimeout(resolve, 300));
}
return apiClient
.get(endpoint, {params, signal: params.query ? signal : undefined})
.then(r => {
if (transformResponse) {
return transformResponse(r.data);
}
return r.data;
});
}