mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 19:58:13 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
158
libs/ui/src/lib/data/AdvancedTable.md
Normal file
158
libs/ui/src/lib/data/AdvancedTable.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# AdvancedTable
|
||||
|
||||
Server-paginated data table with a column-visibility ("View") menu. Built on Mantine `Table`. Portable — two files, no app-specific imports.
|
||||
|
||||
## Files
|
||||
|
||||
- `AdvancedTable.tsx` — the component.
|
||||
- `useServerTable.ts` — small hook for page-index + search-query state (optional, but pairs with it).
|
||||
|
||||
To use in another project, copy both files as-is into that project and export them from your UI barrel (or import by relative path).
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
@mantine/core (tested on ^8)
|
||||
@tabler/icons-react (IconRefresh, IconEye, IconInbox)
|
||||
react-i18next (useTranslation)
|
||||
react (>=17, uses hooks)
|
||||
```
|
||||
|
||||
If the target project doesn't use `react-i18next`, replace the `t(key, fallback)` calls with plain strings — the component only reads the fallback text, translation is not load-bearing.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @mantine/core @tabler/icons-react react-i18next
|
||||
```
|
||||
|
||||
Mantine must already be set up with `MantineProvider` in the app root — this component does not wrap one.
|
||||
|
||||
## Copy the source
|
||||
|
||||
Copy `AdvancedTable.tsx` and `useServerTable.ts` into the new project (e.g. `src/components/table/`). No modifications needed unless you're renaming the i18n keys.
|
||||
|
||||
## API
|
||||
|
||||
### `AdvancedColumn<T>`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `header` | `ReactNode` | yes | Column heading, also used as the label in the View menu. |
|
||||
| `accessorKey` | `string` | no | Dot-path into the row (e.g. `"expectation.name"`), used when `cell` is omitted. |
|
||||
| `cell` | `(ctx: { row: { original: T }; value: unknown }) => ReactNode` | no | Custom cell renderer. Takes priority over `accessorKey`. |
|
||||
| `size` | `number` | no | Column width in px. |
|
||||
| `align` | `'left' \| 'center' \| 'right'` | no | Text alignment for header + cells. |
|
||||
| `enabled` | `boolean` | no | Whether the column starts **visible**. Default `true`. Toggled at runtime via the View menu. |
|
||||
|
||||
### `AdvancedTable<T>` props
|
||||
|
||||
| Prop | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `columns` | `AdvancedColumn<T>[]` | yes | |
|
||||
| `data` | `T[]` | yes | Rows for the **current page** only — not the full dataset. |
|
||||
| `tableName` | `string` | yes | Shown as the table title. |
|
||||
| `itemCount` | `number` | yes | Total row count on the server. Drives pagination and the count badge — not `data.length`. |
|
||||
| `pageIndex` | `number` | yes | 0-based current page. |
|
||||
| `onPageChange` | `(pageIndex: number) => void` | yes | |
|
||||
| `pageSize` | `number` | no | Default `10`. Pagination only renders when `itemCount > pageSize`. |
|
||||
| `onPageSizeChange` | `(pageSize: number) => void` | no | Shows a page-size `<Select>` (10/20/30/40/50 by default) next to the pagination when given. |
|
||||
| `pageSizeOptions` | `number[]` | no | Options for the page-size select. Default `[10, 20, 30, 40, 50]`. |
|
||||
| `refresh` | `() => void` | no | Shows a Refresh button when provided. |
|
||||
| `onSearchChange` | `(q: string) => void` | no | Reserved for a search box; not currently rendered by the component itself (wire your own input and call this, or drive `useServerTable`'s `setQ`). |
|
||||
| `isLoading` | `boolean` | no | Shows a loader row; also spins the Refresh button. |
|
||||
| `emptyText` | `string` | no | Message when `data` is empty. |
|
||||
|
||||
Rows should have an `id: string \| number` field — used as the React key (falls back to row index if absent).
|
||||
|
||||
### `useServerTable(opts?)`
|
||||
|
||||
```ts
|
||||
const { pageIndex, setPageIndex, q, setQ, pageSize, setPageSize, skip, take } = useServerTable({ pageSize: 10 });
|
||||
```
|
||||
|
||||
Centralizes page-index + search-query state for a server-paginated list. `setQ` and `setPageSize` both reset `pageIndex` back to 0. `skip`/`take` are ready to drop into an offset-based API call. Wire `setPageSize` into `AdvancedTable`'s `onPageSizeChange` to expose the page-size select.
|
||||
|
||||
## Behavior notes
|
||||
|
||||
- **Column visibility** is local UI state (`useState`), re-initialized from each column's `enabled` on mount — it does not persist across reloads or sync back to the caller.
|
||||
- At least one column always stays visible; the View menu disables unchecking the last one.
|
||||
- The View menu closes only via outside click (`closeOnItemClick={false}`), so multiple columns can be toggled per open.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```tsx
|
||||
import { AdvancedTable, type AdvancedColumn } from './table/AdvancedTable';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const columns: AdvancedColumn<User>[] = [
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{ header: 'Email', accessorKey: 'email', enabled: true },
|
||||
];
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
tableName="Users"
|
||||
itemCount={users.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Server-paginated, with refresh and a custom action column
|
||||
|
||||
```tsx
|
||||
import { AdvancedTable, useServerTable, type AdvancedColumn } from './table/AdvancedTable';
|
||||
|
||||
function UsersTable() {
|
||||
const { pageIndex, setPageIndex, skip, take } = useServerTable({ pageSize: 10 });
|
||||
const { data, isFetching, refetch } = useGetUsersQuery({ skip, take });
|
||||
|
||||
const users = data?.items ?? [];
|
||||
const totalCount = data?.total ?? 0;
|
||||
|
||||
const columns: AdvancedColumn<User>[] = [
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{ header: 'Email', accessorKey: 'email' },
|
||||
{
|
||||
header: 'Status',
|
||||
align: 'center',
|
||||
cell: ({ row }) => (row.original.active ? 'Active' : 'Inactive'),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon onClick={() => onEdit(row.original)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
tableName="Users"
|
||||
itemCount={totalCount}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={10}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText="No users found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Real reference implementation: `apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx` (`ProfessionTab`).
|
||||
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal file
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { CSSProperties, ReactNode, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Menu,
|
||||
Checkbox,
|
||||
Group,
|
||||
Text,
|
||||
Pagination,
|
||||
Loader,
|
||||
Center,
|
||||
Paper,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface AdvancedColumn<T> {
|
||||
header: ReactNode;
|
||||
/** Dot-path into the row, used when no `cell` is given (e.g. "expectation.name"). */
|
||||
accessorKey?: string;
|
||||
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
|
||||
size?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
/** Whether column starts visible. Default true. */
|
||||
enabled?: boolean;
|
||||
/** Label for the View menu; falls back to `header` when it is a plain string. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface AdvancedTableProps<T> {
|
||||
columns: AdvancedColumn<T>[];
|
||||
data: T[];
|
||||
tableName: string;
|
||||
/** Total item count on the server (drives pagination), not data.length. */
|
||||
itemCount: number;
|
||||
/** 0-based current page. */
|
||||
pageIndex: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
pageSize?: number;
|
||||
/** Shows a page-size <Select> when given; called with the chosen size. */
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
/** Options for the page-size select. Default [10, 20, 30, 40, 50]. */
|
||||
pageSizeOptions?: number[];
|
||||
refresh?: () => void;
|
||||
/** Server-side search — debounced internally. Omit to hide the search box. */
|
||||
onSearchChange?: (q: string) => void;
|
||||
isLoading?: boolean;
|
||||
emptyText?: string;
|
||||
verticalSpacing?: string | number;
|
||||
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
|
||||
/** Makes rows clickable (adds pointer cursor). */
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path?: string): unknown {
|
||||
if (!path) return undefined;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, key) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[key]
|
||||
: undefined,
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvancedTable<T extends { id?: string | number }>({
|
||||
columns,
|
||||
data,
|
||||
tableName,
|
||||
itemCount,
|
||||
pageIndex,
|
||||
onPageChange,
|
||||
pageSize = 10,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 30, 40, 50],
|
||||
refresh,
|
||||
isLoading = false,
|
||||
emptyText,
|
||||
verticalSpacing = "sm",
|
||||
rowStyle,
|
||||
onRowClick,
|
||||
}: AdvancedTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState<boolean[]>(
|
||||
columns.map((c) => c.enabled ?? true),
|
||||
);
|
||||
const toggleColumn = (i: number) =>
|
||||
setVisible((prev) => {
|
||||
if (prev[i] && prev.filter(Boolean).length === 1) return prev; // keep at least one column visible
|
||||
return prev.map((v, idx) => (idx === i ? !v : v));
|
||||
});
|
||||
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{""}</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{refresh && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={refresh}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("common.refresh", "Refresh")}
|
||||
</Button>
|
||||
)}
|
||||
<Menu
|
||||
closeOnItemClick={false}
|
||||
shadow="md"
|
||||
position="bottom-end"
|
||||
width={220}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconAdjustmentsHorizontal size={16} />}
|
||||
>
|
||||
{t("common.view", "View")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{t("common.toggleColumns", "Toggle columns")}
|
||||
</Menu.Label>
|
||||
{columns.map((col, i) => {
|
||||
// A ReactNode header (e.g. a live checkbox or a clickable sort
|
||||
// control) can't be reused as a menu-item label — skip it
|
||||
// rather than nesting interactive markup inside the label.
|
||||
const label = col.label ?? (typeof col.header === "string" ? col.header : null);
|
||||
if (label === null) return null;
|
||||
return (
|
||||
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
|
||||
<Checkbox
|
||||
label={label}
|
||||
checked={visible[i] ?? true}
|
||||
disabled={
|
||||
visible.filter(Boolean).length === 1 &&
|
||||
(visible[i] ?? true)
|
||||
}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
styles={{
|
||||
input: { cursor: "pointer" },
|
||||
label: { cursor: "pointer" },
|
||||
}}
|
||||
/>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={480}>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
verticalSpacing={verticalSpacing}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{shownColumns.map((col, i) => (
|
||||
<Table.Th
|
||||
key={i}
|
||||
style={{ width: col.size, textAlign: col.align }}
|
||||
>
|
||||
{col.header}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={shownColumns.length}>
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : data.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={shownColumns.length}>
|
||||
<Center py="xl">
|
||||
<Group gap="xs" c="dimmed">
|
||||
<IconInbox size={18} />
|
||||
<Text c="dimmed">
|
||||
{emptyText ?? t("common.noResult", "No results")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.map((row, rowIndex) => (
|
||||
<Table.Tr
|
||||
key={row.id ?? rowIndex}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
style={{
|
||||
...(onRowClick ? { cursor: "pointer" } : undefined),
|
||||
...rowStyle?.(row, rowIndex),
|
||||
}}
|
||||
>
|
||||
{shownColumns.map((col, i) => {
|
||||
const value = getByPath(row, col.accessorKey);
|
||||
return (
|
||||
<Table.Td key={i} style={{ textAlign: col.align }}>
|
||||
{col.cell
|
||||
? col.cell({ row: { original: row }, value })
|
||||
: ((value as ReactNode) ?? "-")}
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{(itemCount > pageSize || onPageSizeChange) && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
{onPageSizeChange && (
|
||||
<Select
|
||||
size="sm"
|
||||
w={100}
|
||||
data={pageSizeOptions.map((n) => String(n))}
|
||||
value={String(pageSize)}
|
||||
onChange={(v) => v && onPageSizeChange(Number(v))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
{itemCount > pageSize && (
|
||||
<Pagination
|
||||
total={Math.ceil(itemCount / pageSize)}
|
||||
value={pageIndex + 1}
|
||||
onChange={(page) => onPageChange(page - 1)}
|
||||
size="sm"
|
||||
siblings={0}
|
||||
boundaries={1}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
53
libs/ui/src/lib/data/useServerTable.ts
Normal file
53
libs/ui/src/lib/data/useServerTable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseServerTableOptions {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes the page-index + search-query state a server-paginated table
|
||||
* needs. Changing the search query resets paging back to page 0.
|
||||
*/
|
||||
export function useServerTable({ pageSize: initialPageSize = 10 }: UseServerTableOptions = {}) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSizeInternal] = useState(initialPageSize);
|
||||
const [q, setQInternal] = useState('');
|
||||
|
||||
const setQ = useCallback((value: string) => {
|
||||
setQInternal(value);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
const setPageSize = useCallback((value: number) => {
|
||||
setPageSizeInternal(value);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
// Slice an already-fetched array for AdvancedTable when the endpoint has no
|
||||
// skip/take of its own. Clamps pageIndex so deleting the last row of the
|
||||
// last page doesn't strand the table on an empty slice.
|
||||
const paginate = useCallback(
|
||||
<T,>(rows: T[]) => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1);
|
||||
const clamped = Math.min(pageIndex, lastPage);
|
||||
return {
|
||||
rows: rows.slice(clamped * pageSize, clamped * pageSize + pageSize),
|
||||
pageIndex: clamped,
|
||||
itemCount: rows.length,
|
||||
};
|
||||
},
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
pageIndex,
|
||||
setPageIndex,
|
||||
q,
|
||||
setQ,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
take: pageSize,
|
||||
paginate,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user