mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
Table data!
This commit is contained in:
parent
3ce419ad9c
commit
01e8f96e6e
7 changed files with 240 additions and 69 deletions
|
@ -179,7 +179,10 @@
|
|||
"title": "Points and redeems",
|
||||
"subtitle": "User leaderboard and pending reward redeems",
|
||||
"queue-tab": "Redeem queue",
|
||||
"users-tab": "Manage points"
|
||||
"users-tab": "Manage points",
|
||||
"username": "Viewer",
|
||||
"points": "Points",
|
||||
"username-filter": "Search by username"
|
||||
}
|
||||
},
|
||||
"form-actions": {
|
||||
|
|
126
frontend/src/ui/components/DataTable.tsx
Normal file
126
frontend/src/ui/components/DataTable.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||
import React, { useState } from 'react';
|
||||
import { styled } from '../theme';
|
||||
import { Table, TableHeader } from '../theme/table';
|
||||
import PageList from './PageList';
|
||||
|
||||
interface SortingOrder<T> {
|
||||
key: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: ({
|
||||
title: string;
|
||||
} & React.HTMLAttributes<HTMLTableCellElement> &
|
||||
(
|
||||
| {
|
||||
sortable: true;
|
||||
key: Extract<keyof T, string>;
|
||||
}
|
||||
| {
|
||||
sortable: false;
|
||||
key: string;
|
||||
}
|
||||
))[];
|
||||
defaultSort: SortingOrder<T>;
|
||||
view: (data: T) => React.ReactNode;
|
||||
sort: (key: keyof T) => (a: T, b: T) => number;
|
||||
}
|
||||
|
||||
const Sortable = styled('div', {
|
||||
display: 'flex',
|
||||
gap: '0.3rem',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
/**
|
||||
* DataTable is a component that displays a list of data in a table format with sorting and pagination.
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
defaultSort,
|
||||
sort,
|
||||
view,
|
||||
}: DataTableProps<T>): React.ReactElement {
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [sorting, setSorting] = useState<SortingOrder<T>>(defaultSort);
|
||||
|
||||
const changeSort = (key: keyof T) => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
const sortedEntries = data.slice(0);
|
||||
const sortFn = sort(sorting.key);
|
||||
if (sortFn) {
|
||||
sortedEntries.sort((a, b) => {
|
||||
const result = sortFn(a, b);
|
||||
switch (sorting.order) {
|
||||
case 'asc':
|
||||
return result;
|
||||
case 'desc':
|
||||
return -result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const offset = page * entriesPerPage;
|
||||
const paged = sortedEntries.slice(offset, offset + entriesPerPage);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
<Table>
|
||||
<thead>
|
||||
{columns.map((props) => (
|
||||
<TableHeader {...props} key={props.key}>
|
||||
{props.sortable ? (
|
||||
<Sortable onClick={() => changeSort(props.key)}>
|
||||
{props.title}
|
||||
{sorting.key === props.key &&
|
||||
(sorting.order === 'asc' ? (
|
||||
<ChevronUpIcon />
|
||||
) : (
|
||||
<ChevronDownIcon />
|
||||
))}
|
||||
</Sortable>
|
||||
) : (
|
||||
props.title
|
||||
)}
|
||||
</TableHeader>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>{paged.map(view)}</tbody>
|
||||
</Table>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -86,6 +86,7 @@ function PageList({
|
|||
</ToolbarComboBox>
|
||||
</ToolbarSection>
|
||||
<ToolbarSection>
|
||||
<div style={{ padding: '0 0.25rem' }}>{t('pagination.page')}</div>
|
||||
{current > min ? (
|
||||
<ToolbarButton
|
||||
className="button pagination-link"
|
||||
|
|
|
@ -3,8 +3,11 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useDispatch } from 'react-redux';
|
||||
import { useModule, useUserPoints } from '../../lib/react-utils';
|
||||
import { modules } from '../../store/api/reducer';
|
||||
import PageList from '../components/PageList';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
InputBox,
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
|
@ -14,6 +17,7 @@ import {
|
|||
TabList,
|
||||
TextBlock,
|
||||
} from '../theme';
|
||||
import { TableCell, TableRow } from '../theme/table';
|
||||
|
||||
interface UserSortingOrder {
|
||||
key: 'user' | 'points';
|
||||
|
@ -36,80 +40,75 @@ function UserList() {
|
|||
const users = useUserPoints();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [config] = useModule(modules.loyaltyConfig);
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const [sorting, setSorting] = useState<UserSortingOrder>({
|
||||
key: 'points',
|
||||
order: 'desc',
|
||||
});
|
||||
const filtered = Object.entries(users ?? [])
|
||||
.filter(([user]) => user.includes(usernameFilter))
|
||||
.map(([username, data]) => ({
|
||||
username,
|
||||
...data,
|
||||
}));
|
||||
type UserEntry = typeof filtered[0];
|
||||
|
||||
const changeSort = (key: 'user' | 'points') => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
type SortFn = (a: UserEntry, b: UserEntry) => number;
|
||||
|
||||
const sortfn = (key: keyof UserEntry): SortFn => {
|
||||
switch (key) {
|
||||
case 'username': {
|
||||
return (a, b) => a.username.localeCompare(b.username);
|
||||
}
|
||||
case 'points': {
|
||||
return (a: UserEntry, b: UserEntry) => a.points - b.points;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rawEntries = Object.entries(users ?? []);
|
||||
const filtered = rawEntries.filter(([user]) => user.includes(usernameFilter));
|
||||
|
||||
const sortedEntries = filtered;
|
||||
switch (sorting.key) {
|
||||
case 'user':
|
||||
if (sorting.order === 'asc') {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA > userB ? 1 : -1));
|
||||
} else {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA < userB ? 1 : -1));
|
||||
}
|
||||
break;
|
||||
case 'points':
|
||||
if (sorting.order === 'asc') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, a], [_b, b]) => a.points - b.points);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, a], [_b, b]) => b.points - a.points);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// unreacheable
|
||||
}
|
||||
|
||||
const offset = page * entriesPerPage;
|
||||
const paged = sortedEntries.slice(offset, offset + entriesPerPage);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
|
||||
{paged.map(([user, { points }]) => (
|
||||
<article key={user}>
|
||||
{user} - {points}
|
||||
</article>
|
||||
))}
|
||||
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
<Field size="fullWidth" spacing="none">
|
||||
<InputBox
|
||||
placeholder={t('pages.loyalty-queue.username-filter')}
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<DataTable
|
||||
sort={sortfn}
|
||||
data={filtered}
|
||||
columns={[
|
||||
{
|
||||
key: 'username',
|
||||
title: t('pages.loyalty-queue.username'),
|
||||
sortable: true,
|
||||
style: {
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
title: config?.currency || t('pages.loyalty-queue.points'),
|
||||
sortable: true,
|
||||
style: {
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '',
|
||||
sortable: false,
|
||||
},
|
||||
]}
|
||||
defaultSort={{ key: 'points', order: 'desc' }}
|
||||
view={({ username, points }) => (
|
||||
<TableRow key="username">
|
||||
<TableCell css={{ width: '100%' }}>{username}</TableCell>
|
||||
<TableCell>{points}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small">Edit</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -15,6 +15,9 @@ export const Field = styled('fieldset', {
|
|||
narrow: {
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
none: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
fullWidth: {
|
||||
|
@ -109,6 +112,7 @@ export const MultiButton = styled('div', {
|
|||
export const Button = styled('button', {
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
color: '$gray12',
|
||||
fontWeight: '300',
|
||||
padding: '0.5rem 1rem',
|
||||
|
|
37
frontend/src/ui/theme/table.ts
Normal file
37
frontend/src/ui/theme/table.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { theme, styled } from './theme';
|
||||
|
||||
export const Table = styled('table', {
|
||||
border: '1px solid $gray3',
|
||||
borderCollapse: 'collapse',
|
||||
});
|
||||
|
||||
export const TableRow = styled('tr', {
|
||||
all: 'unset',
|
||||
display: 'table-row',
|
||||
padding: '0.5rem',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'left',
|
||||
backgroundColor: '$gray1',
|
||||
'&:nth-child(even)': {
|
||||
backgroundColor: '$gray2',
|
||||
},
|
||||
});
|
||||
|
||||
export const TableHeader = styled('th', {
|
||||
all: 'unset',
|
||||
display: 'table-cell',
|
||||
padding: '0.25rem 0.5rem',
|
||||
height: '2rem',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'left',
|
||||
backgroundColor: '$gray3',
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
|
||||
export const TableCell = styled('td', {
|
||||
all: 'unset',
|
||||
display: 'table-cell',
|
||||
padding: '0.25rem 0.5rem',
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'left',
|
||||
});
|
|
@ -23,6 +23,7 @@ const itemStyles = {
|
|||
lineHeight: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
};
|
||||
|
||||
export const ToolbarButton = styled(ToolbarPrimitive.Button, {
|
||||
|
|
Loading…
Reference in a new issue