Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion template/apps/api/src/resources/user/actions/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async function handler(ctx: AppKoaContext<ValidatedData>) {
{ sort },
);

ctx.body = { ...result, results: result.results.map(userService.getPublic) };
ctx.body = { ...result, results: result.results.map(userService.getPublic), page };
}

export default (router: AppRouter) => {
Expand Down
60 changes: 60 additions & 0 deletions template/apps/web/src/components/InfiniteScrollContainer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FC, ReactNode, UIEventHandler, useEffect } from 'react';
import { Center, Loader, ScrollAreaAutosize, ScrollAreaProps, Stack } from '@mantine/core';
import { useInViewport } from '@mantine/hooks';

export interface InfiniteScrollProps extends ScrollAreaProps {
children: ReactNode;
fetchNextData: () => void;
hasMore: boolean;
maxContainerHeight?: number | string;
isFetchingNextPage: boolean;
isLoading: boolean;
}

const InfiniteScrollContainer: FC<InfiniteScrollProps> = ({
children,
maxContainerHeight,
hasMore,
fetchNextData,
isFetchingNextPage,
isLoading,
...props
}) => {
const { ref: loadMoreBlockRef, inViewport } = useInViewport();

useEffect(() => {
if (inViewport && hasMore && !isFetchingNextPage && !isLoading) {
fetchNextData();
}
}, [inViewport, hasMore, isLoading, isFetchingNextPage]);

const handleViewportScroll: UIEventHandler<HTMLDivElement> = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;

if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1 && hasMore && !isLoading) {
fetchNextData();
}
};

return (
<ScrollAreaAutosize
mah={maxContainerHeight}
viewportProps={{
onScroll: handleViewportScroll,
}}
{...props}
>
{children}

<Stack ref={loadMoreBlockRef} />

{isFetchingNextPage && (
<Center mt="sm">
<Loader size="sm" />
</Center>
)}
</ScrollAreaAutosize>
);
};

export default InfiniteScrollContainer;
1 change: 1 addition & 0 deletions template/apps/web/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as InfiniteScrollContainer } from './InfiniteScrollContainer';
export { default as Table } from './Table';
3 changes: 3 additions & 0 deletions template/apps/web/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { useMobile } from './use-mobile';

export { useMobile };
8 changes: 8 additions & 0 deletions template/apps/web/src/hooks/use-mobile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { em } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';

export const useMobile = () => {
const isMobile = useMediaQuery(`(max-width: ${em(750)})`);

return isMobile;
};
30 changes: 30 additions & 0 deletions template/apps/web/src/pages/home/components/Filters/helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ComboboxItem } from '@mantine/core';

export const getSortByOptions = (isMobile: boolean): ComboboxItem[] => {
return [
{
value: 'newest',
label: 'Newest',
},
{
value: 'oldest',
label: 'Oldest',
},
...(isMobile
? [
{
value: 'firstName',
label: 'First Name',
},
{
value: 'lastName',
label: 'Last Name',
},
{
value: 'email',
label: 'Email',
},
]
: []),
];
};
37 changes: 20 additions & 17 deletions template/apps/web/src/pages/home/components/Filters/index.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
import { FC, useLayoutEffect, useState } from 'react';
import { ActionIcon, ComboboxItem, Group, Select, TextInput } from '@mantine/core';
import { ActionIcon, Group, Select, Stack, TextInput } from '@mantine/core';
import { DatePickerInput, DatesRangeValue } from '@mantine/dates';
import { useDebouncedValue, useInputState, useSetState } from '@mantine/hooks';
import { IconSearch, IconSelector, IconX } from '@tabler/icons-react';
import { useMobile } from 'hooks';
import { set } from 'lodash';

import { UserListParams } from 'resources/user';

const selectOptions: ComboboxItem[] = [
{
value: 'newest',
label: 'Newest',
},
{
value: 'oldest',
label: 'Oldest',
},
];
import { getSortByOptions } from './helpers';

interface FiltersProps {
setParams: ReturnType<typeof useSetState<UserListParams>>[1];
}

const Filters: FC<FiltersProps> = ({ setParams }) => {
const isMobile = useMobile();

const sortByOptions = getSortByOptions(isMobile);

const [search, setSearch] = useInputState('');
const [sortBy, setSortBy] = useState<string | null>(selectOptions[0].value);
const [sortBy, setSortBy] = useState<string | null>(sortByOptions[0].value);
const [filterDate, setFilterDate] = useState<DatesRangeValue>();

const [debouncedSearch] = useDebouncedValue(search, 500);
Expand Down Expand Up @@ -57,9 +53,9 @@ const Filters: FC<FiltersProps> = ({ setParams }) => {

return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Group wrap="nowrap" component={isMobile ? Stack : undefined} flex={isMobile ? 1 : undefined}>
<TextInput
w={350}
w={isMobile ? '100%' : 350}
size="md"
value={search}
onChange={setSearch}
Expand All @@ -75,9 +71,9 @@ const Filters: FC<FiltersProps> = ({ setParams }) => {
/>

<Select
w={200}
w={isMobile ? '100%' : 200}
size="md"
data={selectOptions}
data={sortByOptions}
value={sortBy}
onChange={handleSort}
allowDeselect={false}
Expand All @@ -92,7 +88,14 @@ const Filters: FC<FiltersProps> = ({ setParams }) => {
}}
/>

<DatePickerInput type="range" size="md" placeholder="Pick date" value={filterDate} onChange={handleFilter} />
<DatePickerInput
valueFormat={isMobile ? 'YYYY-MM-DD' : undefined}
type="range"
size="md"
placeholder="Pick date"
value={filterDate}
onChange={handleFilter}
/>
</Group>
</Group>
);
Expand Down
16 changes: 16 additions & 0 deletions template/apps/web/src/pages/home/components/UserCard/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { UserCardField } from './types';

export const USER_CARD_FIELDS: UserCardField[] = [
{
label: 'First Name',
key: 'firstName',
},
{
label: 'Last Name',
key: 'lastName',
},
{
label: 'Email',
key: 'email',
},
];
37 changes: 37 additions & 0 deletions template/apps/web/src/pages/home/components/UserCard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FC } from 'react';
import { Card, Grid, Stack, Text } from '@mantine/core';

import { User } from 'types';

import { USER_CARD_FIELDS } from './constants';

interface UserCardProps {
user: User;
onClick: (user: User) => void;
}

const UserCard: FC<UserCardProps> = ({ user, onClick }) => {
const userCardFields = USER_CARD_FIELDS.map((field) => {
return (
<Grid key={field.key}>
<Grid.Col span={6}>
<Text fw={500}>{field.label}</Text>
</Grid.Col>

<Grid.Col span={6}>
<Text truncate maw={150}>
{user[field.key] as string}
</Text>
</Grid.Col>
</Grid>
);
});

return (
<Card shadow="sm" radius="md" withBorder onClick={() => onClick(user)}>
<Stack gap="sm">{userCardFields}</Stack>
</Card>
);
};

export default UserCard;
6 changes: 6 additions & 0 deletions template/apps/web/src/pages/home/components/UserCard/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { User } from 'types';

export interface UserCardField {
label: string;
key: keyof User;
}
66 changes: 51 additions & 15 deletions template/apps/web/src/pages/home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import { useMemo } from 'react';
import { NextPage } from 'next';
import Head from 'next/head';
import { Stack, Title } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { SortDirection } from '@tanstack/react-table';
import { useMobile } from 'hooks';
import { pick } from 'lodash';

import { userApi, UserListParams } from 'resources/user';

import { Table } from 'components';
import { InfiniteScrollContainer, Table } from 'components';

import { User } from 'types';

import Filters from './components/Filters';
import UserCard from './components/UserCard';
import { COLUMNS, DEFAULT_PAGE, DEFAULT_PARAMS, EXTERNAL_SORT_FIELDS, PER_PAGE } from './constants';

const Home: NextPage = () => {
const [params, setParams] = useSetState<UserListParams>(DEFAULT_PARAMS);

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

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

const {
data: usersInfinityListData,
isLoading: isUserInfinityListLoading,
hasNextPage: hasUsersInfinityListNextPage,
fetchNextPage: fetchUsersInfinityListNextPage,
isFetchingNextPage: isFetchingUsersInfinityListNextPage,
} = userApi.useInfinityList(params, { enabled: isMobile });

const onSortingChange = (sort: Record<string, SortDirection>) => {
setParams((prev) => {
Expand All @@ -28,14 +41,24 @@ const Home: NextPage = () => {
});
};

const onRowClick = (user: User) => {
const onUserClick = (user: User) => {
showNotification({
title: 'Success',
message: `You clicked on the row for the user with the email address ${user.email}.`,
color: 'green',
});
};

const displayedUsersCards = useMemo(() => {
if (!isMobile) {
return null;
}

return usersInfinityListData?.pages
.flatMap((p) => p.results)
.map((user) => <UserCard key={user._id} user={user} onClick={onUserClick} />);
}, [isMobile, usersInfinityListData?.pages]);

return (
<>
<Head>
Expand All @@ -47,18 +70,31 @@ const Home: NextPage = () => {

<Filters setParams={setParams} />

<Table<User>
data={users?.results}
totalCount={users?.count}
pageCount={users?.pagesCount}
page={DEFAULT_PAGE}
perPage={PER_PAGE}
columns={COLUMNS}
isLoading={isUserListLoading}
onPageChange={(page) => setParams({ page })}
onSortingChange={onSortingChange}
onRowClick={onRowClick}
/>
{isMobile && (
<InfiniteScrollContainer
isLoading={isUserInfinityListLoading}
isFetchingNextPage={isFetchingUsersInfinityListNextPage}
hasMore={hasUsersInfinityListNextPage}
fetchNextData={fetchUsersInfinityListNextPage}
>
<Stack>{displayedUsersCards}</Stack>
</InfiniteScrollContainer>
)}

{!isMobile && (
<Table<User>
data={users?.results}
totalCount={users?.count}
pageCount={users?.pagesCount}
page={DEFAULT_PAGE}
perPage={PER_PAGE}
columns={COLUMNS}
isLoading={isUserListLoading}
onPageChange={(page) => setParams({ page })}
onSortingChange={onSortingChange}
onRowClick={onUserClick}
/>
)}
</Stack>
</>
);
Expand Down
2 changes: 1 addition & 1 deletion template/apps/web/src/pages/profile/index.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const Profile: NextPage = () => {
<title>Profile</title>
</Head>

<Stack w={408} m="auto" pt={48} gap={32}>
<Stack w={{ lg: 408, md: 408, sm: '100%', xs: '100%' }} m="auto" pt={48} gap={32}>
<Title order={1}>Profile</Title>

<FormProvider {...methods}>
Expand Down
2 changes: 1 addition & 1 deletion template/apps/web/src/resources/user/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as userApi from './user.api';

export type * from './user.api';
export * from './user.types';

export { userApi };
Loading