Skip to content

Commit 45abafe

Browse files
Add single select, multi select to QueryTable (#1065)
* WIP * Wire up selection state changes w/ better click boundaries * Remove onSelect from disk page * don't toggle row select on more button click, modify columns array in place * fix e2e tests Co-authored-by: David Crespo <[email protected]>
1 parent 147dfd7 commit 45abafe

File tree

9 files changed

+94
-26
lines changed

9 files changed

+94
-26
lines changed

app/pages/__tests__/instance/networking.e2e.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ test('Instance networking tab', async ({ page }) => {
1010
// Instance networking tab
1111
await page.click('role=tab[name="Networking"]')
1212
await expectRowVisible(page, 'my-nic', [
13-
'',
1413
'my-nic',
1514
'a network interface',
1615
'172.30.0.10',
@@ -56,15 +55,14 @@ test('Instance networking tab', async ({ page }) => {
5655
.click()
5756
await page.click('role=menuitem[name="Make primary"]')
5857
await expectRowVisible(page, 'my-nic', [
59-
'',
6058
'my-nic',
6159
'a network interface',
6260
'172.30.0.10',
6361
'mock-vpc',
6462
'mock-subnet',
6563
'',
6664
])
67-
await expectRowVisible(page, 'nic-2', ['', 'nic-2', null, null, null, null, 'primary'])
65+
await expectRowVisible(page, 'nic-2', ['nic-2', null, null, null, null, 'primary'])
6866

6967
// Make an edit to the network interface
7068
await page

app/pages/__tests__/row-select.e2e.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { forEach } from 'app/util/e2e'
77
// usual testing philosophy), but they let us make sure selection is being
88
// passed through to the UI Table.
99

10-
test('Row select works as expected', async ({ page }) => {
10+
// skipped for now because we no longer have any live multiselect tables to test
11+
// with. TODO: make it a testing-lib test instead?
12+
test.skip('Row multiselect works as expected', async ({ page }) => {
1113
// SETUP
1214

1315
const headCheckbox = page.locator('thead >> role=checkbox')

app/pages/__tests__/ssh-keys.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ test('SSH keys', async ({ page }) => {
1818

1919
// it's there in the table
2020
await expectNotVisible(page, ['text="No SSH keys"'])
21-
await expectRowVisible(page, 'my-key', ['', 'my-key', 'definitely a key'])
21+
await expectRowVisible(page, 'my-key', ['my-key', 'definitely a key'])
2222

2323
// now delete it
2424
await page.click('role=button[name="Row actions"]')

app/pages/project/networking/VpcPage/tabs/VpcFirewallRulesTab.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
TypeValueListCell,
1212
createTable,
1313
getActionsCol,
14-
getSelectCol,
1514
} from '@oxide/table'
1615
import { Button, EmptyMessage, TableEmptyBox } from '@oxide/ui'
1716

@@ -23,7 +22,6 @@ const tableHelper = createTable().setRowType<VpcFirewallRule>()
2322

2423
/** columns that don't depend on anything in `render` */
2524
const staticColumns = [
26-
tableHelper.createDisplayColumn(getSelectCol()),
2725
tableHelper.createDataColumn('name', { header: 'Name' }),
2826
tableHelper.createDataColumn('action', { header: 'Action' }),
2927
// map() fixes the fact that IpNets aren't strings

libs/table/QueryTable.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { UseQueryOptions } from '@tanstack/react-query'
33
import { hashQueryKey } from '@tanstack/react-query'
44
import type { AccessorFn } from '@tanstack/react-table'
55
import { getCoreRowModel, useTableInstance } from '@tanstack/react-table'
6-
import React from 'react'
6+
import React, { useEffect } from 'react'
77
import { useCallback } from 'react'
88
import { useMemo } from 'react'
99
import type { ComponentType, ReactElement } from 'react'
@@ -17,7 +17,7 @@ import { isOneOf } from '@oxide/util'
1717

1818
import { Table, createTable } from './Table'
1919
import { DefaultCell } from './cells'
20-
import { getActionsCol, getSelectCol } from './columns'
20+
import { getActionsCol, getMultiSelectCol, getSelectCol } from './columns'
2121
import type { MakeActions } from './columns'
2222

2323
interface UseQueryTableResult<Item> {
@@ -44,7 +44,7 @@ export const useQueryTable = <A extends ApiListMethods, M extends keyof A>(
4444
return { Table, Column: QueryTableColumn }
4545
}
4646

47-
interface QueryTableProps<Item> {
47+
type QueryTableProps<Item> = {
4848
/** Prints table data in the console when enabled */
4949
debug?: boolean
5050
/** Function that produces a list of actions from a row item */
@@ -53,7 +53,20 @@ interface QueryTableProps<Item> {
5353
pageSize?: number
5454
children: React.ReactNode
5555
emptyState: React.ReactElement
56-
}
56+
} & (
57+
| {
58+
onSelect: (selection: string) => void
59+
onMultiSelect?: never
60+
}
61+
| {
62+
onSelect?: never
63+
onMultiSelect: (selections: string[]) => void
64+
}
65+
| {
66+
onSelect?: never
67+
onMultiSelect?: never
68+
}
69+
)
5770

5871
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5972
const makeQueryTable = <Item,>(
@@ -68,16 +81,25 @@ const makeQueryTable = <Item,>(
6881
pagination = 'page',
6982
pageSize = 10,
7083
emptyState,
84+
onSelect,
85+
onMultiSelect,
7186
}: QueryTableProps<Item>) {
7287
invariant(
7388
isOneOf(children, [QueryTableColumn]),
7489
'QueryTable can only have Column as a child'
7590
)
7691

92+
const [rowSelection, setRowSelection] = React.useState({})
93+
useEffect(() => {
94+
const selected = Object.keys(rowSelection)
95+
onSelect?.(selected[0])
96+
onMultiSelect?.(selected)
97+
}, [rowSelection, onSelect, onMultiSelect])
98+
7799
const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination()
78100
const tableHelper = useMemo(() => createTable().setRowType<Item>(), [])
79101
const columns = useMemo(() => {
80-
let columns = React.Children.toArray(children).map((child) => {
102+
const columns = React.Children.toArray(children).map((child) => {
81103
const column = { ...(child as ReactElement<QueryTableColumnProps<Item>>).props }
82104

83105
// QueryTableColumnProps ensures `id` is passed in if and only if
@@ -101,12 +123,18 @@ const makeQueryTable = <Item,>(
101123
)
102124
})
103125

126+
if (onSelect) {
127+
columns.unshift(getSelectCol())
128+
} else if (onMultiSelect) {
129+
columns.unshift(getMultiSelectCol())
130+
}
131+
104132
if (makeActions) {
105-
columns = [getSelectCol(), ...columns, getActionsCol(makeActions)]
133+
columns.push(getActionsCol(makeActions))
106134
}
107135

108136
return columns
109-
}, [children, tableHelper, makeActions])
137+
}, [children, tableHelper, makeActions, onSelect, onMultiSelect])
110138

111139
const { data, isLoading } = useApiQuery(
112140
query,
@@ -116,14 +144,20 @@ const makeQueryTable = <Item,>(
116144

117145
const tableData: any[] = useMemo(() => (data as any)?.items || [], [data])
118146

119-
const getRowId = useCallback((row) => row.id, [])
147+
const getRowId = useCallback((row) => row.name, [])
120148

121149
const table = useTableInstance(tableHelper, {
122150
columns,
123151
data: tableData,
124152
getRowId,
125153
getCoreRowModel: getCoreRowModel(),
154+
state: {
155+
rowSelection,
156+
},
126157
manualPagination: true,
158+
enableRowSelection: !!onSelect,
159+
enableMultiRowSelection: !!onMultiSelect,
160+
onRowSelectionChange: setRowSelection,
127161
})
128162

129163
if (debug) console.table((data as { items?: any[] })?.items || data)

libs/table/Table.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TableInstance } from '@tanstack/react-table'
22
import { createTable as _createTable } from '@tanstack/react-table'
3+
import cn from 'classnames'
34

45
import { Table as UITable } from '@oxide/ui'
56

@@ -47,13 +48,36 @@ export const Table = <TGenerics extends OurTableGenerics>({
4748
))}
4849
</UITable.Header>
4950
<UITable.Body>
50-
{table.getRowModel().rows.map((row) => (
51-
<UITable.Row className={rowClassName} selected={row.getIsSelected()} key={row.id}>
52-
{row.getAllCells().map((cell) => (
53-
<UITable.Cell key={cell.column.id}>{cell.renderCell()}</UITable.Cell>
54-
))}
55-
</UITable.Row>
56-
))}
51+
{table.getRowModel().rows.map((row) => {
52+
const onSingleSelect = row.getCanSelect()
53+
? () => {
54+
table.resetRowSelection()
55+
row.toggleSelected()
56+
}
57+
: undefined
58+
const onMultiSelect = row.getCanMultiSelect()
59+
? () => row.toggleSelected()
60+
: undefined
61+
const [firstCell, ...cells] = row.getAllCells()
62+
return (
63+
<UITable.Row
64+
className={cn(rowClassName, { 'cursor-pointer': !!onSingleSelect })}
65+
selected={row.getIsSelected()}
66+
key={row.id}
67+
onClick={onSingleSelect}
68+
>
69+
<UITable.Cell
70+
onClick={onMultiSelect}
71+
className={cn({ 'cursor-pointer': !!onMultiSelect })}
72+
>
73+
{firstCell.renderCell()}
74+
</UITable.Cell>
75+
{cells.map((cell) => (
76+
<UITable.Cell key={cell.column.id}>{cell.renderCell()}</UITable.Cell>
77+
))}
78+
</UITable.Row>
79+
)
80+
})}
5781
</UITable.Body>
5882
</UITable>
5983
)

libs/table/columns/action-col.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export const getActionsCol = <TGenerics extends TableGenerics>(
2929
<div className="flex justify-center">
3030
<Menu>
3131
{/* TODO: This name should not suck; future us, make it so! */}
32-
<MenuButton aria-label="Row actions">
32+
{/* stopPropagation prevents clicks from toggling row select in a single select table */}
33+
<MenuButton aria-label="Row actions" onClick={(e) => e.stopPropagation()}>
3334
<More12Icon className="text-tertiary" />
3435
</MenuButton>
3536
<MenuList>

libs/table/columns/select-col.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import type { Row, TableInstance } from '@tanstack/react-table'
22

3-
import { Checkbox } from '@oxide/ui'
3+
import { Checkbox, Radio } from '@oxide/ui'
44

55
// only needs to be a function because of the generic params
66
export const getSelectCol = <TGenerics,>() => ({
7+
id: 'select',
8+
meta: { thClassName: 'w-10' },
9+
header: '',
10+
cell: ({ row }: { row: Row<TGenerics>; instance: TableInstance<TGenerics> }) => {
11+
// `onChange` is empty to suppress react warning. Actual trigger happens in `Table.tsx`
12+
return <Radio checked={row.getIsSelected()} onChange={() => {}} />
13+
},
14+
})
15+
16+
export const getMultiSelectCol = <TGenerics,>() => ({
717
id: 'select',
818
meta: { thClassName: 'w-10' },
919
header: ({ instance }: { instance: TableInstance<TGenerics> }) => (
@@ -16,6 +26,7 @@ export const getSelectCol = <TGenerics,>() => ({
1626
</div>
1727
),
1828
cell: ({ row }: { row: Row<TGenerics> }) => (
19-
<Checkbox checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />
29+
// `onChange` is empty to suppress react warning. Actual trigger happens in `Table.tsx`
30+
<Checkbox checked={row.getIsSelected()} onChange={() => {}} />
2031
),
2132
})

libs/ui/lib/radio/Radio.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const Radio = ({ children, className, ...inputProps }: RadioProps) => (
2828
<div className="absolute left-1 top-1 hidden h-2 w-2 rounded-full bg-accent peer-checked:block" />
2929
</span>
3030

31-
<span className="ml-2.5 text-sans-md text-secondary">{children}</span>
31+
{children && <span className="ml-2.5 text-sans-md text-secondary">{children}</span>}
3232
</label>
3333
)
3434

0 commit comments

Comments
 (0)