From 4c0a5de7e731f9bd4c20de48c013e0cfbf7a9e21 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 00:40:53 +0700 Subject: [PATCH] Describe the tables as TanStack columns Add ui/DataTable, which builds ReUI's DataGrid from `columns` and `data`, and move all 23 tables onto it. What the grid does not take (a class on a row or cell, `aria-sort`, `style`, `colSpan`, `role`, `hidden`) is put on the elements it drew after every render. The column definitions keep their identity between renders, so cells are not mounted again, and the grid's own paging is off. The stacked rows on a phone, the column labels and the sort order of the original work as before. In Blocking > Lists a group heading is a row whose first cell spans the four columns. docs/reui-migration-plan.md records it. Co-Authored-By: Claude Sonnet 5.5 --- docs/reui-migration-plan.md | 2 +- src/screens/admin/Cluster.tsx | 147 +++--- src/screens/admin/Groups.tsx | 65 ++- src/screens/admin/Sessions.tsx | 88 ++-- src/screens/admin/UserDetails.tsx | 66 +-- src/screens/admin/Users.tsx | 121 ++--- src/screens/blocking/BlockLists.tsx | 278 ++++++----- src/screens/blocking/RecentBlocked.tsx | 55 ++- src/screens/blocking/Rules.tsx | 47 +- src/screens/blocking/TopTable.tsx | 56 ++- src/screens/dashboard/TopStats.tsx | 74 +-- src/screens/dhcp/Leases.tsx | 91 ++-- src/screens/dhcp/Scopes.tsx | 79 +-- src/screens/lists/Records.tsx | 192 ++++---- src/screens/logs/QueryLogs.tsx | 127 +++-- src/screens/modals/MyProfile.tsx | 96 ++-- src/screens/zones/DataCell.tsx | 31 +- src/screens/zones/ZoneList.tsx | 454 ++++++++++-------- src/screens/zones/ZoneRecords.tsx | 171 ++++--- src/screens/zones/modals/DnssecProperties.tsx | 235 +++++---- src/screens/zones/modals/ViewDs.tsx | 41 +- src/screens/zones/modals/ZonePermissions.tsx | 64 ++- src/ui/DataTable.tsx | 165 +++++++ src/ui/Table.tsx | 41 +- 24 files changed, 1611 insertions(+), 1175 deletions(-) create mode 100644 src/ui/DataTable.tsx diff --git a/docs/reui-migration-plan.md b/docs/reui-migration-plan.md index 897a925..7edb2ef 100644 --- a/docs/reui-migration-plan.md +++ b/docs/reui-migration-plan.md @@ -66,7 +66,7 @@ | Tooltip | ReUI Tooltip (роль `tooltip` и `aria-describedby` сохранены) | | Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) | | Select | ReUI Popover (поиск, группы и набор по буквам сохранены) | -| Table | Рамка таблицы теперь ReUI `DataGrid` (`DataGrid`, `DataGridTableViewport`, `DataGridTableBase`, `DataGridTableHead`, `DataGridTableBody`, `DataGridTableFoot`) с экземпляром TanStack `useTable`. Строки остаются своими ``/`` внутри тела: колонки как данные не описываются, потому что ячейки несут меню, ссылки, `colSpan` и классы строк. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» сохранены (проверено в браузере на моке: на 1440 px таблица, ниже 560 px карточки, без горизонтальной прокрутки). | +| Table | Все 23 таблицы описаны колонками TanStack: `ui/DataTable.tsx` строит ReUI `DataGrid` из `columns` и `data`, а то, что DataGrid не принимает (класс строки или ячейки, `aria-sort` у ``, `style`, `colSpan`, `role`, `hidden`), навешивает на нарисованные им элементы после каждого рендера. Определения колонок держат стабильную идентичность (иначе ячейки пересоздавались бы при каждом рендере родителя), встроенная пагинация DataGrid выключена (`manualPagination`). В Blocking › Lists строка-заголовок группы это строка, первая ячейка которой растянута на четыре колонки (`colSpan`, `role="columnheader"`, `scope="colgroup"`), а остальные три скрыты. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» работают поверх той же таблицы (проверено в браузере на моке: 1440 px таблица, 390 px карточки, без горизонтальной прокрутки). | | Toast | свой (фокус возвращается, пауза под курсором, действие с правом); цвета и шрифты из темы ReUI | | Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px | diff --git a/src/screens/admin/Cluster.tsx b/src/screens/admin/Cluster.tsx index a864492..324eaa6 100644 --- a/src/screens/admin/Cluster.tsx +++ b/src/screens/admin/Cluster.tsx @@ -37,7 +37,8 @@ import { type Notice, } from './parts' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction, useSort, type Keys, head } from '../../ui/Table' import { Menu, Separator } from '../../ui/Menu' import { GroupRow } from '../../ui/Form' import { Notifier } from '../../ui/Notifier' @@ -219,51 +220,61 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan ) : ( <> - - - - - - - - - - - - - - - - - - - - - ))} -
Node NameIP AddressURLTypeStateUp SinceLast SeenLast Synced - - } - isEmpty={visibleNodes.length === 0} + {/* This node's own row, washed in `--acc-bg`. The `Self` in its + `State` column stays: the colour finds the row, the word says + which it is. Same treatment as the current session's row. */} + String(n.id)} + rowProps={(n) => ({ className: n.state === 'Self' ? tbl.rowSelf : undefined })} emptyText="No Node Found" - columns={9} - > - {visibleNodes.map((n) => ( - /* This node's own row, washed in `--acc-bg`. The `Self` in its - `State` column stays: the colour finds the row, the word says - which it is. Same treatment as the current session's row. */ -
+ stack + columns={[ + { + id: 'name', + ...head({ field: 'name', sort, onSort: toggle, children: 'Node Name' }), + td: () => ({ className: tbl.lead }), + cell: (n) => ( + <> {n.name} - {/* The node's version under its name, as `(v15.6)`: new in - v15.6 (cluster.js:180-183). */} - {n.version != null &&
(v{n.version})
} -
- {n.ipAddresses.map((ip) => ( + {/* The node's version under its name, as `(v15.6)`: new in + v15.6 (cluster.js:180-183). */} + {n.version != null &&
(v{n.version})
} + + ), + }, + { + id: 'ip', + ...head({ field: 'ip', sort, onSort: toggle, children: 'IP Address' }), + td: () => ({ className: styles.mono }), + cell: (n) => ( + n.ipAddresses.map((ip) => (
{ip}
- ))} -
{n.url} - {n.type === 'Primary' || n.type === 'Secondary' ? ( + )) + ), + }, + { + id: 'url', + ...head({ field: 'url', sort, onSort: toggle, children: 'URL' }), + td: () => ({ className: styles.mono }), + cell: (n) => n.url, + }, + { + id: 'type', + ...head({ field: 'type', sort, onSort: toggle, children: 'Type' }), + cell: (n) => ( + n.type === 'Primary' || n.type === 'Secondary' ? ( {n.type} ) : ( Unknown - )} - - {n.state === 'Self' ? ( + ) + ), + }, + { + id: 'state', + ...head({ field: 'state', sort, onSort: toggle, children: 'State' }), + cell: (n) => ( + n.state === 'Self' ? ( Self ) : n.state === 'Connected' ? ( Connected @@ -271,22 +282,42 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan Unreachable ) : ( Unknown - )} - - - + ) + ), + }, + { + id: 'upSince', + ...head({ field: 'upSince', sort, onSort: toggle, children: 'Up Since' }), + td: () => ({ className: styles.nowrap }), + cell: (n) => (), + }, + { + id: 'lastSeen', + ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }), + td: () => ({ className: styles.nowrap }), + cell: (n) => ( + <> {/* The node itself has no "Last Seen": in its row that column - is always empty (cluster.js:177-193). */} - {n.state !== 'Self' && } - - {n.state === 'Self' && n.type === 'Secondary' && ( + is always empty (cluster.js:177-193). */} + {n.state !== 'Self' && } + + ), + }, + { + id: 'synced', + ...head({ field: 'synced', sort, onSort: toggle, children: 'Last Synced' }), + td: () => ({ className: styles.nowrap }), + cell: (n) => ( + n.state === 'Self' && n.type === 'Secondary' && ( - )} - + ) + ), + }, + { + id: 'c8', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (n) => (
{/* What can be done with a node depends on whether this console is talking to the primary or to a secondary @@ -327,10 +358,10 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan )}
-
+ ), + }, + ]} + />
{`Total Nodes: ${nodes.length}`}
diff --git a/src/screens/admin/Groups.tsx b/src/screens/admin/Groups.tsx index 6f4e9b9..52ba04a 100644 --- a/src/screens/admin/Groups.tsx +++ b/src/screens/admin/Groups.tsx @@ -23,7 +23,8 @@ import { type Notice, } from './parts' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { head, RowAction, useSort, type Keys } from '../../ui/Table' import { Menu } from '../../ui/Menu' import { Notifier } from '../../ui/Notifier' @@ -103,42 +104,36 @@ export function Groups({ tabs, token, onNotice }: Props) { ) : ( <> - - - - - - - - - ))} -
NameDescription - - } - isEmpty={visibleGroups.length === 0} + g.name} emptyText="No Group Found" - columns={3} - > - {visibleGroups.map((g) => ( -
- - - {g.description.split('\n').map((line, i) => ( + ), + }, + { + id: 'description', + ...head({ field: 'description', sort, onSort: toggle, children: 'Description' }), + cell: (g) => + g.description.split('\n').map((line, i) => ( // eslint-disable-next-line react/no-array-index-key
{line}
- ))} -
+ )), + }, + { + id: 'actions', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (g) => (
- setDetail(g.name)} - /> + setDetail(g.name)} /> {(close) => (
-
+ ), + }, + ]} + />
{`Total Groups: ${groups.length}`}
diff --git a/src/screens/admin/Sessions.tsx b/src/screens/admin/Sessions.tsx index 7fcab02..fb0cebf 100644 --- a/src/screens/admin/Sessions.tsx +++ b/src/screens/admin/Sessions.tsx @@ -29,7 +29,8 @@ import { type Notice, } from './parts' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction, useSort, type Keys, head } from '../../ui/Table' import { Menu } from '../../ui/Menu' import { Notifier } from '../../ui/Notifier' @@ -142,28 +143,21 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha ) : ( <> - - - - - - - - - - - - - - - ))} -
UsernameSessionLast SeenRemote AddressUser Agent - - } - isEmpty={visibleSessions.length === 0} + {/* My own session's row, washed in `--acc-bg`. The `(current)` in + its token cell stays: the colour finds the row, the word says + which it is. Same treatment as this node's row in `Cluster`. */} + String(s.partialToken)} + rowProps={(s) => ({ className: s.isCurrentSession ? tbl.rowSelf : undefined })} emptyText="No Session Found" - columns={6} - > - {visibleSessions.map((s) => ( - /* My own session's row, washed in `--acc-bg`. The `(current)` in - its token cell stays: the colour finds the row, the word says - which it is. Same treatment as this node's row in `Cluster`. */ -
+ stack + columns={[ + { + id: 'username', + ...head({ field: 'username', sort, onSort: toggle, children: 'Username' }), + td: () => ({ className: tbl.lead }), + cell: (s) => ( - - - - - {s.lastSeenRemoteAddress} - {s.lastSeenUserAgent} - + ), + }, + { + id: 'session', + ...head({ field: 'session', sort, onSort: toggle, children: 'Session' }), + td: () => ({ className: tbl.lead }), + cell: (s) => (), + }, + { + id: 'lastSeen', + ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }), + td: () => ({ className: styles.nowrap }), + cell: (s) => (), + }, + { + id: 'address', + ...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }), + td: () => ({ className: styles.mono }), + cell: (s) => s.lastSeenRemoteAddress, + }, + { + id: 'agent', + ...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }), + cell: (s) => ({s.lastSeenUserAgent}), + }, + { + id: 'c5', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (s) => (
{/* auth.js:938: upstream's menu entry says "View User Details", not the Users table's "View Details". The frequent action @@ -201,10 +213,10 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha )}
-
+ ), + }, + ]} + />
{`Total Sessions: ${sessions.length}`}
diff --git a/src/screens/admin/UserDetails.tsx b/src/screens/admin/UserDetails.tsx index b336d39..1ab4c0c 100644 --- a/src/screens/admin/UserDetails.tsx +++ b/src/screens/admin/UserDetails.tsx @@ -27,7 +27,8 @@ import { } from './parts' import tbl from '../../ui/Table.module.css' import frm from '../../ui/Form.module.css' -import { Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { useSort, type Keys, head } from '../../ui/Table' import { Notifier } from '../../ui/Notifier' import { Menu } from '../../ui/Menu' @@ -305,30 +306,37 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved,

Active Sessions

- - - - - - - - - - - - - ))} -
SessionLast SeenRemote AddressUser Agent - - } - > - {visibleSessions.map((s) => ( -
- - - - {s.lastSeenRemoteAddress} - {s.lastSeenUserAgent} - + String(s.partialToken)} + columns={[ + { + id: 'session', + ...head({ field: 'session', sort, onSort: toggle, children: 'Session' }), + cell: (s) => (), + }, + { + id: 'lastSeen', + ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }), + td: () => ({ className: styles.nowrap }), + cell: (s) => (), + }, + { + id: 'address', + ...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }), + td: () => ({ className: styles.mono }), + cell: (s) => s.lastSeenRemoteAddress, + }, + { + id: 'agent', + ...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }), + cell: (s) => ({s.lastSeenUserAgent}), + }, + { + id: 'c4', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (s) => (
{/* Inside the menu, as in "Administration > Sessions" and as in upstream, which also puts it in a dropdown @@ -347,10 +355,10 @@ export function UserDetails({ open, username, token, cluster, onClose, onSaved, )}
-
+ ), + }, + ]} + />
{`Total Sessions: ${sessions.length}`}
diff --git a/src/screens/admin/Users.tsx b/src/screens/admin/Users.tsx index 50354c4..d07ce65 100644 --- a/src/screens/admin/Users.tsx +++ b/src/screens/admin/Users.tsx @@ -25,7 +25,8 @@ import { type Notice, } from './parts' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction, useSort, type Keys, head } from '../../ui/Table' import { Menu, Separator } from '../../ui/Menu' import { neverUsed } from '../../api/zones' import { Notifier } from '../../ui/Notifier' @@ -172,27 +173,17 @@ export function Users({ tabs, token, cluster, onNotice }: Props) { ) : ( <> - - - - - - - - - - - - {/* `Type`, `2FA Status` and `Status` are the three columns that - say what a user IS, and they were reading as three different - kinds of thing: two pills and one bare word. The word does not - change: `Remote/SSO` is upstream's, `SSO Managed` in the next - column is its sibling, so it takes the same `info` tone, and - `Remote/LDAP` is the other remote user, so it takes it too. */} - - - - {/* - Upstream formats the date without checking whether it is .NET's - minimum (auth.js:1141), so for a user who has never logged in it - shows "0000-12-31 23:45:16 from 0.0.0.0" (year zero, because it - converts to local time a date older than time zones) and an - address that does not exist. That is not a datum: it is a - sentinel value leaking onto the screen. It says "Never", which is - what it means, just as was done with the DNSSEC column. - */} - - - - - ))} -
UsernameDisplay NameType2FA StatusStatusRecent LoginPrevious Login - - } - isEmpty={visibleUsers.length === 0} + String(u.username)} emptyText="No User Found" - columns={8} - > - {visibleUsers.map((u) => ( -
+ stack + columns={[ + { + id: 'username', + ...head({ field: 'username', sort, onSort: toggle, children: 'Username' }), + td: () => ({ className: tbl.lead }), + cell: (u) => ( - {u.displayName} - {u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? ( + ), + }, + { + id: 'display', + ...head({ field: 'display', sort, onSort: toggle, children: 'Display Name' }), + cell: (u) => u.displayName, + }, + { + id: 'type', + ...head({ field: 'type', sort, onSort: toggle, children: 'Type' }), + cell: (u) => ( + u.type === 'RemoteSSO' || u.type === 'RemoteLDAP' ? ( {userType(u)} ) : ( {userType(u)} - )} - - {u.type === 'RemoteSSO' ? ( + ) + ), + }, + { + id: 'totp', + ...head({ field: 'totp', sort, onSort: toggle, children: '2FA Status' }), + cell: (u) => ( + u.type === 'RemoteSSO' ? ( SSO Managed ) : u.totpEnabled ? ( Enabled ) : ( Disabled - )} - - {u.disabled ? ( + ) + ), + }, + { + id: 'status', + ...head({ field: 'status', sort, onSort: toggle, children: 'Status' }), + cell: (u) => ( + u.disabled ? ( Disabled ) : ( Enabled - )} - {access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress)}{access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress)} + ) + ), + }, + { + id: 'recent', + ...head({ field: 'recent', sort, onSort: toggle, children: 'Recent Login' }), + td: () => ({ className: styles.mono }), + cell: (u) => access(u.recentSessionLoggedOn, u.recentSessionRemoteAddress), + }, + { + id: 'previous', + ...head({ field: 'previous', sort, onSort: toggle, children: 'Previous Login' }), + td: () => ({ className: styles.mono }), + cell: (u) => access(u.previousSessionLoggedOn, u.previousSessionRemoteAddress), + }, + { + id: 'c7', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (u) => (
-
+ ), + }, + ]} + />
{`Total Users: ${users.length}`}
diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx index 897d05b..b4869eb 100644 --- a/src/screens/blocking/BlockLists.tsx +++ b/src/screens/blocking/BlockLists.tsx @@ -5,7 +5,8 @@ import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' import { nextUpdateText } from '../settings/panes/Blocking' import { SectionHeader } from '../../ui/SectionHeader' import { Panel, Body } from '../../ui/Panel' -import { RowAction, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction } from '../../ui/Table' import { Field, Input, Select } from '../../ui/Field' import { Button } from '../../ui/Button' import { PermissionButton } from '../../ui/PermissionButton' @@ -478,128 +479,96 @@ export function BlockLists({ /* The lists of each group, to say how many it holds. */ const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1) - function headingRow(key: string, name: string, count: number, controls: ReactNode) { - return ( - - -
- {name} - {plural(count, 'list', 'lists')} - {controls != null && } - {controls} -
- - - ) + /* What the table draws, one item per row: a group heading (its content spans the + whole row) or a line. The grid draws cells, so a heading is a row whose first cell + takes the width of all four and whose other cells are not drawn. */ + type Entry = ListLine & { kind: 'block' | 'allow' } + type Item = + | { key: string; kind: 'heading'; node: ReactNode } + | { key: string; kind: 'line'; l: Entry; i: number } + + function heading(key: string, name: string, count: number, controls: ReactNode): Item { + return { + key, + kind: 'heading', + node: ( +
+ {name} + {plural(count, 'list', 'lists')} + {controls != null && } + {controls} +
+ ), + } } /* One row per line. A key that follows the line and not its place, so a list that moves or switches keeps its row, and the focus stays on the button pressed. */ const seen = new Map() - const rows: ReactNode[] = [] - if (heads.length > 0 && heads[0] > 0) rows.push(headingRow('none', 'No group', groupSize(-1), null)) + const items: Item[] = [] + if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null)) lines.forEach((l, i) => { if (l.kind === 'comment') { const g = heads.indexOf(i) const name = nameOf(l) - rows.push( - edit != null && edit.group === g ? ( - - - setEditing({ ...edit, value, problem: null })} - onSubmit={submitHeading} - onCancel={cancelEditing} - /> - - - ) : ( - headingRow(`H:${g}`, name, groupSize(g), ( - <> - setEditing({ group: g, value: headingName(l), problem: null })}> - Rename - - ungroup(g)}> - Ungroup - - - )) - ), + items.push( + edit != null && edit.group === g + ? { + key: `H:${g}`, + kind: 'heading', + node: ( + setEditing({ ...edit, value, problem: null })} + onSubmit={submitHeading} + onCancel={cancelEditing} + /> + ), + } + : heading(`H:${g}`, name, groupSize(g), ( + <> + setEditing({ group: g, value: headingName(l), problem: null })}> + Rename + + ungroup(g)}> + Ungroup + + + )), ) return } const id = `${l.kind}:${l.url}` const n = seen.get(id) ?? 0 seen.set(id, n + 1) - const name = l.url == null ? null : listName(l.url, named) - const faded = !l.enabled ? styles.off : undefined - const what = `${l.kind} list ${l.url ?? ''}` - rows.push( - - - {canToggle(l) && ( - - )} - - - {name != null &&
{name}
} -
- {/* The kind under the URL where the Type column is folded away. */} - - - {KIND_LABEL[l.kind]} - - - - - - {KIND_LABEL[l.kind]} - - - -
- setLines((all) => moveLine(all, i, -1))} /> - setLines((all) => moveLine(all, i, 1))} /> - setLines((all) => all.filter((_, j) => j !== i))}> - Remove - -
- - , - ) + items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i }) }) if (edit != null && edit.group === 'new') { - rows.push( - - - setEditing({ ...edit, value, problem: null })} - onSubmit={submitHeading} - onCancel={cancelEditing} - /> - - , - ) + items.push({ + key: 'H:new', + kind: 'heading', + node: ( + setEditing({ ...edit, value, problem: null })} + onSubmit={submitHeading} + onCancel={cancelEditing} + /> + ), + }) } + /* What a line's cells share. */ + const lineCells = (l: Entry) => ({ + name: l.url == null ? null : listName(l.url, named), + faded: !l.enabled ? styles.off : undefined, + what: `${l.kind} list ${l.url ?? ''}`, + }) + return ( <> @@ -778,15 +747,100 @@ export function BlockLists({ ) : saved == null ? ( ) : ( -
EnabledListType} - isEmpty={lines.length === 0 && edit?.group !== 'new'} - emptyText="No lists" - columns={4} + + data={items} + getRowId={(item) => item.key} + rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)} + emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined} className={shared.inPanel} - > - {rows} -
+ columns={[ + { + id: 'enabled', + header: 'Enabled', + td: (item) => + item.kind === 'heading' + ? { colSpan: 4, scope: 'colgroup', role: 'columnheader' } + : undefined, + cell: (item) => { + if (item.kind === 'heading') return item.node + const { l, i } = item + const { what } = lineCells(l) + return ( + canToggle(l) && ( + + ) + ) + }, + }, + { + id: 'list', + header: 'List', + td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: lineCells(item.l).faded }), + cell: (item) => { + if (item.kind === 'heading') return null + const { l } = item + const { name } = lineCells(l) + return ( + <> + {name != null &&
{name}
} +
+ {/* The kind under the URL where the Type column is folded away. */} + + + {KIND_LABEL[l.kind]} + + + ) + }, + }, + { + id: 'kind', + th: { className: styles.kindCell }, + header: 'Type', + td: (item) => + item.kind === 'heading' + ? { hidden: true } + : { className: `${lineCells(item.l).faded ?? ''} ${styles.kindCell}` }, + cell: (item) => + item.kind === 'heading' ? null : ( + + + {KIND_LABEL[item.l.kind]} + + ), + }, + { + id: 'actions', + td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: styles.actions }), + cell: (item) => { + if (item.kind === 'heading') return null + const { l, i } = item + const { what } = lineCells(l) + return ( +
+ setLines((all) => moveLine(all, i, -1))} /> + setLines((all) => moveLine(all, i, 1))} /> + setLines((all) => all.filter((_, j) => j !== i))}> + Remove + +
+ ) + }, + }, + ]} + /> )}
)} - - - - - - - } - isEmpty={state.entries.length === 0} + String(`${e.responseType}:${e.rowNumber}:${e.timestamp}`)} emptyText="No blocked queries." - columns={4} + stack className={shared.inPanel} - > - {state.entries.map((e) => ( - - - - - - - ))} -
TimeClientDomainType
{dateTime(e.timestamp)}{e.clientIpAddress}{e.qtype != null && {e.qtype}}
+ columns={[ + { + id: 'c0', + header: 'Time', + td: () => ({ className: styles.time }), + cell: (e) => dateTime(e.timestamp), + }, + { + id: 'c1', + header: 'Client', + td: () => ({ className: styles.mono }), + cell: (e) => e.clientIpAddress, + }, + { + id: 'c2', + header: 'Domain', + td: () => ({ className: `${styles.name} ${tbl.lead}` }), + cell: (e) => (), + }, + { + id: 'c3', + header: 'Type', + td: () => ({ className: tbl.lead }), + cell: (e) => e.qtype != null && {e.qtype}, + }, + ]} + /> )} diff --git a/src/screens/blocking/Rules.tsx b/src/screens/blocking/Rules.tsx index 0145284..e392b83 100644 --- a/src/screens/blocking/Rules.tsx +++ b/src/screens/blocking/Rules.tsx @@ -3,7 +3,7 @@ import { readRuleExport } from '../../api/blocking' import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists' import { SectionHeader } from '../../ui/SectionHeader' import { Panel, Body } from '../../ui/Panel' -import { Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' import { Pagination } from '../../ui/Pagination' import { Segmented } from '../../ui/Segmented' import { Menu } from '../../ui/Menu' @@ -522,29 +522,32 @@ export function Rules({ ) ) : ( <> - - - - - - - - - ))} -
DomainRule - - } - isEmpty={current.rows.length === 0} + String(`${r.list}:${r.domain}`)} emptyText={query === '' ? 'No rules' : 'No rules match this filter'} - columns={3} className={shared.inPanel} - > - {current.rows.map((r) => ( -
+ columns={[ + { + id: 'c0', + header: 'Domain', + td: () => ({ className: styles.domain }), + cell: (r) => (), + }, + { + id: 'c1', + header: 'Rule', + cell: (r) => ( {LABEL[r.list]} - + ), + }, + { + id: 'c2', + td: () => ({ className: styles.actions }), + cell: (r) => ( Delete -
+ ), + }, + ]} + /> {current.totalPages > 1 && ( } > - - - - - - - - - - ))} -
Domain - Hits - - } - isEmpty={rows.length === 0} + String(r.name)} emptyText={failure ? undefined : 'No domains for this period.'} - columns={4} className={shared.inPanel} - > - {rows.map((r) => ( -
+ columns={[ + { + id: 'c0', + header: 'Domain', + td: () => ({ className: styles.name }), + cell: (r) => (), + }, + { + id: 'c1', + th: { 'aria-label': 'Share' }, + cell: (r) => ( - {r.hits.toLocaleString()} + ), + }, + { + id: 'c2', + th: { className: styles.hitsHead }, + header: 'Hits', + td: () => ({ className: styles.hits }), + cell: (r) => r.hits.toLocaleString(), + }, + { + id: 'c3', + th: { 'aria-label': 'Actions' }, + cell: (r) => ( {(close) => need == null ? ( @@ -116,10 +124,10 @@ export function TopTable({ ) } -
+ ), + }, + ]} + /> setMore(false)} /> ) diff --git a/src/screens/dashboard/TopStats.tsx b/src/screens/dashboard/TopStats.tsx index 93a4d29..4171a6a 100644 --- a/src/screens/dashboard/TopStats.tsx +++ b/src/screens/dashboard/TopStats.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { getTop, type Range, type TopKind, type TopEntry } from '../../api/dashboard' import { Dialog } from '../../ui/Dialog' -import { Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' import { Loading } from '../../ui/Empty' import { Notifier } from '../../ui/Notifier' import tbl from '../../ui/Table.module.css' @@ -104,43 +104,47 @@ export function TopStats({ {loading ? ( ) : ( - String(`${f.name}|${i}`)} + rowProps={(f) => ({ className: f.rateLimited ? styles.limited : undefined })} + emptyText="No Data" className={styles.topTableWrap} tableClass={styles.topTable} - header={ - <> - - - - } - > - {rows.map((f, i) => ( - - } + columns={[ + { + id: 'c0', + header: type == null ? '' : HEADER[type], + cell: (f) => ( + <> - {f.name} - {f.rateLimited ? ' (rate limited)' : ''} - - {isClient && f.domain != null && f.domain !== '' && ( - {f.domain} - )} - - - - - ))} -
{type == null ? '' : HEADER[type]}{type == null ? '' : COUNT[type]} - - } - isEmpty={rows.length === 0} - emptyText="No Data" - columns={3} - footer={ - - {type == null ? '' : `Total ${TITLES[type]}: ${rows.length.toLocaleString()}`} -
+ footer={ + {type == null ? '' : `Total ${TITLES[type]}: ${rows.length.toLocaleString()}`} + {f.hits.toLocaleString()} - {type != null && } -
+ {f.name} + {f.rateLimited ? ' (rate limited)' : ''} + + {isClient && f.domain != null && f.domain !== '' && ( + {f.domain} + )} + + ), + }, + { + id: 'c1', + th: { 'style': { width: 110 } }, + header: type == null ? '' : COUNT[type], + td: () => ({ className: styles.topCount }), + cell: (f) => f.hits.toLocaleString(), + }, + { + id: 'c2', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (f) => type != null && , + }, + ]} + /> )} ) diff --git a/src/screens/dhcp/Leases.tsx b/src/screens/dhcp/Leases.tsx index a2a7e4e..35b81d7 100644 --- a/src/screens/dhcp/Leases.tsx +++ b/src/screens/dhcp/Leases.tsx @@ -17,7 +17,8 @@ import { Failure, Loading } from '../../ui/Empty' import { Tag } from '../../ui/Tag' import tbl from '../../ui/Table.module.css' import styles from './Dhcp.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction, useSort, type Keys, head } from '../../ui/Table' import { Menu } from '../../ui/Menu' import { noticeFromFailure } from '../../lib/notice' import { Notifier } from '../../ui/Notifier' @@ -186,38 +187,62 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t Could not load the list. ) : ( <> - - - - - - - - - - - - - - - - - - ))} -
ScopeMAC AddressIP Address - Host NameLease ObtainedLease Expires - - } - isEmpty={visibleLeases.length === 0} + String(`${l.scope}/${l.clientIdentifier}`)} emptyText="No Lease Found" - columns={8} - > - {visibleLeases.map((l, i) => ( -
{l.scope}{l.hardwareAddress}{l.address} + stack + columns={[ + { + id: 'scope', + ...head({ field: 'scope', sort, onSort: toggle, children: 'Scope' }), + td: () => ({ className: styles.mono }), + cell: (l) => l.scope, + }, + { + id: 'mac', + ...head({ field: 'mac', sort, onSort: toggle, children: 'MAC Address' }), + td: () => ({ className: styles.mono }), + cell: (l) => l.hardwareAddress, + }, + { + id: 'address', + ...head({ field: 'address', sort, onSort: toggle, children: 'IP Address' }), + td: () => ({ className: `${styles.mono} ${tbl.lead}` }), + cell: (l) => l.address, + }, + { + id: 'type', + ...head({ field: 'type', sort, onSort: toggle, name: 'Type' }), + cell: (l) => ( {l.type} - {l.hostName}{minuteStamp(l.leaseObtained)}{minuteStamp(l.leaseExpires)} + ), + }, + { + id: 'host', + ...head({ field: 'host', sort, onSort: toggle, children: 'Host Name' }), + td: () => ({ className: `${styles.mono} ${tbl.lead}` }), + cell: (l) => l.hostName, + }, + { + id: 'obtained', + ...head({ field: 'obtained', sort, onSort: toggle, children: 'Lease Obtained' }), + td: () => ({ className: styles.date }), + cell: (l) => minuteStamp(l.leaseObtained), + }, + { + id: 'expires', + ...head({ field: 'expires', sort, onSort: toggle, children: 'Lease Expires' }), + td: () => ({ className: styles.date }), + cell: (l) => minuteStamp(l.leaseExpires), + }, + { + id: 'c7', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (l, i) => (
{/* dhcp.js:63-64. Which of the two conversions is offered depends on the lease's current type. */} @@ -250,10 +275,10 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t )}
-
+ ), + }, + ]} + />
{/* The footer is the count and nothing else. When there are no rows, diff --git a/src/screens/dhcp/Scopes.tsx b/src/screens/dhcp/Scopes.tsx index 9cf1488..d3fc2d5 100644 --- a/src/screens/dhcp/Scopes.tsx +++ b/src/screens/dhcp/Scopes.tsx @@ -19,7 +19,8 @@ import { Failure, Loading } from '../../ui/Empty' import { Tag } from '../../ui/Tag' import tbl from '../../ui/Table.module.css' import styles from './Dhcp.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { RowAction, useSort, type Keys, head } from '../../ui/Table' import { Menu } from '../../ui/Menu' import { noticeFromFailure } from '../../lib/notice' import { Notifier } from '../../ui/Notifier' @@ -240,26 +241,22 @@ export function Scopes({ Could not load the list. ) : ( <> - - - - - - - - - - - {/* `interfaceAddress` is OMITTED when null; upstream draws an - empty cell (dhcp.js:228). */} - - - - - ))} -
NameScope Range/Subnet MaskNetwork/BroadcastInterfaceStatus - - } - isEmpty={visibleScopes.length === 0} + String(s.name)} emptyText="No Scope Found" - columns={6} - > - {visibleScopes.map((s) => ( -
{s.name} + stack + columns={[ + { + id: 'name', + ...head({ field: 'name', sort, onSort: toggle, children: 'Name' }), + td: () => ({ className: `${styles.name} ${tbl.lead}` }), + cell: (s) => s.name, + }, + { + id: 'range', + ...head({ field: 'range', sort, onSort: toggle, children: 'Scope Range/Subnet Mask' }), + cell: (s) => (
Range
@@ -268,24 +265,40 @@ export function Scopes({
Mask
{s.subnetMask}
-
+ ), + }, + { + id: 'network', + ...head({ field: 'network', sort, onSort: toggle, children: 'Network/Broadcast' }), + cell: (s) => (
Network
{s.networkAddress}
Broadcast
{s.broadcastAddress}
-
{s.interfaceAddress ?? ''} + ), + }, + { + id: 'interface', + ...head({ field: 'interface', sort, onSort: toggle, children: 'Interface' }), + td: () => ({ className: styles.mono }), + cell: (s) => s.interfaceAddress ?? '', + }, + { + id: 'c4', + header: 'Status', + cell: (s) => ( {s.enabled ? 'Enabled' : 'Disabled'} - + ), + }, + { + id: 'c5', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (s) => (
)}
-
+ ), + }, + ]} + />
{/* The footer is the count and nothing else. When there are no rows, diff --git a/src/screens/lists/Records.tsx b/src/screens/lists/Records.tsx index 5ac0f7c..10b0a5b 100644 --- a/src/screens/lists/Records.tsx +++ b/src/screens/lists/Records.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import type { DnsRecord } from '../../api/zonelists' import { rdataEntries, extras, meta, splitTtl, type Entry } from './record' import tbl from '../../ui/Table.module.css' -import { Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' import styles from './Lists.module.css' /* @@ -78,73 +78,9 @@ function Inline({ entries, className, bare = false }: { ) } -function Row({ r, withDnssec, node }: { r: DnsRecord; withDnssec: boolean; node: string }) { - const [signatures, setSignatures] = useState(false) - const [glue, setGlue] = useState(false) - - const ttl = splitTtl(r) - const extra = extras(r) - const name = r.nameIdn ?? r.name - // The name always matches the open node; it is only said when it does NOT. - const differentName = r.name !== node && name !== node - - return ( - - - {r.type} - {differentName && {name === '' ? '' : name}} - - - {ttl.value} {ttl.human && ({ttl.human})} - - - - {/* The second line: where the answer came from and when it was used, and - then whatever else the record carries (a name server's health, a field - the server adds tomorrow). It was a third and fourth block of - label-over-value rows; it is the grey line now, the same weight as the - metadata beside it, because none of it is the record's data. */} -
- {meta(r).join(' · ')} - {extra.length > 0 && } -
- {signatures && r.dnssecRecords && ( -
{r.dnssecRecords.join('\n')}
- )} - {glue && r.glueRecords &&
{r.glueRecords.join('\n')}
} - - {withDnssec && ( - - - {r.dnssecStatus ?? '—'} - - - )} - -
- {r.glueRecords && ( - - )} - {r.dnssecRecords && ( - - )} -
- - - ) -} +/* What each record has open: its signatures (RRSIG) and its glue. The grid draws a row + as separate cells, so the state the cells share is kept here, by the record. */ +type Open = Record export function ResourceRecords({ records, @@ -156,24 +92,110 @@ export function ResourceRecords({ withDnssec: boolean node: string }) { + const [open, setOpen] = useState({}) + const rowKey = (r: DnsRecord, i: number) => `${r.name}|${r.type}|${i}` + const toggle = (key: string, what: 'signatures' | 'glue') => + setOpen((o) => ({ ...o, [key]: { ...o[key], [what]: !o[key]?.[what] } })) + return ( - - - - - {withDnssec && } - - - } - > - {records.map((r, i) => ( - - ))} -
TypeTTLDataDNSSEC - -
+ data={records} + getRowId={rowKey} + columns={[ + { + id: 'type', + th: { style: { width: 110 } }, + header: 'Type', + td: () => ({ className: tbl.lead }), + cell: (r) => { + const name = r.nameIdn ?? r.name + // The name always matches the open node; it is only said when it does NOT. + const differentName = r.name !== node && name !== node + return ( + <> + {r.type} + {differentName && {name === '' ? '' : name}} + + ) + }, + }, + { + id: 'ttl', + th: { style: { width: 120 } }, + header: 'TTL', + td: () => ({ className: styles.ttl }), + cell: (r) => { + const ttl = splitTtl(r) + return ( + <> + {ttl.value} {ttl.human && ({ttl.human})} + + ) + }, + }, + { + id: 'data', + header: 'Data', + cell: (r, i) => { + const extra = extras(r) + const here = open[rowKey(r, i)] + return ( + <> + + {/* The second line: where the answer came from and when it was used, and + then whatever else the record carries (a name server's health, a field + the server adds tomorrow). It was a third and fourth block of + label-over-value rows; it is the grey line now, the same weight as the + metadata beside it, because none of it is the record's data. */} +
+ {meta(r).join(' · ')} + {extra.length > 0 && } +
+ {here?.signatures && r.dnssecRecords && ( +
{r.dnssecRecords.join('\n')}
+ )} + {here?.glue && r.glueRecords &&
{r.glueRecords.join('\n')}
} + + ) + }, + }, + ...(withDnssec + ? [ + { + id: 'dnssec', + th: { style: { width: 100 } }, + header: 'DNSSEC', + cell: (r: DnsRecord) => ( + {r.dnssecStatus ?? '—'} + ), + }, + ] + : []), + { + id: 'actions', + th: { style: { width: 120 } }, + header: , + td: () => ({ className: tbl.actionsCell }), + cell: (r, i) => { + const key = rowKey(r, i) + return ( +
+ {r.glueRecords && ( + + )} + {r.dnssecRecords && ( + + )} +
+ ) + }, + }, + ]} + /> ) } diff --git a/src/screens/logs/QueryLogs.tsx b/src/screens/logs/QueryLogs.tsx index 81e6f66..343292f 100644 --- a/src/screens/logs/QueryLogs.tsx +++ b/src/screens/logs/QueryLogs.tsx @@ -21,7 +21,7 @@ import { toIso, dateTime } from './dates' import { Loading } from '../../ui/Empty' import { Check } from '../../ui/Check' import { Tag } from '../../ui/Tag' -import { Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' import styles from './Logs.module.css' import { pageWindow } from '../../lib/pagination' import { Pagination } from '../../ui/Pagination' @@ -927,53 +927,82 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) { {/* Always all seven, whether or not they appear on this page: see `LEGEND_ROWS`. */} - String(e.rowNumber)} + rowProps={(e) => ({ className: rowClass(e) })} stack tableClass={styles.queryTable} - header={ - <> - - - - - - - - - - - - - - - - - - {/* logs.js:518. The root is written with a dot. */} - - - - - - - ))} -
#TimestampClient IP AddressProtocolResponse TypeRCODEDomainTypeClassAnswer - - } - > - {page.entries.map((e) => ( -
{e.rowNumber} - - - - {e.protocol} + columns={[ + { + id: 'c0', + header: '#', + td: () => ({ className: styles.mono }), + cell: (e) => e.rowNumber, + }, + { + id: 'c1', + header: 'Timestamp', + td: () => ({ className: `${styles.mono} ${styles.stamp}` }), + cell: (e) => (), + }, + { + id: 'c2', + header: 'Client IP Address', + td: () => ({ className: styles.mono }), + cell: (e) => (), + }, + { + id: 'c3', + header: 'Protocol', + cell: (e) => e.protocol, + }, + { + id: 'c4', + header: 'Response Type', + cell: (e) => ( + <> {/* The row's colour, in this cell: see `.tone` in Logs.module.css. */} - {e.responseType} - {e.responseRtt != null && ( -
({e.responseRtt.toFixed(2)} ms)
- )} -
{e.rcode} - - {e.qtype ?? ''}{e.qclass ?? ''} - - + {e.responseType} + {e.responseRtt != null && ( +
({e.responseRtt.toFixed(2)} ms)
+ )} + + ), + }, + { + id: 'c5', + header: 'RCODE', + cell: (e) => e.rcode, + }, + { + id: 'c6', + header: 'Domain', + td: () => ({ className: `${styles.mono} ${tbl.lead}` }), + cell: (e) => (), + }, + { + id: 'c7', + header: 'Type', + td: () => ({ className: tbl.lead }), + cell: (e) => e.qtype ?? '', + }, + { + id: 'c8', + header: 'Class', + cell: (e) => e.qclass ?? '', + }, + { + id: 'c9', + header: 'Answer', + td: () => ({ className: `${styles.mono} ${styles.answer}` }), + cell: (e) => (), + }, + { + id: 'c10', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (e) => ( {(close) => { const verb = verbFor(e.responseType) @@ -1008,10 +1037,10 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) { ) }} -
+ ), + }, + ]} + />
{statusText(page)} diff --git a/src/screens/modals/MyProfile.tsx b/src/screens/modals/MyProfile.tsx index c465fdc..4cdbc3a 100644 --- a/src/screens/modals/MyProfile.tsx +++ b/src/screens/modals/MyProfile.tsx @@ -8,7 +8,8 @@ import { AgentCell, SessionCell, LastSeenCell } from '../../ui/SessionCells' import { deleteSession, type SessionRow } from '../../api/user' import styles from './MyProfile.module.css' import tbl from '../../ui/Table.module.css' -import { Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { useSort, type Keys, head } from '../../ui/Table' import { fromNow, dateTime } from '../../lib/dates' import { noticeFromFailure, type Notice } from '../../lib/notice' import { Notifier } from '../../ui/Notifier' @@ -212,55 +213,54 @@ export function MyProfile({ module had its own with a fourth cell density and without the panel that wraps it, so the same sessions table looked two different ways depending on whether it opened from "My Profile" or "User Details". */} - Group - } - isEmpty={groups.rows.length === 0} + ({ name }))} + getRowId={(g) => g.name} emptyText="No Group Found" - columns={1} - > - {groups.rows.map((g) => ( - - - - ))} -
{g}
+ className={styles.narrow} + columns={[ + { + id: 'group', + ...head({ field: 'group', sort: groups.sort, onSort: groups.toggle, children: 'Group' }), + cell: (g) => g.name, + }, + ]} + />
{`Total Groups: ${profile?.memberOfGroups?.length ?? 0}`}
Active Sessions
- - - - - {/* Upstream has it (`index.html`, the `tbodyMyProfileActiveSessions` - table: Session · Last Seen · Remote Address · User Agent) and here - it had been lost: it was the only one of the console's three - sessions tables without it, and it is the one that says WHERE each - session is open from. */} - - - - - - - - - ))} -
SessionLast SeenRemote AddressUser Agent - - } - > - {visibleSessions.map((row) => ( -
- - - - {row.lastSeenRemoteAddress} - {row.lastSeenUserAgent} - + String(row.partialToken)} + columns={[ + { + id: 'type', + ...head({ field: 'type', sort, onSort: toggle, children: 'Session' }), + cell: (row) => (), + }, + { + id: 'lastSeen', + ...head({ field: 'lastSeen', sort, onSort: toggle, children: 'Last Seen' }), + cell: (row) => (), + }, + { + id: 'address', + ...head({ field: 'address', sort, onSort: toggle, children: 'Remote Address' }), + td: () => ({ className: styles.mono }), + cell: (row) => row.lastSeenRemoteAddress, + }, + { + id: 'agent', + ...head({ field: 'agent', sort, onSort: toggle, children: 'User Agent' }), + cell: (row) => ({row.lastSeenUserAgent}), + }, + { + id: 'c4', + th: { className: tbl.actionsCell }, + td: () => ({ className: tbl.actionsCell }), + cell: (row) => (
{/* In a dropdown, as in the other two sessions tables and as in upstream (`auth.js`, `deleteMySession`). */} @@ -276,10 +276,10 @@ export function MyProfile({ )}
-
+ ), + }, + ]} + />
Total Sessions: {profile?.sessions?.length ?? 0}
diff --git a/src/screens/zones/DataCell.tsx b/src/screens/zones/DataCell.tsx index 873bc63..f658733 100644 --- a/src/screens/zones/DataCell.tsx +++ b/src/screens/zones/DataCell.tsx @@ -2,7 +2,7 @@ import { Tag } from '../../ui/Tag' import { useState } from 'react' import type { ResourceRecord } from '../../api/records' import { recordCells, recordFooter, type Cell } from './record-view' -import { Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' import styles from './Zones.module.css' /* @@ -61,25 +61,16 @@ function CellBlock({ cell }: { cell: Cell }) { case 'table': return ( - - {cell.headers.map((c) => ( - - ))} - - } - > - {cell.rows.map((row, i) => ( - - {row.map((v, j) => ( - - ))} - - ))} -
{c}
- {v} -
+ + data={cell.rows.map((cells, i) => ({ id: String(i), cells }))} + getRowId={(r) => r.id} + columns={cell.headers.map((title, j) => ({ + id: String(j), + header: title, + td: () => ({ className: styles.mono }), + cell: (r) => r.cells[j], + }))} + /> ) case 'pairs': diff --git a/src/screens/zones/ZoneList.tsx b/src/screens/zones/ZoneList.tsx index d2bf2ac..04cd4f7 100644 --- a/src/screens/zones/ZoneList.tsx +++ b/src/screens/zones/ZoneList.tsx @@ -26,7 +26,8 @@ import { Menu, Separator } from '../../ui/Menu' import { minuteStamp as date } from '../../lib/dates' import { statusText, pageWindow } from '../../lib/pagination' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { head, RowAction, useSort, type Keys } from '../../ui/Table' import styles from './Zones.module.css' import type { Notice, Confirmation } from './types' import { Pagination } from '../../ui/Pagination' @@ -367,6 +368,31 @@ export function ZoneList({ // The last page is asked for with -1: the server works it out itself. const pagination = + /* What a row's cells are made from. The grid draws each cell on its own, so each one + asks for the row's props. */ + const rowProps = (z: Zone, i: number): RowProps => ({ + zone: z, + index: firstRow + i, + checked: checkedOnes.includes(z.name), + stale, + busy, + canModify, + canDelete, + onCheck: (v) => setChecked((m) => (v ? [...m, z.name] : m.filter((n) => n !== z.name))), + onOpen, + onEditZoneFile, + onEnable: enable, + onDisable: disable, + onDelete: remove, + onResync: resync, + onImport, + onExport: runExport, + onConvert, + onClone, + onPermissions, + onOptions, + }) + return ( <>
- ` and the second stays on the wrapper, which has no border. Measured: with `className` the `--dan` border did not appear. */ tableClass={stale ? styles.stale : undefined} - header={ - <> - {/* The checkbox is 18 px; it is pressed from anywhere in its cell. */} - - - - - - - - - - {/* The actions column reserved 230 px with three labels inside; - with icons 120 is enough and the 110 left over go to the data, - which is what the table is about. */} - - - - ) : ( - visibleZones.map((z, i) => ( - - setChecked((m) => (v ? [...m, z.name] : m.filter((n) => n !== z.name))) - } - onOpen={onOpen} - onEditZoneFile={onEditZoneFile} - onEnable={enable} - onDisable={disable} - onDelete={remove} - onResync={resync} - onImport={onImport} - onExport={runExport} - onConvert={onConvert} - onClone={onClone} - onPermissions={onPermissions} - onOptions={onOptions} - /> - )) - )} -
+ data={visibleZones} + getRowId={(z) => z.name} + emptyText="No Zone Found" + columns={[ + { + id: 'check', + /* The checkbox is 18 px; it is pressed from anywhere in its cell. */ + th: { style: { width: 38 }, className: tbl.checkCell }, + header: ( - #ZoneTypeDNSSECStatus - Serial - Expiry - Last Modified - - - } - > - {visibleZones.length === 0 ? ( -
- No Zone Found -
+ ), + td: () => ({ className: tbl.checkCell }), + cell: (z, i) => zoneCells(rowProps(z, i)).check, + }, + { + id: 'num', + th: { style: { width: 34 } }, + header: '#', + td: () => ({ className: `${styles.num2} ${tbl.number}` }), + cell: (z, i) => zoneCells(rowProps(z, i)).num, + }, + { + id: 'zone', + ...head({ field: 'zone', sort, onSort: toggle, children: 'Zone' }), + td: () => ({ className: `${tbl.stacked} ${tbl.lead} ${styles.zoneCell}` }), + cell: (z, i) => zoneCells(rowProps(z, i)).zone, + }, + { + id: 'type', + ...head({ field: 'type', sort, onSort: toggle, style: { width: 120 }, children: 'Type' }), + td: () => ({ className: tbl.meta }), + cell: (z, i) => zoneCells(rowProps(z, i)).type, + }, + { + id: 'dnssec', + ...head({ field: 'dnssec', sort, onSort: toggle, style: { width: 90 }, children: 'DNSSEC' }), + cell: (z, i) => zoneCells(rowProps(z, i)).dnssec, + }, + { + id: 'status', + ...head({ field: 'status', sort, onSort: toggle, style: { width: 120 }, children: 'Status' }), + cell: (z, i) => zoneCells(rowProps(z, i)).status, + }, + { + id: 'serial', + ...head({ field: 'serial', sort, onSort: toggle, style: { width: 110, textAlign: 'right' }, children: 'Serial' }), + td: () => ({ className: styles.mono }), + cell: (z, i) => zoneCells(rowProps(z, i)).serial, + }, + { + id: 'expiry', + ...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }), + td: () => ({ className: styles.mono }), + cell: (z, i) => zoneCells(rowProps(z, i)).expiry, + }, + { + id: 'modified', + ...head({ field: 'modified', sort, onSort: toggle, style: { width: 150 }, children: 'Last Modified' }), + td: () => ({ className: `${styles.mono} ${tbl.meta}` }), + cell: (z, i) => zoneCells(rowProps(z, i)).modified, + }, + /* The actions column reserved 230 px with three labels inside; with icons + 120 is enough and the 110 left over go to the data, which is what the + table is about. */ + { + id: 'actions', + th: { style: { width: 120 } }, + td: () => ({ className: tbl.actionsCell }), + cell: (z, i) => zoneCells(rowProps(z, i)).actions, + }, + ]} + />
{state} @@ -623,7 +655,7 @@ const KEYS: Keys = { modified: (z) => date(z.lastModified), } -function ZoneRow(p: RowProps) { +function zoneCells(p: RowProps) { const { zone: z } = p const name = z.name === '' ? '.' : z.name const state = zoneState(z) @@ -650,152 +682,152 @@ function ZoneRow(p: RowProps) { const catalogLabel = z.catalog != null ? { text: z.catalog, tone: 'neutral' as TagTone } : null - return ( - - - - - {p.index} - {/* The monospacing goes on the CELL and the button inherits it: the - shared class says `font-family: inherit` precisely for this, and putting - it on the button depended on which module was emitted last. */} - + return { + check: ( + + ), + num: p.index, + /* The monospacing goes on the CELL and the button inherits it: the + shared class says `font-family: inherit` precisely for this, and putting + it on the button depended on which module was emitted last. */ + zone: ( + <> - {catalogLabel && ( -
- {catalogLabel.text} -
- )} - - {/* The type goes as text and not as a capsule: with Type, DNSSEC and - Status all in capsules, three consecutive columns of pills read as a - single smear and none of the three stood out. */} - {typeLabel(z.type)} - + type="button" + className={`${styles.zoneLink} ${tbl.entity}`} + onClick={() => p.onOpen(name)} + > + + + {catalogLabel && ( +
+ {catalogLabel.text} +
+ )} + + ), + /* The type goes as text and not as a capsule: with Type, DNSSEC and + Status all in capsules, three consecutive columns of pills read as a + single smear and none of the three stood out. */ + type: typeLabel(z.type), + dnssec: ( + <> {/* - Upstream leaves the cell EMPTY when the zone is not signed - (zone.js:721-731), and a blank cell does not say "unsigned": it says "I - do not know" or "it has not loaded". With 240 zones paginated ten at a - time, "which ones do I still have to sign" could only be answered from - the API. + Upstream leaves the cell EMPTY when the zone is not signed + (zone.js:721-731), and a blank cell does not say "unsigned": it says "I + do not know" or "it has not loaded". With 240 zones paginated ten at a + time, "which ones do I still have to sign" could only be answered from + the API. - The three states are said with three different texts, not with the same - text in two colours: without private keys the zone is signed but this - server cannot re-sign it, which is not the same as being signed. - */} - {!signed ? ( - Unsigned - ) : z.hasDnssecPrivateKeys ? ( - Signed - ) : ( - Signed, no keys - )} - - - {state} - - {z.soaSerial ?? ' '} - {date(z.expiry)} - {date(z.lastModified)} - -
- p.onOptions(name)} - /> - {/* The same icon for both states: which one applies is said by the - "Status" column, three columns to the left. */} - (z.disabled ? p.onEnable(z) : p.onDisable(z))} - /> - - {(close) => ( - <> - {/* v15.5 splits the first entry by type (zone.js:829-839 in - v15.5.1): a zone whose records you can edit gets "Edit - Zone" and "Edit Zone File"; any other gets "View Zone", - which opens the same screen. */} - {EDITABLE.includes(z.type) ? ( - <> - - - - ) : ( - - )} - {RESYNC.includes(z.type) && ( - - )} - {IMPORT.includes(z.type) && ( - - )} - {EXPORT.includes(z.type) && ( - - )} - {CONVERT.includes(z.type) && ( - - )} - {CLONE.includes(z.type) && ( - - )} - - {/* - Delete goes back into the menu, and this time with a reason. It - was in the row so it would cost one click just as on the zone's - own screen, but a detail screen is not a row: there you act on - ONE object you are looking at, and here on one of two hundred and - forty, with "Disable" right next to it and with no undo anywhere - in this console. + The three states are said with three different texts, not with the same + text in two colours: without private keys the zone is signed but this + server cannot re-sign it, which is not the same as being signed. */} - - + + + ) : ( + - - )} - -
- - - ) + )} + {RESYNC.includes(z.type) && ( + + )} + {IMPORT.includes(z.type) && ( + + )} + {EXPORT.includes(z.type) && ( + + )} + {CONVERT.includes(z.type) && ( + + )} + {CLONE.includes(z.type) && ( + + )} + + {/* + Delete goes back into the menu, and this time with a reason. It + was in the row so it would cost one click just as on the zone's + own screen, but a detail screen is not a row: there you act on + ONE object you are looking at, and here on one of two hundred and + forty, with "Disable" right next to it and with no undo anywhere + in this console. + */} + + + + )} + +
+ ), + } } diff --git a/src/screens/zones/ZoneRecords.tsx b/src/screens/zones/ZoneRecords.tsx index 12f5038..a50dcbd 100644 --- a/src/screens/zones/ZoneRecords.tsx +++ b/src/screens/zones/ZoneRecords.tsx @@ -34,7 +34,8 @@ import { SectionHeader } from '../../ui/SectionHeader' import { Empty, Loading } from '../../ui/Empty' import { Chip, Tag } from '../../ui/Tag' import tbl from '../../ui/Table.module.css' -import { RowAction, Th, useSort, type Keys, Table } from '../../ui/Table' +import { DataTable } from '../../ui/DataTable' +import { head as headCell, RowAction, useSort, type Keys } from '../../ui/Table' import styles from './Zones.module.css' import type { Notice, Confirmation } from './types' import { Pagination } from '../../ui/Pagination' @@ -560,85 +561,97 @@ export function ZoneRecords(p: ZoneRecordsProps) { {pagination}
- - - - - - - {/* The actions column reserved 230 px with three labels inside; - with icons 120 is enough and the 110 left over go to the data, - which is what the table is about. */} - - - - ) : ( - onPage.map((r, i) => { - const actions = rowActions(zoneInfo.type, r.type) - return ( - - - - - - - - - ) - }) - )} -
#NameTypeTTLData - - } - > - {onPage.length === 0 ? ( -
- No Record Found -
{offset + i + 1} - {r.type} - {r.disabled && ( -
- Disabled -
- )} -
- {r.ttl} ({r.ttlString}) - - - - {!actions.hidden && ( -
- p.onEditRecord(zoneInfo, r, records)} - /> - setRecordState(r, !r.disabled)} - /> - {/* Delete, inside the menu: the same rule as in the zone - list. */} - - {(close) => ( - - )} - -
- )} -
+ data={onPage} + getRowId={(r, i) => `${r.name}|${r.type}|${offset + i}`} + emptyText="No Record Found" + columns={[ + { + id: 'num', + th: { style: { width: 34 } }, + header: '#', + td: () => ({ className: styles.num2 }), + cell: (_r, i) => offset + i + 1, + }, + { + id: 'name', + ...headCell({ field: 'name', sort, onSort: toggle, style: { width: 110 }, children: 'Name' }), + td: () => ({ className: `${styles.mono} ${tbl.lead}` }), + cell: (r) => , + }, + { + id: 'type', + ...headCell({ field: 'type', sort, onSort: toggle, style: { width: 80 }, children: 'Type' }), + td: () => ({ className: tbl.lead }), + cell: (r) => ( + <> + {r.type} + {r.disabled && ( +
+ Disabled +
+ )} + + ), + }, + { + id: 'ttl', + ...headCell({ field: 'ttl', sort, onSort: toggle, style: { width: 110 }, children: 'TTL' }), + td: () => ({ className: styles.ttl }), + cell: (r) => ( + <> + {r.ttl} ({r.ttlString}) + + ), + }, + { + id: 'data', + ...headCell({ field: 'data', sort, onSort: toggle, children: 'Data' }), + td: () => ({ className: styles.dataCell }), + cell: (r) => , + }, + /* The actions column reserved 230 px with three labels inside; with icons + 120 is enough and the 110 left over go to the data, which is what the + table is about. */ + { + id: 'actions', + th: { style: { width: 120 } }, + td: () => ({ className: tbl.actionsCell }), + cell: (r) => { + const actions = rowActions(zoneInfo.type, r.type) + return actions.hidden ? null : ( +
+ p.onEditRecord(zoneInfo, r, records)} + /> + setRecordState(r, !r.disabled)} + /> + {/* Delete, inside the menu: the same rule as in the zone list. */} + + {(close) => ( + + )} + +
+ ) + }, + }, + ]} + />
{text} diff --git a/src/screens/zones/modals/DnssecProperties.tsx b/src/screens/zones/modals/DnssecProperties.tsx index 2edca63..faf9e70 100644 --- a/src/screens/zones/modals/DnssecProperties.tsx +++ b/src/screens/zones/modals/DnssecProperties.tsx @@ -43,7 +43,8 @@ import styles from '../Zones.module.css' import { External } from '../../../ui/External' import { RFC_NSEC3_ITERATIONS, RFC_NSEC3_SALT } from '../references' import frm from '../../../ui/Form.module.css' -import { Th, useSort, type Keys, Table } from '../../../ui/Table' +import { DataTable } from '../../../ui/DataTable' +import { head, useSort, type Keys } from '../../../ui/Table' import { noticeFromFailure } from '../../../lib/notice' import { Notifier } from '../../../ui/Notifier' @@ -335,46 +336,111 @@ export function DnssecProperties({ Loading DNSSEC properties… ) : (
- - - - - - - - - - - ) : ( - visibleKeys.map((k) => ( - setRollovers((r) => ({ ...r, [k.keyTag]: v }))} - onSaveRollover={() => saveRollover(k)} - onDelete={() => deleteKey(k)} - onActivate={() => activate(k)} - onRolloverNow={() => rollover(k)} - onRetire={() => retire(k)} - /> - )) - )} -
Key TagKey TypeAlgorithmState - State Changed - - Rollover (days) - - - } - > - {visibleKeys.length === 0 ? ( -
- No Key Found -
+ String(k.keyTag)} + emptyText="No Key Found" + columns={[ + { + id: 'keyTag', + ...head({ field: 'keyTag', sort, onSort: toggle, style: { width: 80 }, children: 'Key Tag' }), + td: () => ({ className: styles.mono }), + cell: (k) => k.keyTag, + }, + { + id: 'keyType', + ...head({ field: 'keyType', sort, onSort: toggle, style: { width: 130 }, children: 'Key Type' }), + cell: (k) => k.keyType, + }, + { + id: 'algorithm', + ...head({ field: 'algorithm', sort, onSort: toggle, style: { width: 150 }, children: 'Algorithm' }), + cell: (k) => ( + <> + {k.algorithm} ({k.algorithmNumber}) + + ), + }, + { + id: 'state', + ...head({ field: 'state', sort, onSort: toggle, style: { width: 110 }, children: 'State' }), + cell: (k) => ( + <> + {k.state} + {k.state === 'Active' && k.isRetiring ? ' (retiring)' : ''} + + ), + }, + { + id: 'changed', + ...head({ field: 'changed', sort, onSort: toggle, style: { width: 150 }, children: 'State Changed' }), + td: () => ({ className: styles.mono, style: { fontSize: 11.5 } }), + cell: (k) => ( + <> + {shortDate(k.stateChangedOn)} + {k.stateReadyBy != null &&
(ready by: {shortDate(k.stateReadyBy)})
} + {k.stateReadyBy == null && k.stateActiveBy != null && ( +
(active by: {shortDate(k.stateActiveBy)})
+ )} + + ), + }, + { + id: 'rollover', + ...head({ field: 'rollover', sort, onSort: toggle, style: { width: 150 }, children: 'Rollover (days)' }), + cell: (k) => + keyActions(k).automaticRollover ? ( +
+ setRollovers((r) => ({ ...r, [k.keyTag]: e.target.value }))} + /> + +
+ ) : ( + '—' + ), + }, + { + id: 'actions', + th: { style: { width: 190 } }, + td: () => ({ className: tbl.actionsCell }), + cell: (k) => { + const a = keyActions(k) + return ( +
+ {a.remove && ( + + )} + {a.activate && ( + + )} + {a.rollover && ( + + )} + {a.retire && ( + + )} +
+ ) + }, + }, + ]} + /> {notes.map((n) => ( @@ -734,90 +800,3 @@ export function stateNotes(keys: PrivateKey[]): string[] { return notes } -function KeyRow({ - privateKey: k, - busy, - rollover, - onRollover, - onSaveRollover, - onDelete, - onActivate, - onRolloverNow, - onRetire, -}: { - privateKey: PrivateKey - busy: boolean - rollover: string - onRollover: (v: string) => void - onSaveRollover: () => void - onDelete: () => void - onActivate: () => void - onRolloverNow: () => void - onRetire: () => void -}) { - const a = keyActions(k) - - return ( - - {k.keyTag} - {k.keyType} - - {k.algorithm} ({k.algorithmNumber}) - - - {k.state} - {k.state === 'Active' && k.isRetiring ? ' (retiring)' : ''} - - - {shortDate(k.stateChangedOn)} - {k.stateReadyBy != null &&
(ready by: {shortDate(k.stateReadyBy)})
} - {k.stateReadyBy == null && k.stateActiveBy != null && ( -
(active by: {shortDate(k.stateActiveBy)})
- )} - - - {a.automaticRollover ? ( -
- onRollover(e.target.value)} - /> - -
- ) : ( - '—' - )} - - -
- {a.remove && ( - - )} - {a.activate && ( - - )} - {a.rollover && ( - - )} - {a.retire && ( - - )} -
- - - ) -} diff --git a/src/screens/zones/modals/ViewDs.tsx b/src/screens/zones/modals/ViewDs.tsx index b38fdae..73c9cb9 100644 --- a/src/screens/zones/modals/ViewDs.tsx +++ b/src/screens/zones/modals/ViewDs.tsx @@ -4,7 +4,7 @@ import { Dialog } from '../../../ui/Dialog' import { Empty, Loading } from '../../../ui/Empty' import { minuteStamp as shortDate } from '../../../lib/dates' import type { Notice } from '../types' -import { Table } from '../../../ui/Table' +import { DataTable } from '../../../ui/DataTable' import styles from '../Zones.module.css' import { Notifier } from '../../../ui/Notifier' @@ -123,25 +123,28 @@ export function ViewDs({ - - - - - } - > - {ds.digests.map((d) => ( - - - - - ))} -
Digest TypeDigest
+ String(d.digestType)} + columns={[ + { + id: 'c0', + th: { 'style': { width: 140 } }, + header: 'Digest Type', + td: () => ({ className: styles.mono }), + cell: (d) => ( + <> {d.digestType} ({d.digestTypeNumber}) - - -
+ + ), + }, + { + id: 'c1', + header: 'Digest', + cell: (d) => (), + }, + ]} + />
)) )} diff --git a/src/screens/zones/modals/ZonePermissions.tsx b/src/screens/zones/modals/ZonePermissions.tsx index 221e990..3ac9430 100644 --- a/src/screens/zones/modals/ZonePermissions.tsx +++ b/src/screens/zones/modals/ZonePermissions.tsx @@ -5,7 +5,7 @@ import { Dialog } from '../../../ui/Dialog' import { Field, Select } from '../../../ui/Field' import { Loading } from '../../../ui/Empty' import type { Notice } from '../types' -import { Table } from '../../../ui/Table' +import { DataTable } from '../../../ui/DataTable' import styles from '../Zones.module.css' import { noticeFromFailure } from '../../../lib/notice' import { Notifier } from '../../../ui/Notifier' @@ -181,42 +181,36 @@ function PermissionsTable({ {rows.length === 0 ? (
No permissions assigned.
) : ( - - - - - - - - {(['canView', 'canModify', 'canDelete'] as const).map((key) => ( - - ))} - - - ))} -
NameViewModifyDelete - - } - > - {rows.map((f, i) => ( -
{f.name} - change(i, key, e.target.checked)} - /> - - -
+ ), + }, + ]} + /> )} diff --git a/src/ui/DataTable.tsx b/src/ui/DataTable.tsx new file mode 100644 index 0000000..a48240e --- /dev/null +++ b/src/ui/DataTable.tsx @@ -0,0 +1,165 @@ +import { createContext, useContext, useLayoutEffect, useMemo, useRef, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react' +import { useTable } from '@tanstack/react-table' +import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { useStacking } from './Table' +import styles from './Table.module.css' + +/* +The console's table on ReUI's DataGrid, with its columns as TanStack columns and its +rows as data. + + u.username} + columns={[ + { id: 'name', ...head({ field: 'name', sort, onSort: toggle }, 'Username'), cell: (u) => u.username }, + { id: 'actions', th: { className: tbl.actionsCell }, cell: (u) => , td: () => ({ className: tbl.actionsCell }) }, + ]} + emptyText="No User Found" + /> + +What the grid does not take, the table asks of the DOM afterwards: the grid draws every +``, `` and `` itself and has no door for a class on one row, an `aria-sort` +on one header or a `colSpan` on one cell. `th`, `td` and `rowProps` carry those, and +`apply` puts them on the elements the grid drew, after every render, as `Table` does +for the labels of the stacked rows. Sorting stays the screen's (`useSort`): the data +arrives already in order, so the grid sorts nothing. +*/ + +export interface Col { + id: string + /** What goes inside the ``: normally the result of `head(…)`. */ + header?: ReactNode + /** What the `` itself carries: `aria-sort`, a class. */ + th?: ThHTMLAttributes + cell: (row: T, index: number) => ReactNode + /** What a row's `` carries: a class. */ + td?: (row: T, index: number) => TdHTMLAttributes | undefined +} + +const ColumnsContext = createContext[]>([]) + +function HeaderCell({ column }: { column: { id: string } }) { + const columns = useContext(ColumnsContext) + return <>{columns.find((c) => c.id === column.id)?.header ?? null} +} + +function BodyCell({ row, column }: { row: { original: object; index: number }; column: { id: string } }) { + const columns = useContext(ColumnsContext) + return <>{columns.find((c) => c.id === column.id)?.cell(row.original, row.index) ?? null} +} + +type Attrs = Record + +/* React writes a number as pixels, except for the properties that take none. */ +const UNITLESS = new Set(['opacity', 'flex', 'flexGrow', 'flexShrink', 'order', 'zIndex', 'lineHeight', 'fontWeight', 'zoom']) +const cssText = (style: Record) => + Object.entries(style) + .filter(([, v]) => v != null && v !== false) + .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}: ${typeof v === 'number' && v !== 0 && !UNITLESS.has(k) ? `${v}px` : v}`) + .join('; ') + +/* What was put on an element last time, so a class or an attribute that no longer + applies is taken off again. */ +const applied = new WeakMap() + +function apply(el: Element, attrs: Attrs | undefined) { + const before = applied.get(el) + if (before != null) { + el.classList.remove(...before.classes) + for (const name of before.names) el.removeAttribute(name) + } + const classes = String(attrs?.className ?? '').split(/\s+/).filter(Boolean) + const names: string[] = [] + for (const [key, value] of Object.entries(attrs ?? {})) { + if (key === 'className' || key === 'children' || value == null || value === false || typeof value === 'function') continue + const name = key === 'colSpan' ? 'colspan' : key === 'rowSpan' ? 'rowspan' : key + el.setAttribute(name, value === true ? '' : key === 'style' && typeof value === 'object' ? cssText(value as Record) : String(value)) + names.push(name) + } + el.classList.add(...classes) + applied.set(el, { classes, names }) +} + +export function DataTable({ + data, + columns, + getRowId, + rowProps, + emptyText, + className, + tableClass, + footer, + stack = false, +}: { + data: T[] + columns: Col[] + getRowId: (row: T, index: number) => string + /** What a row's `` carries: a class, `data-*`. */ + rowProps?: (row: T, index: number) => HTMLAttributes | undefined + /** What to say when there are no rows. Without it the body is blank. */ + emptyText?: ReactNode + className?: string + tableClass?: string + /** The cells of a footer row. */ + footer?: ReactNode + stack?: boolean +}) { + const wrap = useRef(null) + useStacking(wrap, stack) + + /* The column definitions must keep their identity from one render to the next: the + grid renders a function in `header`/`cell` as a COMPONENT, and a new function every + render would unmount and mount every cell again (a menu closes, a field loses its + focus). So the definitions are built once per set of ids, from two components that + read the current columns from the context below. */ + const ids = columns.map((c) => c.id).join('|') + // eslint-disable-next-line react-hooks/exhaustive-deps + const defs = useMemo(() => columns.map((c) => ({ id: c.id, header: HeaderCell, cell: BodyCell })), [ids]) + const grid = useTable({ features: dataGridFeatures, columns: defs, data, getRowId, manualPagination: true }) + + /* After every render: the grid has drawn its elements, now they get what the screen + asked of them. */ + useLayoutEffect(() => { + const root = wrap.current + const table = root?.querySelector('table') + if (root == null || table == null) return + for (const c of columns) { + const th = table.tHead?.querySelector(`th[data-col-id="${CSS.escape(c.id)}"]`) + if (th != null) apply(th, c.th as Attrs | undefined) + } + const byId = new Map(data.map((row, i) => [getRowId(row, i), { row, i }])) + for (const tr of Array.from(table.tBodies[0]?.rows ?? [])) { + const found = byId.get(tr.dataset.rowId ?? '') + if (found == null) { + /* The "nothing here" row. */ + const cell = tr.cells[0] + if (cell != null && tr.querySelector('[data-slot="data-grid-table-empty"]') != null) cell.classList.add(styles.noRows) + continue + } + apply(tr, rowProps?.(found.row, found.i) as Attrs | undefined) + columns.forEach((c, n) => { + const td = tr.cells[n] + if (td != null) apply(td, c.td?.(found.row, found.i) as Attrs | undefined) + }) + } + }) + + const base = [styles.table, stack && styles.stack, tableClass].filter(Boolean).join(' ') + return ( +
+ []}> + + {footer} : undefined} /> + + +
+ ) +} diff --git a/src/ui/Table.tsx b/src/ui/Table.tsx index 67a5e6d..e012697 100644 --- a/src/ui/Table.tsx +++ b/src/ui/Table.tsx @@ -249,7 +249,7 @@ function place(wrap: HTMLElement, table: HTMLTableElement) { /* The table is ReUI's, so it is found inside the wrapper rather than held by a ref. */ const tableIn = (wrap: HTMLElement | null) => wrap?.querySelector('table') ?? null -function useStacking(ref: React.RefObject, stack: boolean) { +export function useStacking(ref: React.RefObject, stack: boolean) { // After every render, because the rows are the screen's: a sort, a page or a // filter hands back new cells, and React leaves alone attributes it did not set. useLayoutEffect(() => { @@ -412,6 +412,45 @@ export function Th({ ) } +/** + * The column-definition twin of `Th`, for `DataTable`: the header's content (the + * sort button, or the plain text) and the attributes its `` carries + * (`aria-sort`). `DataTable` draws the `` itself. + */ +export function head({ + field, + sort, + onSort, + name, + children, + ...th +}: { + field?: string + sort?: Sort | null + onSort?: (field: string) => void + children?: ReactNode + name?: string +} & React.ThHTMLAttributes): { header: ReactNode; th: React.ThHTMLAttributes } { + if (field == null || onSort == null) return { header: children, th } + const active = sort?.field === field + return { + th: { 'aria-sort': active ? (sort!.desc ? 'descending' : 'ascending') : 'none', ...th }, + header: ( + + ), + } +} + /* The button for a row action. It carries an icon and not a label (see the measured reasoning in `Table.module.css`) but keeps its name in `aria-label` and in