Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { QueryClient } from '@tanstack/react-query'

import { Api } from './__generated__/Api'
import {
getApiQueryOptions,
getListQueryOptionsFn,
getUseApiMutation,
getUseApiQueries,
getUseApiQuery,
Expand All @@ -17,13 +19,23 @@ import {
wrapQueryClient,
} from './hooks'

export { ensurePrefetched, PAGE_SIZE, type PaginatedQuery } from './hooks'

export const api = new Api({
// unit tests run in Node, whose fetch implementation requires a full URL
host: process.env.NODE_ENV === 'test' ? 'http://testhost' : '',
})

export type ApiMethods = typeof api.methods

/** API-specific query options helper. */
export const apiq = getApiQueryOptions(api.methods)
/**
* Query options helper that only supports list endpoints. Returns
* a function `(limit, pageToken) => QueryOptions` for use with
* `useQueryTable`.
*/
export const getListQFn = getListQueryOptionsFn(api.methods)
export const useApiQuery = getUseApiQuery(api.methods)
export const useApiQueries = getUseApiQueries(api.methods)
/**
Expand Down
70 changes: 68 additions & 2 deletions app/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import {
type UseQueryOptions,
type UseQueryResult,
} from '@tanstack/react-query'
import * as R from 'remeda'
import { type SetNonNullable } from 'type-fest'

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

import type { ApiResult } from './__generated__/Api'
import { processServerError, type ApiError } from './errors'
import { navToLogin } from './nav-to-login'
import { type ResultsPage } from './util'

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

// Managed here instead of at the display layer so it can be built into the
// query options and shared between loader prefetch and QueryTable
export const PAGE_SIZE = 25

/**
* This primarily exists so we can have an object that encapsulates everything
* useQueryTable needs to know about a query. In particular, it needs the page
* size, and you can't pull that out of the query options object unless you
* stick it in `meta`, and then we don't have type safety.
*/
export type PaginatedQuery<TData> = {
optionsFn: (
pageToken?: string
) => UseQueryOptions<TData, ApiError> & { queryKey: QueryKey }
pageSize: number
}

/**
* This is the same as getApiQueryOptions except for two things:
*
* 1. We use a type constraint on the method key to ensure it can
* only be used with endpoints that return a `ResultsPage`.
* 2. Instead of returning the options directly, it returns a paginated
* query config object containing the page size and a function that
* takes `limit` and `pageToken` and merges them into the query params
* so that these can be passed in by `QueryTable`.
*/
export const getListQueryOptionsFn =
<A extends ApiClient>(api: A) =>
<
M extends string &
{
// this helper can only be used with endpoints that return ResultsPage
[K in keyof A]: Result<A[K]> extends ResultsPage<unknown> ? K : never
}[keyof A],
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What this does:

image

>(
method: M,
params: Params<A[M]>,
options: UseQueryOtherOptions<Result<A[M]>, ApiError> = {}
): PaginatedQuery<Result<A[M]>> => {
// We pull limit out of the query params rather than passing it in some
// other way so that there is exactly one way of specifying it. If we had
// some other way of doing it, and then you also passed it in as a query
// param, it would be hard to guess which takes precedence. (pathOr plays
// nice when the properties don't exist.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const limit = R.pathOr(params as any, ['query', 'limit'], PAGE_SIZE)
return {
optionsFn: (pageToken?: string) => {
const newParams = { ...params, query: { ...params.query, limit, pageToken } }
return getApiQueryOptions(api)(method, newParams, {
...options,
// identity function so current page sticks around while next loads
placeholderData: (x) => x,
Copy link
Collaborator Author

@david-crespo david-crespo Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just remembered the problem with this, which made me reluctant to do it everywhere, though doing it in just a few random tables (what we currently do) is probably even worse. When we don't do the blank flash, we don't scroll to top when the next page loads.

2024-11-21-pag-no-scroll.mp4

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GOT EM

2024-11-21-fix-table-scroll-to-top.mp4

})
},
pageSize: limit,
}
}

export const getUseApiQuery =
<A extends ApiClient>(api: A) =>
<M extends string & keyof A>(
Expand All @@ -140,7 +202,7 @@ export const getUsePrefetchedApiQuery =
options: UseQueryOtherOptions<Result<A[M]>, ApiError> = {}
) => {
const qOptions = getApiQueryOptions(api)(method, params, options)
return ensure(useQuery(qOptions), qOptions.queryKey)
return ensurePrefetched(useQuery(qOptions), qOptions.queryKey)
}

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

export function ensure<TData, TError>(
/**
* Ensure a query result came from the cache by blowing up if `data` comes
* back undefined.
*/
export function ensurePrefetched<TData, TError>(
result: UseQueryResult<TData, TError>,
/**
* Optional because if we call this manually from a component like
Expand Down
2 changes: 2 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
VpcFirewallRuleUpdate,
} from './__generated__/Api'

export type ResultsPage<TItem> = { items: TItem[]; nextPage?: string }

// API limits encoded in https://github.com/oxidecomputer/omicron/blob/main/nexus/src/app/mod.rs

export const MAX_NICS_PER_INSTANCE = 8
Expand Down
7 changes: 6 additions & 1 deletion app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ export function ContentPane() {
const ref = useRef<HTMLDivElement>(null)
useScrollRestoration(ref)
return (
<div ref={ref} className="flex flex-col overflow-auto" data-testid="scroll-container">
<div
ref={ref}
className="flex flex-col overflow-auto"
id="scroll-container"
data-testid="scroll-container"
>
<div className="flex grow flex-col pb-8">
<SkipLinkTarget />
<main className="[&>*]:gutter">
Expand Down
18 changes: 12 additions & 6 deletions app/pages/project/snapshots/SnapshotsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom'

import {
apiQueryClient,
getListQFn,
queryClient,
useApiMutation,
useApiQueryClient,
useApiQueryErrorsAllowed,
Expand All @@ -25,7 +27,7 @@ import { confirmDelete } from '~/stores/confirm-delete'
import { SkeletonCell } from '~/table/cells/EmptyCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
import { useQueryTable } from '~/table/QueryTable2'
import { Badge } from '~/ui/lib/Badge'
import { CreateLink } from '~/ui/lib/CreateButton'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
Expand All @@ -52,12 +54,12 @@ const EmptyState = () => (
/>
)

const snapshotList = (project: string) => getListQFn('snapshotList', { query: { project } })
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can now share the query options between the prefetch in the loader and the query table, and we don't have to specify PAGE_SIZE manually here to make sure it matches the query inside QueryTable.


SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => {
const { project } = getProjectSelector(params)
await Promise.all([
apiQueryClient.prefetchQuery('snapshotList', {
query: { project, limit: PAGE_SIZE },
}),
queryClient.prefetchQuery(snapshotList(project).optionsFn()),

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

const { mutateAsync: deleteSnapshot } = useApiMutation('snapshotDelete', {
Expand Down Expand Up @@ -132,6 +133,11 @@ export function SnapshotsPage() {
[deleteSnapshot, navigate, project]
)
const columns = useColsWithActions(staticCols, makeActions)
const { table } = useQueryTable({
query: snapshotList(project),
columns,
emptyState: <EmptyState />,
})
return (
<>
<PageHeader>
Expand All @@ -146,7 +152,7 @@ export function SnapshotsPage() {
<TableActions>
<CreateLink to={pb.snapshotsNew({ project })}>New snapshot</CreateLink>
</TableActions>
<Table columns={columns} emptyState={<EmptyState />} />
{table}
<Outlet />
</>
)
Expand Down
14 changes: 9 additions & 5 deletions app/pages/system/inventory/DisksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
import { createColumnHelper } from '@tanstack/react-table'

import {
apiQueryClient,
getListQFn,
queryClient,
type PhysicalDisk,
type PhysicalDiskPolicy,
type PhysicalDiskState,
} from '@oxide/api'
import { Servers24Icon } from '@oxide/design-system/icons/react'

import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
import { useQueryTable } from '~/table/QueryTable2'
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'

Expand All @@ -37,8 +38,10 @@ const EmptyState = () => (
/>
)

const diskList = getListQFn('physicalDiskList', {})

export async function loader() {
await apiQueryClient.prefetchQuery('physicalDiskList', { query: { limit: PAGE_SIZE } })
await queryClient.prefetchQuery(diskList.optionsFn())
return null
}

Expand Down Expand Up @@ -68,6 +71,7 @@ const staticCols = [

Component.displayName = 'DisksTab'
export function Component() {
const { Table } = useQueryTable('physicalDiskList', {})
return <Table emptyState={<EmptyState />} columns={staticCols} />
const emptyState = <EmptyState />
const { table } = useQueryTable({ query: diskList, columns: staticCols, emptyState })
return table
}
21 changes: 14 additions & 7 deletions app/pages/system/inventory/SledsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@
*/
import { createColumnHelper } from '@tanstack/react-table'

import { apiQueryClient, type Sled, type SledPolicy, type SledState } from '@oxide/api'
import {
getListQFn,
queryClient,
type Sled,
type SledPolicy,
type SledState,
} from '@oxide/api'
import { Servers24Icon } from '@oxide/design-system/icons/react'

import { makeLinkCell } from '~/table/cells/LinkCell'
import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable'
import { useQueryTable } from '~/table/QueryTable2'
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { pb } from '~/util/path-builder'
Expand All @@ -36,10 +42,10 @@ const EmptyState = () => {
)
}

const sledList = getListQFn('sledList', {})

export async function loader() {
await apiQueryClient.prefetchQuery('sledList', {
query: { limit: PAGE_SIZE },
})
await queryClient.prefetchQuery(sledList.optionsFn())
return null
}

Expand Down Expand Up @@ -69,6 +75,7 @@ const staticCols = [

Component.displayName = 'SledsTab'
export function Component() {
const { Table } = useQueryTable('sledList', {}, { placeholderData: (x) => x })
return <Table emptyState={<EmptyState />} columns={staticCols} />
const emptyState = <EmptyState />
const { table } = useQueryTable({ query: sledList, columns: staticCols, emptyState })
return table
}
5 changes: 3 additions & 2 deletions app/table/QueryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-
import React, { useCallback, useMemo, type ComponentType } from 'react'

import {
PAGE_SIZE,
useApiQuery,
type ApiError,
type ApiListMethods,
Expand All @@ -27,6 +28,8 @@ import { TableEmptyBox } from '~/ui/lib/Table'

import { Table } from './Table'

export { PAGE_SIZE }

interface UseQueryTableResult<Item extends Record<string, unknown>> {
Table: ComponentType<QueryTableProps<Item>>
}
Expand Down Expand Up @@ -59,8 +62,6 @@ type QueryTableProps<Item> = {
columns: ColumnDef<Item, any>[]
}

export const PAGE_SIZE = 25

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const makeQueryTable = <Item extends Record<string, unknown>>(
query: any,
Expand Down
Loading
Loading