Skip to content

Commit e264c36

Browse files
committed
feat: add infinite card
1 parent e405018 commit e264c36

File tree

3 files changed

+328
-5
lines changed

3 files changed

+328
-5
lines changed

src/app/social/page.tsx

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
11
'use client';
2-
import { useQuery } from '@tanstack/react-query';
2+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
33
import { AxiosError } from 'axios';
44
import React from 'react';
55
import ReactTimeAgo from 'react-time-ago';
66

77
import { searchUser, userKey } from '@/lib/api/user';
8-
import { buildPaginatedTableURL } from '@/lib/table';
8+
import axios from '@/lib/axios';
9+
import { buildInfiniteTableURL, buildPaginatedTableURL } from '@/lib/table';
910
import useQueryToast from '@/hooks/toast/useQueryToast';
1011
import useServerTable from '@/hooks/useServerTable';
1112

1213
import ButtonLink from '@/components/links/ButtonLink';
1314
import NextImage from '@/components/NextImage';
1415
import ServerCard from '@/components/table/ServerCard';
16+
import ServerInfiniteCard from '@/components/table/ServerInfiniteCard';
1517
import Typography from '@/components/typography/Typography';
1618

1719
import useAuthStore from '@/store/useAuthStore';
1820
import useChatStore from '@/store/useChatStore';
1921

20-
import { ApiError, PaginatedApiResponse } from '@/types/api';
22+
import {
23+
ApiError,
24+
ApiResponse,
25+
BasePaginationResponseField,
26+
PaginatedApiResponse,
27+
} from '@/types/api';
2128
import { User } from '@/types/user';
2229

2330
export default function SocialPage() {
2431
const user = useAuthStore.useUser();
2532
const chatStore = useChatStore();
2633

27-
const { tableState, setTableState } =
28-
useServerTable<Omit<User, 'email' | 'roles'>>();
34+
const { tableState, setTableState } = useServerTable<
35+
Omit<User, 'email' | 'roles'>
36+
>({ pageSize: 6 });
2937

3038
const url = buildPaginatedTableURL({
3139
baseUrl: '/user/search',
@@ -45,9 +53,42 @@ export default function SocialPage() {
4553
{ hideSuccess: true }
4654
);
4755

56+
const result = useInfiniteQuery({
57+
queryKey: [userKey, 'users', tableState.globalFilter],
58+
queryFn: async ({ pageParam }) => {
59+
return await axios
60+
.get<
61+
ApiResponse<
62+
{
63+
users: Array<Omit<User, 'email' | 'roles'>>;
64+
} & BasePaginationResponseField
65+
>
66+
>(
67+
buildInfiniteTableURL({
68+
baseUrl: '/user/search',
69+
page: pageParam as number,
70+
tableState,
71+
})
72+
)
73+
.then((res) => {
74+
const { users, ...rest } = res.data.data;
75+
return { ...rest, statusCode: 200, data: users };
76+
});
77+
},
78+
initialPageParam: 0,
79+
getNextPageParam: (lastPage, allPages, lastPageParam) => {
80+
const maxPage = Math.ceil(
81+
lastPage.total / tableState.pagination.pageSize
82+
);
83+
return lastPageParam + 1 < maxPage ? lastPage.page : null;
84+
},
85+
getPreviousPageParam: (firstPage) => firstPage.page - 1,
86+
});
87+
4888
return (
4989
<>
5090
<div className='w-full'>
91+
<h2 className='text-lg md:text-xl'>Paginated Card</h2>
5192
<ServerCard
5293
card={
5394
<>
@@ -153,6 +194,119 @@ export default function SocialPage() {
153194
withFilter
154195
/>
155196
</div>
197+
<div className='w-full'>
198+
<h2 className='text-lg md:text-xl'>Infinite Scroll</h2>
199+
<ServerInfiniteCard
200+
card={
201+
<>
202+
<div className='mt-5'>
203+
{result.data?.pages
204+
.flatMap((page) => page.data)
205+
.map((item) => (
206+
<div
207+
key={item.id}
208+
className='flex h-32 p-5 shadow-lg dark:text-white dark:shadow-gray-700'
209+
>
210+
<div className='w-[80px] rounded-full'>
211+
<NextImage
212+
width={80}
213+
height={80}
214+
src={item.picture ?? '/images/profile_picture.jpg'}
215+
classNames={{
216+
image:
217+
'rounded-full w-[80px] h-[80px] object-cover',
218+
}}
219+
alt='User'
220+
className='w-[80px]'
221+
unoptimized={true}
222+
/>
223+
</div>
224+
<div className='ml-5'>
225+
<Typography variant='s2' className='dark:text-white'>
226+
{item.username}
227+
</Typography>
228+
<Typography variant='s4' className='dark:text-white'>
229+
Joined{' '}
230+
<ReactTimeAgo
231+
date={new Date(item.createdAt)}
232+
locale='en-US'
233+
/>
234+
</Typography>
235+
{item.id !== user?.id && (
236+
<div className='mt-1'>
237+
<ButtonLink
238+
href='/chat'
239+
variant='outline'
240+
className='border-graydark text-graydark hover:bg-gray-100 dark:border-white dark:text-white dark:hover:bg-gray-600'
241+
onClick={() => {
242+
const chat = chatStore.chatList?.find(
243+
(chat) =>
244+
chat.user1.id == item.id ||
245+
chat.user2.id == item.id
246+
);
247+
if (chat) {
248+
chatStore.setActiveChat(chat);
249+
} else {
250+
const newChat = {
251+
id: `${(user as User).id}-${item.id}`,
252+
user1: user as User,
253+
user2: item,
254+
lastMessage: '',
255+
lastMessageAt: null,
256+
isNewChat: true,
257+
};
258+
chatStore.setNewChat(newChat);
259+
chatStore.setActiveChat(newChat);
260+
}
261+
}}
262+
>
263+
<svg
264+
className='mr-1 fill-current duration-300 ease-in-out'
265+
width='18'
266+
height='18'
267+
viewBox='0 0 18 18'
268+
fill='none'
269+
xmlns='http://www.w3.org/2000/svg'
270+
>
271+
<path
272+
d='M10.9688 1.57495H7.03135C3.43135 1.57495 0.506348 4.41558 0.506348 7.90308C0.506348 11.3906 2.75635 13.8375 8.26885 16.3125C8.40947 16.3687 8.52197 16.3968 8.6626 16.3968C8.85947 16.3968 9.02822 16.3406 9.19697 16.2281C9.47822 16.0593 9.64697 15.75 9.64697 15.4125V14.2031H10.9688C14.5688 14.2031 17.522 11.3625 17.522 7.87495C17.522 4.38745 14.5688 1.57495 10.9688 1.57495ZM10.9688 12.9937H9.3376C8.80322 12.9937 8.35322 13.4437 8.35322 13.9781V15.0187C3.6001 12.825 1.74385 10.8 1.74385 7.9312C1.74385 5.14683 4.10635 2.8687 7.03135 2.8687H10.9688C13.8657 2.8687 16.2563 5.14683 16.2563 7.9312C16.2563 10.7156 13.8657 12.9937 10.9688 12.9937Z'
273+
fill=''
274+
/>
275+
<path
276+
d='M5.42812 7.28442C5.0625 7.28442 4.78125 7.56567 4.78125 7.9313C4.78125 8.29692 5.0625 8.57817 5.42812 8.57817C5.79375 8.57817 6.075 8.29692 6.075 7.9313C6.075 7.56567 5.79375 7.28442 5.42812 7.28442Z'
277+
fill=''
278+
/>
279+
<path
280+
d='M9.00015 7.28442C8.63452 7.28442 8.35327 7.56567 8.35327 7.9313C8.35327 8.29692 8.63452 8.57817 9.00015 8.57817C9.33765 8.57817 9.64702 8.29692 9.64702 7.9313C9.64702 7.56567 9.33765 7.28442 9.00015 7.28442Z'
281+
fill=''
282+
/>
283+
<path
284+
d='M12.5719 7.28442C12.2063 7.28442 11.925 7.56567 11.925 7.9313C11.925 8.29692 12.2063 8.57817 12.5719 8.57817C12.9375 8.57817 13.2188 8.29692 13.2188 7.9313C13.2188 7.56567 12.9094 7.28442 12.5719 7.28442Z'
285+
fill=''
286+
/>
287+
</svg>
288+
Chat
289+
</ButtonLink>
290+
</div>
291+
)}
292+
</div>
293+
</div>
294+
))}
295+
</div>
296+
</>
297+
}
298+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
299+
//@ts-ignore
300+
response={result.data?.pages.flatMap((page) => page.data)}
301+
data={result.data?.pages.flatMap((page) => page.data) ?? []}
302+
isLoading={isPending}
303+
tableState={tableState}
304+
setTableState={setTableState}
305+
className='mt-8'
306+
withFilter
307+
infiniteResult={result}
308+
/>
309+
</div>
156310
</>
157311
);
158312
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { InfiniteData } from '@tanstack/query-core';
2+
import { UseInfiniteQueryResult } from '@tanstack/react-query';
3+
import {
4+
getCoreRowModel,
5+
PaginationState,
6+
SortingState,
7+
useReactTable,
8+
} from '@tanstack/react-table';
9+
import clsx from 'clsx';
10+
import * as React from 'react';
11+
12+
import { cn } from '@/lib/utils';
13+
14+
import Button from '@/components/buttons/Button';
15+
import Filter from '@/components/table/Filter';
16+
import PopupFilter, { PopupFilterProps } from '@/components/table/PopupFilter';
17+
18+
import { PaginatedApiResponse } from '@/types/api';
19+
20+
export type ServerInfiniteCardState = {
21+
globalFilter: string;
22+
pagination: PaginationState;
23+
sorting: SortingState;
24+
};
25+
26+
type SetServerInfiniteCardState = {
27+
setGlobalFilter: React.Dispatch<React.SetStateAction<string>>;
28+
setPagination: React.Dispatch<React.SetStateAction<PaginationState>>;
29+
setSorting: React.Dispatch<React.SetStateAction<SortingState>>;
30+
};
31+
32+
type ServerInfiniteCardProps<T extends object> = {
33+
card: React.ReactNode;
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
popUpFilterProps?: Omit<PopupFilterProps<any, T>, 'table'>;
36+
isLoading: boolean;
37+
response: PaginatedApiResponse<T[]> | undefined;
38+
data: T[];
39+
tableState: ServerInfiniteCardState;
40+
setTableState: SetServerInfiniteCardState;
41+
withFilter?: boolean;
42+
infiniteResult: UseInfiniteQueryResult<
43+
InfiniteData<PaginatedApiResponse<T[]>>
44+
>;
45+
} & React.ComponentPropsWithoutRef<'div'>;
46+
47+
export default function ServerInfiniteCard<T extends object>({
48+
card: Card,
49+
className,
50+
popUpFilterProps,
51+
isLoading,
52+
response,
53+
data,
54+
tableState,
55+
setTableState,
56+
withFilter = false,
57+
infiniteResult,
58+
...rest
59+
}: ServerInfiniteCardProps<T>) {
60+
const lastPage =
61+
response && response.page_size
62+
? Math.ceil(response.total / response.page_size)
63+
: 0;
64+
const table = useReactTable({
65+
data: data,
66+
columns: [],
67+
pageCount: lastPage,
68+
state: {
69+
...tableState,
70+
},
71+
defaultColumn: {
72+
minSize: 0,
73+
size: 0,
74+
},
75+
onGlobalFilterChange: setTableState.setGlobalFilter,
76+
onPaginationChange: setTableState.setPagination,
77+
onSortingChange: setTableState.setSorting,
78+
getCoreRowModel: getCoreRowModel(),
79+
manualPagination: true,
80+
manualSorting: true,
81+
});
82+
83+
const { hasNextPage, fetchNextPage } = infiniteResult;
84+
85+
return (
86+
<div className={cn('flex flex-col', className)} {...rest}>
87+
<div
88+
className={clsx(
89+
'flex flex-col items-stretch gap-3 sm:flex-row',
90+
withFilter ? 'sm:justify-between' : 'sm:justify-end'
91+
)}
92+
>
93+
{withFilter && <Filter table={table} />}
94+
<div className='flex items-center gap-3'>
95+
{popUpFilterProps && (
96+
<PopupFilter
97+
table={table}
98+
filterOption={popUpFilterProps.filterOption}
99+
setFilterQuery={popUpFilterProps.setFilterQuery}
100+
title={popUpFilterProps.title}
101+
buttonClassname={popUpFilterProps.buttonClassname}
102+
/>
103+
)}
104+
</div>
105+
</div>
106+
<div className='flex flex-col'>
107+
{isLoading ? 'Loading...' : data.length == 0 ? 'No entry found' : Card}
108+
</div>
109+
{hasNextPage && (
110+
<div className='mt-5 flex flex-row justify-center'>
111+
<Button
112+
variant='outline'
113+
size='sm'
114+
disabled={!hasNextPage}
115+
className='flex w-fit'
116+
onClick={() => fetchNextPage()}
117+
>
118+
Load More
119+
</Button>
120+
</div>
121+
)}
122+
</div>
123+
);
124+
}

src/lib/table.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,48 @@ export const buildPaginatedTableURL: BuildPaginationTableURL = ({
4444
...options,
4545
}
4646
);
47+
48+
type BuildInfiniteTableParam = {
49+
/** API Base URL, with / on the front */
50+
baseUrl: string;
51+
tableState: ServerTableState;
52+
page: number;
53+
/** Parameter addition
54+
* @example include: ['user', 'officer']
55+
*/
56+
additionalParam?: Record<string, unknown>;
57+
options?: StringifyOptions;
58+
};
59+
type BuildInfiniteTableURL = (props: BuildInfiniteTableParam) => string;
60+
61+
export const buildInfiniteTableURL: BuildInfiniteTableURL = ({
62+
baseUrl,
63+
page,
64+
tableState,
65+
additionalParam,
66+
options,
67+
}) =>
68+
queryString.stringifyUrl(
69+
{
70+
url: baseUrl,
71+
query: {
72+
page_size: tableState.pagination.pageSize,
73+
page: page + 1,
74+
sort_name:
75+
tableState.sorting.length > 0 ? tableState.sorting[0].id : '',
76+
sort_type:
77+
tableState.sorting.length > 0
78+
? tableState.sorting[0].desc
79+
? 'desc'
80+
: 'asc'
81+
: '',
82+
keyword: tableState.globalFilter,
83+
...additionalParam,
84+
},
85+
},
86+
{
87+
arrayFormat: 'comma',
88+
skipEmptyString: true,
89+
...options,
90+
}
91+
);

0 commit comments

Comments
 (0)