feat: sortable table component

This commit is contained in:
Sonny
2024-08-30 23:35:05 +02:00
parent 442a1003bb
commit f0ec6d6b3d
6 changed files with 346 additions and 98 deletions

View File

@@ -0,0 +1,22 @@
import styled from '@emotion/styled';
const IconButton = styled.button(({ theme }) => ({
cursor: 'pointer',
height: '2rem',
width: '2rem',
fontSize: '1rem',
color: theme.colors.font,
backgroundColor: theme.colors.grey,
borderRadius: '50%',
border: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:disabled': {
cursor: 'not-allowed',
opacity: 0.15,
},
}));
export default IconButton;

View File

@@ -0,0 +1,218 @@
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
import IconButton from '~/components/common/icon_button';
import {
MdKeyboardArrowLeft,
MdKeyboardArrowRight,
MdKeyboardDoubleArrowLeft,
MdKeyboardDoubleArrowRight,
} from 'react-icons/md';
import Input from '~/components/common/form/_input';
const TablePageFooter = styled.div({
display: 'flex',
gap: '1em',
alignItems: 'center',
});
const Box = styled(TablePageFooter)({
gap: '0.35em',
});
const Resizer = styled.div<{ isResizing: boolean }>(
({ theme, isResizing }) => ({
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '5px',
opacity: !isResizing ? 0 : 1,
background: !isResizing ? theme.colors.white : theme.colors.primary,
'&:hover': {
opacity: 0.5,
},
})
);
type TableProps<T> = {
columns: ColumnDef<T>[];
data: T[];
};
export default function Table<T>({ columns, data }: TableProps<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
data,
columns,
enableColumnResizing: true,
columnResizeMode: 'onChange',
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: true,
});
return (
<div css={{ fontSize: '0.9rem', paddingBlock: '1em' }}>
<div
css={{
maxWidth: '100%',
marginBottom: '1em',
display: 'block',
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
}}
css={{
position: 'relative',
userSelect: 'none',
// resizer
'&:hover > div > div': {
opacity: 0.5,
},
}}
colSpan={header.colSpan}
>
{header.isPlaceholder ? null : (
<div
css={{
cursor: header.column.getCanSort()
? 'pointer'
: 'default',
}}
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getCanSort()
? header.column.getNextSortingOrder() === 'asc'
? 'Sort ascending'
: header.column.getNextSortingOrder() === 'desc'
? 'Sort descending'
: 'Clear sort'
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
{header.column.getCanResize() && (
<Resizer
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
isResizing={header.column.getIsResizing()}
/>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table
.getRowModel()
.rows.slice(0, 10)
.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{table.getPageCount() > 1 && (
<TablePageFooter>
<Box>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardDoubleArrowLeft />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardArrowLeft />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<MdKeyboardArrowRight />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<MdKeyboardDoubleArrowRight />
</IconButton>
</Box>
<Box>
<span>Page</span>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</Box>
<Box>
Go to page
<Input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</Box>
</TablePageFooter>
)}
</div>
);
}

View File

@@ -131,7 +131,9 @@ function GlobalStyles() {
},
'th, td': {
whiteSpace: 'nowrap',
whiteSspace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'tr:nth-of-type(even)': {

View File

@@ -1,14 +1,13 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ColumnDef } from '@tanstack/react-table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { ReactNode } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import RoundedImage from '~/components/common/rounded_image';
import TextEllipsis from '~/components/common/text_ellipsis';
import Table from '~/components/common/table';
import ContentLayout from '~/components/layouts/content_layout';
import { DATE_FORMAT } from '~/constants';
import { sortByCreationDate } from '~/lib/array';
import { UserWithRelationCount } from '~/types/app';
dayjs.extend(relativeTime);
@@ -19,15 +18,18 @@ interface AdminDashboardProps {
totalLinks: number;
}
const Cell = styled.div<{ column?: boolean; fixed?: boolean }>(
({ column, fixed }) => ({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: fixed ? 'unset' : 'center',
gap: column ? 0 : '0.35em',
flexDirection: column ? 'column' : 'row',
})
const CenteredCell = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
});
const RenderDateCell = (info: any) => (
<CenteredCell>
<span>{dayjs(info.getValue().toString()).fromNow()}</span>
<span>{dayjs(info.getValue().toString()).format(DATE_FORMAT)}</span>
</CenteredCell>
);
const ThemeProvider = (props: AdminDashboardProps) => (
@@ -44,95 +46,76 @@ function AdminDashboard({
}: AdminDashboardProps) {
const { t } = useTranslation();
const theme = useTheme();
return (
<div style={{ overflow: 'auto', marginTop: '1em' }}>
<table>
<thead>
<tr>
<TableCell type="th">#</TableCell>
<TableCell type="th">{t('common:name')}</TableCell>
<TableCell type="th">{t('common:email')}</TableCell>
<TableCell type="th">
const columns = useMemo(
() =>
[
{
accessorKey: 'id',
header: (
<>
# <span css={{ color: theme.colors.grey }}>({users.length})</span>
</>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'fullname',
header: t('common:name'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'email',
header: t('common:email'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'count',
header: (
<>
{t('common:collection.collections', { count: totalCollections })}{' '}
<span css={{ color: theme.colors.grey }}>
({totalCollections})
</span>
</TableCell>
<TableCell type="th">
</>
),
cell: (info) => (info.getValue() as any)?.collection,
},
{
accessorKey: 'count',
header: (
<>
{t('common:link.links', { count: totalLinks })}{' '}
<span css={{ color: theme.colors.grey }}>({totalLinks})</span>
</TableCell>
<TableCell type="th">{t('admin:role')}</TableCell>
<TableCell type="th">{t('admin:created_at')}</TableCell>
<TableCell type="th">{t('admin:updated_at')}</TableCell>
</tr>
</thead>
<tbody>
{users.length !== 0 &&
sortByCreationDate(users).map((user) => (
<TableUserRow user={user} key={user.id} />
))}
</tbody>
</table>
</div>
</>
),
cell: (info: any) => info.getValue()?.link,
},
{
accessorKey: 'isAdmin',
header: t('admin:role'),
cell: (info) =>
info.getValue() ? (
<span style={{ color: theme.colors.lightRed }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: theme.colors.green }}>
{t('admin:user')}
</span>
),
},
{
accessorKey: 'createdAt',
header: t('admin:created_at'),
cell: RenderDateCell,
},
{
accessorKey: 'updatedAt',
header: t('admin:updated_at'),
cell: RenderDateCell,
},
] as ColumnDef<UserWithRelationCount>[],
[]
);
}
function TableUserRow({ user }: { user: UserWithRelationCount }) {
const { t } = useTranslation();
const theme = useTheme();
const { id, fullname, avatarUrl, email, isAdmin, createdAt, updatedAt } =
user;
return (
<tr>
<TableCell type="td">{id}</TableCell>
<TableCell type="td" fixed>
{avatarUrl && (
<RoundedImage
src={avatarUrl}
width={24}
height={24}
alt={fullname}
title={fullname}
referrerPolicy="no-referrer"
/>
)}
<TextEllipsis>{fullname ?? '-'}</TextEllipsis>
</TableCell>
<TableCell type="td">
<TextEllipsis>{email}</TextEllipsis>
</TableCell>
<TableCell type="td">{user.count.collection}</TableCell>
<TableCell type="td">{user.count.link}</TableCell>
<TableCell type="td">
{isAdmin ? (
<span style={{ color: theme.colors.lightRed }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: theme.colors.green }}>{t('admin:user')}</span>
)}
</TableCell>
<TableCell type="td" column>
<span>{dayjs(createdAt.toString()).fromNow()}</span>
<span>{dayjs(createdAt.toString()).format(DATE_FORMAT)}</span>
</TableCell>
<TableCell type="td" column>
<span>{dayjs(updatedAt.toString()).fromNow()}</span>
<span>{dayjs(updatedAt.toString()).format(DATE_FORMAT)}</span>
</TableCell>
</tr>
);
}
type TableItem = {
children: ReactNode;
type: 'td' | 'th';
fixed?: boolean;
column?: boolean;
};
function TableCell({ children, type, ...props }: TableItem) {
const child = <Cell {...props}>{children}</Cell>;
return type === 'td' ? <td>{child}</td> : <th>{child}</th>;
return <Table columns={columns} data={users} />;
}

View File

@@ -78,6 +78,7 @@
"@emotion/styled": "^11.11.5",
"@inertiajs/react": "^1.2.0",
"@izzyjs/route": "^1.1.0-0",
"@tanstack/react-table": "^8.20.5",
"@vinejs/vine": "^2.1.0",
"bentocache": "^1.0.0-beta.9",
"dayjs": "^1.11.11",

22
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
'@izzyjs/route':
specifier: ^1.1.0-0
version: 1.1.0-0(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(babel-plugin-macros@3.1.0)(typescript@5.4.5))(@vinejs/vine@2.1.0)(edge.js@6.0.2))(edge.js@6.0.2)
'@tanstack/react-table':
specifier: ^8.20.5
version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@vinejs/vine':
specifier: ^2.1.0
version: 2.1.0
@@ -1244,6 +1247,17 @@ packages:
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
'@tanstack/react-table@8.20.5':
resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
@@ -5877,6 +5891,14 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
'@tanstack/react-table@8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/table-core': 8.20.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/table-core@8.20.5': {}
'@tokenizer/token@0.3.0': {}
'@tootallnate/quickjs-emscripten@0.23.0': {}