Skip to content

Commit

Permalink
Pagination Improvements (#283)
Browse files Browse the repository at this point in the history
  • Loading branch information
fruneen committed Mar 4, 2024
1 parent 96c3a10 commit e83792e
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 83 deletions.
87 changes: 44 additions & 43 deletions template/apps/api/src/resources/user/actions/list.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,65 @@
import { z } from 'zod';

import { AppKoaContext, AppRouter } from 'types';
import { AppKoaContext, AppRouter, NestedKeys, User } from 'types';

import { userService } from 'resources/user';

import { validateMiddleware } from 'middlewares';

const schema = z.object({
page: z.string().transform(Number).default('1'),
perPage: z.string().transform(Number).default('10'),
sort: z.object({
createdOn: z.enum(['asc', 'desc']),
}).default({ createdOn: 'desc' }),
import { stringUtil } from 'utils';

import { paginationSchema } from 'schemas';

const schema = paginationSchema.extend({
filter: z.object({
createdOn: z.object({
sinceDate: z.string(),
dueDate: z.string(),
}).nullable().default(null),
}).nullable().default(null),
searchValue: z.string().default(''),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
}).optional(),
}).optional(),
});

type ValidatedData = z.infer<typeof schema>;

async function handler(ctx: AppKoaContext<ValidatedData>) {
const {
perPage, page, sort, searchValue, filter,
} = ctx.validatedData;

const validatedSearch = searchValue.split('\\').join('\\\\').split('.').join('\\.');
const regExp = new RegExp(validatedSearch, 'gi');

const users = await userService.find(
{
$and: [
{
$or: [
{ firstName: { $regex: regExp } },
{ lastName: { $regex: regExp } },
{ email: { $regex: regExp } },
{ createdOn: {} },
],
const { perPage, page, sort, searchValue, filter } = ctx.validatedData;

const filterOptions = [];

if (searchValue) {
const searchPattern = stringUtil.escapeRegExpString(searchValue);

const searchFields: NestedKeys<User>[] = ['firstName', 'lastName', 'email'];

filterOptions.push({
$or: searchFields.map((field) => ({ [field]: { $regex: searchPattern } })),
});
}

if (filter) {
const { createdOn, ...otherFilters } = filter;

if (createdOn) {
const { startDate, endDate } = createdOn;

filterOptions.push({
createdOn: {
...(startDate && ({ $gte: startDate })),
...(endDate && ({ $lt: endDate })),
},
filter?.createdOn ? {
createdOn: {
$gte: new Date(filter.createdOn.sinceDate as string),
$lt: new Date(filter.createdOn.dueDate as string),
},
} : {},
],
},
});
}

Object.entries(otherFilters).forEach(([key, value]) => {
filterOptions.push({ [key]: value });
});
}

ctx.body = await userService.find(
{ ...(filterOptions.length && { $and: filterOptions }) },
{ page, perPage },
{ sort },
);

ctx.body = {
items: users.results,
totalPages: users.pagesCount,
count: users.count,
};
}

export default (router: AppRouter) => {
Expand Down
2 changes: 2 additions & 0 deletions template/apps/api/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import configUtil from './config.util';
import promiseUtil from './promise.util';
import routeUtil from './routes.util';
import securityUtil from './security.util';
import stringUtil from './string.util';

export {
configUtil,
promiseUtil,
routeUtil,
securityUtil,
stringUtil,
};
9 changes: 9 additions & 0 deletions template/apps/api/src/utils/string.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const escapeRegExpString = (searchString: string): RegExp => {
const escapedString = searchString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

return new RegExp(escapedString, 'gi');
};

export default {
escapeRegExpString,
};
55 changes: 28 additions & 27 deletions template/apps/web/src/pages/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,30 @@ import {
import { useDebouncedValue, useInputState } from '@mantine/hooks';
import { IconSearch, IconX, IconSelector } from '@tabler/icons-react';
import { RowSelectionState, SortingState } from '@tanstack/react-table';
import { DatePickerInput, DatesRangeValue } from '@mantine/dates';
import { DatePickerInput, DatesRangeValue, DateValue } from '@mantine/dates';

import { userApi } from 'resources/user';

import { Table } from 'components';

import { ListParams, SortOrder } from 'types';

import { PER_PAGE, columns, selectOptions } from './constants';

import classes from './index.module.css';

interface UsersListParams {
page?: number;
perPage?: number;
searchValue?: string;
sort?: {
createdOn: 'asc' | 'desc';
};
filter?: {
createdOn?: {
sinceDate: Date | null;
dueDate: Date | null;
};
type FilterParams = {
createdOn?: {
startDate: DateValue;
endDate: DateValue;
};
}
};

type SortParams = {
createdOn?: SortOrder;
};

type UsersListParams = ListParams<FilterParams, SortParams>;

const Home: NextPage = () => {
const [search, setSearch] = useInputState('');
Expand All @@ -58,20 +58,20 @@ const Home: NextPage = () => {
}));
}, []);

const handleFilter = useCallback(([sinceDate, dueDate]: DatesRangeValue) => {
setFilterDate([sinceDate, dueDate]);
const handleFilter = useCallback(([startDate, endDate]: DatesRangeValue) => {
setFilterDate([startDate, endDate]);

if (!sinceDate) {
if (!startDate) {
setParams((prev) => ({
...prev,
filter: {},
}));
}

if (dueDate) {
if (endDate) {
setParams((prev) => ({
...prev,
filter: { createdOn: { sinceDate, dueDate } },
filter: { createdOn: { startDate, endDate } },
}));
}
}, []);
Expand All @@ -80,13 +80,14 @@ const Home: NextPage = () => {
setParams((prev) => ({ ...prev, page: 1, searchValue: debouncedSearch, perPage: PER_PAGE }));
}, [debouncedSearch]);

const { data, isLoading: isListLoading } = userApi.useList(params);
const { data: users, isLoading: isUserListLoading } = userApi.useList(params);

return (
<>
<Head>
<title>Home</title>
</Head>

<Stack gap="lg">
<Title order={2}>Users</Title>

Expand All @@ -96,7 +97,7 @@ const Home: NextPage = () => {
className={classes.inputSkeleton}
height={42}
radius="sm"
visible={isListLoading}
visible={isUserListLoading}
width="auto"
>
<TextInput
Expand All @@ -121,7 +122,7 @@ const Home: NextPage = () => {
width="auto"
height={42}
radius="sm"
visible={isListLoading}
visible={isUserListLoading}
>
<Select
w={200}
Expand All @@ -145,7 +146,7 @@ const Home: NextPage = () => {
className={classes.datePickerSkeleton}
height={42}
radius="sm"
visible={isListLoading}
visible={isUserListLoading}
width="auto"
>
<DatePickerInput
Expand All @@ -159,7 +160,7 @@ const Home: NextPage = () => {
</Group>
</Group>

{isListLoading && (
{isUserListLoading && (
<>
{[1, 2, 3].map((item) => (
<Skeleton
Expand All @@ -172,11 +173,11 @@ const Home: NextPage = () => {
</>
)}

{data?.items.length ? (
{users?.results.length ? (
<Table
columns={columns}
data={data.items}
dataCount={data.count}
data={users.results}
dataCount={users.count}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
sorting={sorting}
Expand Down
18 changes: 5 additions & 13 deletions template/apps/web/src/resources/user/user.api.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { useQuery } from '@tanstack/react-query';

import { User } from 'types';
import { ListResult, User } from 'types';

import { apiService } from 'services';

export function useList<T>(params: T) {
interface UserListResponse {
count: number;
items: User[];
totalPages: number;
}

return useQuery<UserListResponse>({
queryKey: ['users', params],
queryFn: () => apiService.get('/users', params),
});
}
export const useList = <T>(params: T) => useQuery<ListResult<User>>({
queryKey: ['users', params],
queryFn: () => apiService.get('/users', params),
});
20 changes: 20 additions & 0 deletions template/apps/web/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
export * from 'app-types';

export type QueryParam = string | string[] | undefined;

export type ListResult<T> = {
results: T[];
pagesCount: number;
count: number;
};

export type SortOrder = 'asc' | 'desc';

export type SortParams<F> = {
[P in keyof F]?: SortOrder;
};

export type ListParams<T, F> = {
page?: number;
perPage?: number;
searchValue?: string;
filter?: T;
sort?: SortParams<F>;
};
7 changes: 7 additions & 0 deletions template/packages/app-types/src/common.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type Path<T> = T extends object ? {
[K in keyof T]: K extends string
? `${K}` | (Path<T[K]> extends infer R ? (R extends never ? never : `${K}.${R & string}`) : never)
: never
}[keyof T] : never;

export type NestedKeys<T> = Path<Required<T>>;
2 changes: 2 additions & 0 deletions template/packages/app-types/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from 'enums';

export * from './common.types';

export * from './token.types';
export * from './user.types';
2 changes: 2 additions & 0 deletions template/packages/schemas/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './pagination.schema';

export * from './token.schema';
export * from './user.schema';
12 changes: 12 additions & 0 deletions template/packages/schemas/src/pagination.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from 'zod';

export const paginationSchema = z.object({
page: z.coerce.number().default(1),
perPage: z.coerce.number().default(10),

searchValue: z.string().optional(),

sort: z.object({
createdOn: z.enum(['asc', 'desc']).default('asc'),
}).default({}),
});

0 comments on commit e83792e

Please sign in to comment.