Skip to content

Commit

Permalink
feat: add a generic TableWithSearchAndFilter component
Browse files Browse the repository at this point in the history
  • Loading branch information
Karnak19 committed Sep 8, 2022
1 parent 05f1532 commit 6d692ea
Show file tree
Hide file tree
Showing 18 changed files with 388 additions and 145 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
"@emotion/react": "^11.10.0",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.9.3",
"@mantine/core": "^5.0.3",
"@mantine/core": "^5.2.3",
"@mantine/dropzone": "^5.0.3",
"@mantine/form": "^5.0.3",
"@mantine/hooks": "^5.0.3",
"@mantine/hooks": "^5.2.3",
"@mantine/modals": "^5.0.3",
"@mantine/next": "^5.0.3",
"@mantine/notifications": "^5.0.3",
"@mantine/rte": "^5.0.3",
"@mantine/spotlight": "^5.0.3",
"@tanstack/react-virtual": "^3.0.0-beta.18",
"cookies-next": "^2.1.1",
"fuse.js": "^6.6.2",
"next": "12.2.1",
Expand Down
4 changes: 3 additions & 1 deletion pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ const useStyles = createStyles((theme) => ({

export default function Home() {
const { data: categories, isLoading: isCatLoading } = useGetCategoriesQuery();
const { data: fans, isLoading: isFansLoading } = useGetFansQuery();
const {
list: { data: fans, isLoading: isFansLoading },
} = useGetFansQuery();
const { data: videos, isLoading: isVideosLoading } = useGetVideosQuery();
const { data: playlists, isLoading: isPlaylistsLoading } = useGetPlaylistsQuery();
const { data: modules, isLoading: isModulesLoading } = useGetAccountModulesQuery();
Expand Down
2 changes: 1 addition & 1 deletion pages/playlists/[playlistId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function PlaylistId() {
<div
style={{
width: '100%',
minHeight: '100vh',
minHeight: '80vh',
height: '100%',
position: 'relative',
}}
Expand Down
2 changes: 1 addition & 1 deletion pages/videos/[videoId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function VideoId() {
<div
style={{
width: '100%',
minHeight: '100vh',
minHeight: '80vh',
height: '100%',
position: 'relative',
}}
Expand Down
5 changes: 4 additions & 1 deletion src/components/FormDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Button, Drawer, Grid } from '@mantine/core';
import { useRouter } from 'next/router';
import { FilePlus } from 'tabler-icons-react';

function FormDrawer({
Expand All @@ -12,6 +13,7 @@ function FormDrawer({
buttonText?: string;
}) {
const [opened, setOpened] = useState(false);
const router = useRouter();

return (
<Grid.Col span={12}>
Expand All @@ -20,8 +22,9 @@ function FormDrawer({
</Button>
<Drawer
opened={opened}
title="Create new video"
title={`Create new ${router.pathname.split('/')[1]} `}
size="xl"
padding="xl"
position="right"
onClose={() => setOpened(false)}
>
Expand Down
117 changes: 117 additions & 0 deletions src/components/TableWithSearchAndFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useMemo, useRef } from 'react';
import { ScrollArea, SimpleGrid, Stack, Table, TextInput } from '@mantine/core';
import { useDebouncedState } from '@mantine/hooks';
import Fuse from 'fuse.js';
import { useRouter } from 'next/router';
import { Filter, Search } from 'tabler-icons-react';

import { useStickyHeader } from '../hooks/useStickyHeader';

interface IProps<T> {
isRoot?: boolean;
items: T[];
searchedItems?: T[];
itemRenderer: (item: T) => JSX.Element;
fuseKeys: (keyof T)[];
cols: {
always: (keyof (T & Record<string, unknown>))[];
fullSize: (keyof (T & Record<string, unknown>))[];
};
search?: string;
setSearch?: (newValue: string) => void;
lastRow: JSX.Element;
}

/**
*
* @param fuseKeys Keys for Fuse.js, associated with the filter input to filter out the view by fuzzy matching
* @param isRoot Boolean to display the whole rows or not.
*/
function TableWithSearchAndFilter<T>({
fuseKeys,
isRoot,
cols,
itemRenderer,
items,
searchedItems,
search,
setSearch,
lastRow,
}: IProps<T>) {
const [filter, setFilter] = useDebouncedState('', 300);

const parentRef = useRef<HTMLDivElement>(null);

const router = useRouter();

const { classes, cx, setScrolled, scrolled } = useStickyHeader();

const fuse = useMemo(
() =>
new Fuse(items || [], {
keys: fuseKeys as string[],
minMatchCharLength: 2,
}),
[items],
);

const results = useMemo(() => {
if (filter) {
return fuse.search(filter).map(({ item }) => itemRenderer(item));
}

if (search) {
return searchedItems?.map(itemRenderer);
}

return items.map(itemRenderer);
}, [items, filter, search]);

return (
<Stack>
<SimpleGrid cols={2}>
{new String(search) && !!setSearch && (
<TextInput
label="Search (in DB)"
defaultValue={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={`Search ${router.pathname.split('/')[1]}`}
icon={<Search />}
/>
)}
<TextInput
label="Filter (the current view)"
defaultValue={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={`Filter by ${fuseKeys.join(', ')}`}
icon={<Filter />}
/>
</SimpleGrid>
<ScrollArea
viewportRef={parentRef}
sx={{ height: '80vh' }}
onScrollPositionChange={({ y }) => setScrolled(y !== 0)}
>
<Table striped highlightOnHover>
<thead className={cx(classes.header, { [classes.scrolled]: scrolled })}>
<tr>
{cols.always.map((col) => (
<th key={col.toString()}>{col.toString()}</th>
))}

{isRoot && cols.fullSize.map((col) => <th key={col.toString()}>{col.toString()}</th>)}

<th>Copy ID</th>
</tr>
</thead>
<tbody>
{results}
{lastRow}
</tbody>
</Table>
</ScrollArea>
</Stack>
);
}

export default TableWithSearchAndFilter;
6 changes: 4 additions & 2 deletions src/features/fans/FanPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { FansList, useGetFansQuery } from '.';

function FanPageLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isLoading } = useGetFansQuery();
const {
list: { isLoading },
} = useGetFansQuery();

const isRoot = router.pathname === '/fans';

Expand All @@ -20,7 +22,7 @@ function FanPageLayout({ children }: { children: React.ReactNode }) {
>
<LoadingOverlay visible={isLoading} />
<Grid.Col span={isRoot ? 12 : 4}>
<FansList isRoot={isRoot} />
<FansList />
</Grid.Col>
<Grid.Col span={8}>{children}</Grid.Col>
</Grid>
Expand Down
100 changes: 32 additions & 68 deletions src/features/fans/FansList.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,52 @@
import React, { useMemo, useState } from 'react';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import { Avatar, ScrollArea, Stack, Table, TextInput } from '@mantine/core';
import Fuse from 'fuse.js';
import { Avatar } from '@mantine/core';
import { useRouter } from 'next/router';
import { Users } from 'tabler-icons-react';

import StatusBadge from '../../components/StatusBadge';
import TableWithSearchAndFilter from '../../components/TableWithSearchAndFilter';
import TdClipboardId from '../../components/TdClipboardId';
import { useSelected } from '../../hooks/useSelectedStyle';
import { useStickyHeader } from '../../hooks/useStickyHeader';
import { useGetFanByIdQuery, useGetFanProductsQuery, useGetFansQuery } from '.';
import { Fan } from './fetcher';

function FansList({ isRoot }: { isRoot?: boolean }) {
const [search, setSearch] = useState('');

const { data, ref } = useGetFansQuery();

const fans = data?.pages.flatMap((p) => p.items) || [];

const { classes, cx, setScrolled, scrolled } = useStickyHeader();

const fuse = useMemo(
() =>
new Fuse(fans || [], {
keys: ['email', 'username', 'id'],
minMatchCharLength: 2,
}),
[data],
);

const fuzzyResults = useMemo(
() =>
fuse?.search(search).map(({ item }) => {
return <Item key={item.id} item={item} isRoot={isRoot} />;
}),
[fuse, search],
);
function FansList() {
const router = useRouter();
const {
list: { data },
search: { data: searchedData },
searchState,
setSearchState,
ref,
} = useGetFansQuery();

const results = fans?.map((item) => <Item key={item.id} item={item} isRoot={isRoot} />);
const isRoot = router.pathname === '/fans';

return (
<Stack>
<div>
<TextInput
label="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter fans"
icon={<Users />}
/>
</div>
<ScrollArea sx={{ height: '80vh' }} onScrollPositionChange={({ y }) => setScrolled(y !== 0)}>
<Table striped highlightOnHover>
<thead className={cx(classes.header, { [classes.scrolled]: scrolled })}>
<tr>
<th>Avatar</th>
<th>Email</th>
{isRoot && (
<>
<th>Username</th>
<th>Status</th>
<th>Profiles</th>
<th>Products</th>
</>
)}
<th>Copy ID</th>
</tr>
</thead>
<tbody>
{search ? fuzzyResults : results}
<tr ref={ref}>
<td colSpan={7}>fetching more...</td>
</tr>
</tbody>
</Table>
</ScrollArea>
</Stack>
<TableWithSearchAndFilter
items={data?.pages.flatMap((p) => p.items) || []}
searchedItems={searchedData?.pages.flatMap((p) => p.items) || []}
itemRenderer={(a) => <Item item={a} isRoot={isRoot} />}
isRoot={isRoot}
fuseKeys={['id', 'email']}
cols={{
always: ['imageUrl', 'email'],
fullSize: ['username', 'status', 'profiles', 'products'],
}}
search={searchState}
setSearch={setSearchState}
lastRow={
<tr ref={ref}>
<td colSpan={7}>fetching more...</td>
</tr>
}
/>
);
}

export default FansList;

function Item({ item, isRoot }: { isRoot?: boolean; item: Fan }) {
export function Item({ item, isRoot }: { isRoot?: boolean; item: Fan }) {
const router = useRouter();
const { classes, isSelected } = useSelected('fanId');

Expand Down
12 changes: 12 additions & 0 deletions src/features/fans/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ export const getFans = (accountKey: string | undefined) => ({
}).then((response) => response.json() as Promise<{ items: Fan[]; cursor: { after: string } }>),
});

export const getFansByUsername = (accountKey: string | undefined, username: string) => ({
key: ['fans', { accountKey, username }],
query: async ({ pageParam = '' }) =>
betterFetch(`${USERS_SERVICE_URL}/fans?limit=20&after=${pageParam}&username=${username}`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-account-key': accountKey || '',
},
}).then((response) => response.json() as Promise<{ items: Fan[]; cursor: { after: string } }>),
});

export const getFanById = (fanId: string, accountKey: string | undefined) => ({
key: ['fans', { accountKey, fanId }],
query: async () =>
Expand Down
Loading

0 comments on commit 6d692ea

Please sign in to comment.