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",
|
"title": "Points and redeems",
|
||||||
"subtitle": "User leaderboard and pending reward redeems",
|
"subtitle": "User leaderboard and pending reward redeems",
|
||||||
"queue-tab": "Redeem queue",
|
"queue-tab": "Redeem queue",
|
||||||
"users-tab": "Manage points"
|
"users-tab": "Manage points",
|
||||||
|
"username": "Viewer",
|
||||||
|
"points": "Points",
|
||||||
|
"username-filter": "Search by username"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form-actions": {
|
"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>
|
</ToolbarComboBox>
|
||||||
</ToolbarSection>
|
</ToolbarSection>
|
||||||
<ToolbarSection>
|
<ToolbarSection>
|
||||||
|
<div style={{ padding: '0 0.25rem' }}>{t('pagination.page')}</div>
|
||||||
{current > min ? (
|
{current > min ? (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
className="button pagination-link"
|
className="button pagination-link"
|
||||||
|
|
|
@ -3,8 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useModule, useUserPoints } from '../../lib/react-utils';
|
import { useModule, useUserPoints } from '../../lib/react-utils';
|
||||||
import { modules } from '../../store/api/reducer';
|
import { modules } from '../../store/api/reducer';
|
||||||
import PageList from '../components/PageList';
|
import { DataTable } from '../components/DataTable';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Field,
|
||||||
|
InputBox,
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
|
@ -14,6 +17,7 @@ import {
|
||||||
TabList,
|
TabList,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
} from '../theme';
|
} from '../theme';
|
||||||
|
import { TableCell, TableRow } from '../theme/table';
|
||||||
|
|
||||||
interface UserSortingOrder {
|
interface UserSortingOrder {
|
||||||
key: 'user' | 'points';
|
key: 'user' | 'points';
|
||||||
|
@ -36,80 +40,75 @@ function UserList() {
|
||||||
const users = useUserPoints();
|
const users = useUserPoints();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
const [config] = useModule(modules.loyaltyConfig);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [usernameFilter, setUsernameFilter] = useState('');
|
const [usernameFilter, setUsernameFilter] = useState('');
|
||||||
const [sorting, setSorting] = useState<UserSortingOrder>({
|
const filtered = Object.entries(users ?? [])
|
||||||
key: 'points',
|
.filter(([user]) => user.includes(usernameFilter))
|
||||||
order: 'desc',
|
.map(([username, data]) => ({
|
||||||
});
|
username,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
type UserEntry = typeof filtered[0];
|
||||||
|
|
||||||
const changeSort = (key: 'user' | 'points') => {
|
type SortFn = (a: UserEntry, b: UserEntry) => number;
|
||||||
if (sorting.key === key) {
|
|
||||||
// Same key, swap sorting order
|
const sortfn = (key: keyof UserEntry): SortFn => {
|
||||||
setSorting({
|
switch (key) {
|
||||||
...sorting,
|
case 'username': {
|
||||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
return (a, b) => a.username.localeCompare(b.username);
|
||||||
});
|
}
|
||||||
} else {
|
case 'points': {
|
||||||
// Different key, change to sort that key
|
return (a: UserEntry, b: UserEntry) => a.points - b.points;
|
||||||
setSorting({ ...sorting, key, order: 'asc' });
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageList
|
<Field size="fullWidth" spacing="none">
|
||||||
current={page + 1}
|
<InputBox
|
||||||
min={1}
|
placeholder={t('pages.loyalty-queue.username-filter')}
|
||||||
max={totalPages + 1}
|
value={usernameFilter}
|
||||||
itemsPerPage={entriesPerPage}
|
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
/>
|
||||||
onPageChange={(p) => setPage(p - 1)}
|
</Field>
|
||||||
/>
|
<DataTable
|
||||||
|
sort={sortfn}
|
||||||
{paged.map(([user, { points }]) => (
|
data={filtered}
|
||||||
<article key={user}>
|
columns={[
|
||||||
{user} - {points}
|
{
|
||||||
</article>
|
key: 'username',
|
||||||
))}
|
title: t('pages.loyalty-queue.username'),
|
||||||
|
sortable: true,
|
||||||
<PageList
|
style: {
|
||||||
current={page + 1}
|
width: '100%',
|
||||||
min={1}
|
textAlign: 'left',
|
||||||
max={totalPages + 1}
|
},
|
||||||
itemsPerPage={entriesPerPage}
|
},
|
||||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
{
|
||||||
onPageChange={(p) => setPage(p - 1)}
|
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: {
|
narrow: {
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
},
|
},
|
||||||
|
none: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
fullWidth: {
|
fullWidth: {
|
||||||
|
@ -109,6 +112,7 @@ export const MultiButton = styled('div', {
|
||||||
export const Button = styled('button', {
|
export const Button = styled('button', {
|
||||||
all: 'unset',
|
all: 'unset',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
color: '$gray12',
|
color: '$gray12',
|
||||||
fontWeight: '300',
|
fontWeight: '300',
|
||||||
padding: '0.5rem 1rem',
|
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,
|
lineHeight: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ToolbarButton = styled(ToolbarPrimitive.Button, {
|
export const ToolbarButton = styled(ToolbarPrimitive.Button, {
|
||||||
|
|
Loading…
Reference in a new issue