Skip to content

Commit 034ca87

Browse files
authored
refactor: new useQueryTable with legit types, returns the query data (#2567)
* redo useQueryTable so that the types are legit and it returns the data * fix junky getRowId and use new QueryTable on sleds and physical disks * get wild with a list-specific helper to make the call sites clean * encapsulate pageSize in the query config so it is defined in one place * do the placeholderData thing for all lists * scroll to top when page changes * loading spinner on page changes! * fix the pagination test, test lovely new scroll reset logic * fix other e2es, don't scroll reset on browser forward/back * fix bug found while converting other tables, extract useScrollReset
1 parent 4b4d7fb commit 034ca87

File tree

15 files changed

+300
-53
lines changed

15 files changed

+300
-53
lines changed

app/api/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { QueryClient } from '@tanstack/react-query'
99

1010
import { Api } from './__generated__/Api'
1111
import {
12+
getApiQueryOptions,
13+
getListQueryOptionsFn,
1214
getUseApiMutation,
1315
getUseApiQueries,
1416
getUseApiQuery,
@@ -17,13 +19,23 @@ import {
1719
wrapQueryClient,
1820
} from './hooks'
1921

22+
export { ensurePrefetched, PAGE_SIZE, type PaginatedQuery } from './hooks'
23+
2024
export const api = new Api({
2125
// unit tests run in Node, whose fetch implementation requires a full URL
2226
host: process.env.NODE_ENV === 'test' ? 'http://testhost' : '',
2327
})
2428

2529
export type ApiMethods = typeof api.methods
2630

31+
/** API-specific query options helper. */
32+
export const apiq = getApiQueryOptions(api.methods)
33+
/**
34+
* Query options helper that only supports list endpoints. Returns
35+
* a function `(limit, pageToken) => QueryOptions` for use with
36+
* `useQueryTable`.
37+
*/
38+
export const getListQFn = getListQueryOptionsFn(api.methods)
2739
export const useApiQuery = getUseApiQuery(api.methods)
2840
export const useApiQueries = getUseApiQueries(api.methods)
2941
/**

app/api/hooks.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ import {
2121
type UseQueryOptions,
2222
type UseQueryResult,
2323
} from '@tanstack/react-query'
24+
import * as R from 'remeda'
2425
import { type SetNonNullable } from 'type-fest'
2526

2627
import { invariant } from '~/util/invariant'
2728

2829
import type { ApiResult } from './__generated__/Api'
2930
import { processServerError, type ApiError } from './errors'
3031
import { navToLogin } from './nav-to-login'
32+
import { type ResultsPage } from './util'
3133

3234
/* eslint-disable @typescript-eslint/no-explicit-any */
3335
export type Params<F> = F extends (p: infer P) => any ? P : never
@@ -123,6 +125,66 @@ export const getApiQueryOptions =
123125
...options,
124126
})
125127

128+
// Managed here instead of at the display layer so it can be built into the
129+
// query options and shared between loader prefetch and QueryTable
130+
export const PAGE_SIZE = 25
131+
132+
/**
133+
* This primarily exists so we can have an object that encapsulates everything
134+
* useQueryTable needs to know about a query. In particular, it needs the page
135+
* size, and you can't pull that out of the query options object unless you
136+
* stick it in `meta`, and then we don't have type safety.
137+
*/
138+
export type PaginatedQuery<TData> = {
139+
optionsFn: (
140+
pageToken?: string
141+
) => UseQueryOptions<TData, ApiError> & { queryKey: QueryKey }
142+
pageSize: number
143+
}
144+
145+
/**
146+
* This is the same as getApiQueryOptions except for two things:
147+
*
148+
* 1. We use a type constraint on the method key to ensure it can
149+
* only be used with endpoints that return a `ResultsPage`.
150+
* 2. Instead of returning the options directly, it returns a paginated
151+
* query config object containing the page size and a function that
152+
* takes `limit` and `pageToken` and merges them into the query params
153+
* so that these can be passed in by `QueryTable`.
154+
*/
155+
export const getListQueryOptionsFn =
156+
<A extends ApiClient>(api: A) =>
157+
<
158+
M extends string &
159+
{
160+
// this helper can only be used with endpoints that return ResultsPage
161+
[K in keyof A]: Result<A[K]> extends ResultsPage<unknown> ? K : never
162+
}[keyof A],
163+
>(
164+
method: M,
165+
params: Params<A[M]>,
166+
options: UseQueryOtherOptions<Result<A[M]>, ApiError> = {}
167+
): PaginatedQuery<Result<A[M]>> => {
168+
// We pull limit out of the query params rather than passing it in some
169+
// other way so that there is exactly one way of specifying it. If we had
170+
// some other way of doing it, and then you also passed it in as a query
171+
// param, it would be hard to guess which takes precedence. (pathOr plays
172+
// nice when the properties don't exist.)
173+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
174+
const limit = R.pathOr(params as any, ['query', 'limit'], PAGE_SIZE)
175+
return {
176+
optionsFn: (pageToken?: string) => {
177+
const newParams = { ...params, query: { ...params.query, limit, pageToken } }
178+
return getApiQueryOptions(api)(method, newParams, {
179+
...options,
180+
// identity function so current page sticks around while next loads
181+
placeholderData: (x) => x,
182+
})
183+
},
184+
pageSize: limit,
185+
}
186+
}
187+
126188
export const getUseApiQuery =
127189
<A extends ApiClient>(api: A) =>
128190
<M extends string & keyof A>(
@@ -140,7 +202,7 @@ export const getUsePrefetchedApiQuery =
140202
options: UseQueryOtherOptions<Result<A[M]>, ApiError> = {}
141203
) => {
142204
const qOptions = getApiQueryOptions(api)(method, params, options)
143-
return ensure(useQuery(qOptions), qOptions.queryKey)
205+
return ensurePrefetched(useQuery(qOptions), qOptions.queryKey)
144206
}
145207

146208
const prefetchError = (key?: QueryKey) =>
@@ -152,7 +214,11 @@ Ensure the following:
152214
• request isn't erroring-out server-side (check the Networking tab)
153215
• mock API endpoint is implemented in handlers.ts`
154216

155-
export function ensure<TData, TError>(
217+
/**
218+
* Ensure a query result came from the cache by blowing up if `data` comes
219+
* back undefined.
220+
*/
221+
export function ensurePrefetched<TData, TError>(
156222
result: UseQueryResult<TData, TError>,
157223
/**
158224
* Optional because if we call this manually from a component like

app/api/util.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import type {
2323
VpcFirewallRuleUpdate,
2424
} from './__generated__/Api'
2525

26+
export type ResultsPage<TItem> = { items: TItem[]; nextPage?: string }
27+
2628
// API limits encoded in https://github.com/oxidecomputer/omicron/blob/main/nexus/src/app/mod.rs
2729

2830
export const MAX_NICS_PER_INSTANCE = 8

app/layouts/helpers.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ export function ContentPane() {
2020
const ref = useRef<HTMLDivElement>(null)
2121
useScrollRestoration(ref)
2222
return (
23-
<div ref={ref} className="flex flex-col overflow-auto" data-testid="scroll-container">
23+
<div
24+
ref={ref}
25+
className="flex flex-col overflow-auto"
26+
id="scroll-container"
27+
data-testid="scroll-container"
28+
>
2429
<div className="flex grow flex-col pb-8">
2530
<SkipLinkTarget />
2631
<main className="[&>*]:gutter">

app/pages/project/snapshots/SnapshotsPage.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1111

1212
import {
1313
apiQueryClient,
14+
getListQFn,
15+
queryClient,
1416
useApiMutation,
1517
useApiQueryClient,
1618
useApiQueryErrorsAllowed,
@@ -25,7 +27,7 @@ import { confirmDelete } from '~/stores/confirm-delete'
2527
import { SkeletonCell } from '~/table/cells/EmptyCell'
2628
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
2729
import { Columns } from '~/table/columns/common'
28-
import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
30+
import { useQueryTable } from '~/table/QueryTable2'
2931
import { Badge } from '~/ui/lib/Badge'
3032
import { CreateLink } from '~/ui/lib/CreateButton'
3133
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
@@ -52,12 +54,12 @@ const EmptyState = () => (
5254
/>
5355
)
5456

57+
const snapshotList = (project: string) => getListQFn('snapshotList', { query: { project } })
58+
5559
SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => {
5660
const { project } = getProjectSelector(params)
5761
await Promise.all([
58-
apiQueryClient.prefetchQuery('snapshotList', {
59-
query: { project, limit: PAGE_SIZE },
60-
}),
62+
queryClient.prefetchQuery(snapshotList(project).optionsFn()),
6163

6264
// Fetch disks and preload into RQ cache so fetches by ID in DiskNameFromId
6365
// can be mostly instant yet gracefully fall back to fetching individually
@@ -100,7 +102,6 @@ const staticCols = [
100102
export function SnapshotsPage() {
101103
const queryClient = useApiQueryClient()
102104
const { project } = useProjectSelector()
103-
const { Table } = useQueryTable('snapshotList', { query: { project } })
104105
const navigate = useNavigate()
105106

106107
const { mutateAsync: deleteSnapshot } = useApiMutation('snapshotDelete', {
@@ -132,6 +133,11 @@ export function SnapshotsPage() {
132133
[deleteSnapshot, navigate, project]
133134
)
134135
const columns = useColsWithActions(staticCols, makeActions)
136+
const { table } = useQueryTable({
137+
query: snapshotList(project),
138+
columns,
139+
emptyState: <EmptyState />,
140+
})
135141
return (
136142
<>
137143
<PageHeader>
@@ -146,7 +152,7 @@ export function SnapshotsPage() {
146152
<TableActions>
147153
<CreateLink to={pb.snapshotsNew({ project })}>New snapshot</CreateLink>
148154
</TableActions>
149-
<Table columns={columns} emptyState={<EmptyState />} />
155+
{table}
150156
<Outlet />
151157
</>
152158
)

app/pages/system/inventory/DisksTab.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
import { createColumnHelper } from '@tanstack/react-table'
99

1010
import {
11-
apiQueryClient,
11+
getListQFn,
12+
queryClient,
1213
type PhysicalDisk,
1314
type PhysicalDiskPolicy,
1415
type PhysicalDiskState,
1516
} from '@oxide/api'
1617
import { Servers24Icon } from '@oxide/design-system/icons/react'
1718

18-
import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
19+
import { useQueryTable } from '~/table/QueryTable2'
1920
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
2021
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
2122

@@ -37,8 +38,10 @@ const EmptyState = () => (
3738
/>
3839
)
3940

41+
const diskList = getListQFn('physicalDiskList', {})
42+
4043
export async function loader() {
41-
await apiQueryClient.prefetchQuery('physicalDiskList', { query: { limit: PAGE_SIZE } })
44+
await queryClient.prefetchQuery(diskList.optionsFn())
4245
return null
4346
}
4447

@@ -68,6 +71,7 @@ const staticCols = [
6871

6972
Component.displayName = 'DisksTab'
7073
export function Component() {
71-
const { Table } = useQueryTable('physicalDiskList', {})
72-
return <Table emptyState={<EmptyState />} columns={staticCols} />
74+
const emptyState = <EmptyState />
75+
const { table } = useQueryTable({ query: diskList, columns: staticCols, emptyState })
76+
return table
7377
}

app/pages/system/inventory/SledsTab.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
*/
88
import { createColumnHelper } from '@tanstack/react-table'
99

10-
import { apiQueryClient, type Sled, type SledPolicy, type SledState } from '@oxide/api'
10+
import {
11+
getListQFn,
12+
queryClient,
13+
type Sled,
14+
type SledPolicy,
15+
type SledState,
16+
} from '@oxide/api'
1117
import { Servers24Icon } from '@oxide/design-system/icons/react'
1218

1319
import { makeLinkCell } from '~/table/cells/LinkCell'
14-
import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
20+
import { useQueryTable } from '~/table/QueryTable2'
1521
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
1622
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
1723
import { pb } from '~/util/path-builder'
@@ -36,10 +42,10 @@ const EmptyState = () => {
3642
)
3743
}
3844

45+
const sledList = getListQFn('sledList', {})
46+
3947
export async function loader() {
40-
await apiQueryClient.prefetchQuery('sledList', {
41-
query: { limit: PAGE_SIZE },
42-
})
48+
await queryClient.prefetchQuery(sledList.optionsFn())
4349
return null
4450
}
4551

@@ -69,6 +75,7 @@ const staticCols = [
6975

7076
Component.displayName = 'SledsTab'
7177
export function Component() {
72-
const { Table } = useQueryTable('sledList', {}, { placeholderData: (x) => x })
73-
return <Table emptyState={<EmptyState />} columns={staticCols} />
78+
const emptyState = <EmptyState />
79+
const { table } = useQueryTable({ query: sledList, columns: staticCols, emptyState })
80+
return table
7481
}

app/table/QueryTable.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-
1212
import React, { useCallback, useMemo, type ComponentType } from 'react'
1313

1414
import {
15+
PAGE_SIZE,
1516
useApiQuery,
1617
type ApiError,
1718
type ApiListMethods,
@@ -27,6 +28,8 @@ import { TableEmptyBox } from '~/ui/lib/Table'
2728

2829
import { Table } from './Table'
2930

31+
export { PAGE_SIZE }
32+
3033
interface UseQueryTableResult<Item extends Record<string, unknown>> {
3134
Table: ComponentType<QueryTableProps<Item>>
3235
}
@@ -59,8 +62,6 @@ type QueryTableProps<Item> = {
5962
columns: ColumnDef<Item, any>[]
6063
}
6164

62-
export const PAGE_SIZE = 25
63-
6465
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6566
const makeQueryTable = <Item extends Record<string, unknown>>(
6667
query: any,

0 commit comments

Comments
 (0)