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