From 83f6c8d2e49d905bb4bb723b73ddd80ad0656ba1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Nov 2024 12:44:55 -0600 Subject: [PATCH 01/18] redo useQueryTable so that the types are legit and it returns the data --- app/api/client.ts | 4 + app/pages/project/snapshots/SnapshotsPage.tsx | 19 +++-- app/table/QueryTable2.tsx | 84 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 app/table/QueryTable2.tsx diff --git a/app/api/client.ts b/app/api/client.ts index cdd3c08875..2985d800b7 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -9,6 +9,7 @@ import { QueryClient } from '@tanstack/react-query' import { Api } from './__generated__/Api' import { + getApiQueryOptions, getUseApiMutation, getUseApiQueries, getUseApiQuery, @@ -17,6 +18,8 @@ import { wrapQueryClient, } from './hooks' +export { ensure } 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' : '', @@ -24,6 +27,7 @@ export const api = new Api({ export type ApiMethods = typeof api.methods +export const apiq = getApiQueryOptions(api.methods) export const useApiQuery = getUseApiQuery(api.methods) export const useApiQueries = getUseApiQueries(api.methods) /** diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index d2b0e28ab1..405b8e00e9 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -10,7 +10,9 @@ import { useCallback } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { + apiq, apiQueryClient, + queryClient, useApiMutation, useApiQueryClient, useApiQueryErrorsAllowed, @@ -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 { PAGE_SIZE, useQueryTable } from '~/table/QueryTable2' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -51,13 +53,14 @@ const EmptyState = () => ( buttonTo={pb.snapshotsNew(useProjectSelector())} /> ) +// clearly we need to shorten this +const snapshotListOptions = (project: string) => (limit: number, pageToken?: string) => + apiq('snapshotList', { query: { project, pageToken, limit } }) SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) await Promise.all([ - apiQueryClient.prefetchQuery('snapshotList', { - query: { project, limit: PAGE_SIZE }, - }), + queryClient.prefetchQuery(snapshotListOptions(project)(PAGE_SIZE)), // Fetch disks and preload into RQ cache so fetches by ID in DiskNameFromId // can be mostly instant yet gracefully fall back to fetching individually @@ -100,7 +103,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', { @@ -132,6 +134,11 @@ export function SnapshotsPage() { [deleteSnapshot, navigate, project] ) const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + optionsFn: snapshotListOptions(project), + columns, + emptyState: , + }) return ( <> @@ -146,7 +153,7 @@ export function SnapshotsPage() { New snapshot - } /> + {table} ) diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx new file mode 100644 index 0000000000..0815f2fb4f --- /dev/null +++ b/app/table/QueryTable2.tsx @@ -0,0 +1,84 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery, type QueryKey, type QueryOptions } from '@tanstack/react-query' +import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' +import React, { useCallback, useMemo } from 'react' + +import { ensure, type ApiError } from '@oxide/api' + +import { Pagination } from '~/components/Pagination' +import { usePagination } from '~/hooks/use-pagination' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' + +import { Table } from './Table' + +export const PAGE_SIZE = 25 + +type QueryTableProps = { + optionsFn: ( + limit: number, + page_token?: string + ) => QueryOptions<{ items: TItem[]; nextPage?: string }, ApiError> & { + queryKey: QueryKey + } + pageSize?: number + rowHeight?: 'small' | 'large' + emptyState: React.ReactElement + // React Table does the same in the type of `columns` on `useReactTable` + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: ColumnDef[] +} + +export function useQueryTable({ + optionsFn, + pageSize = PAGE_SIZE, + rowHeight = 'small', + emptyState, + columns, +}: QueryTableProps) { + const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() + const queryResult = useQuery(optionsFn(pageSize, currentPage)) + // only ensure prefetched if we're on the first page + if (currentPage === undefined) ensure(queryResult) + const { data, isLoading } = queryResult + const tableData = useMemo(() => data?.items || [], [data]) + + // TODO: need a better function that takes name or ID. sleds in the sleds + // table have no name, for example + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getRowId = useCallback((row: any) => row.name, []) + + const table = useReactTable({ + columns, + data: tableData, + getRowId, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + const isEmpty = tableData.length === 0 && !hasPrev + + const tableElement = isLoading ? null : isEmpty ? ( + {emptyState || } + ) : ( + <> +
+ + + ) + + return { table: tableElement, query: queryResult } +} From 4de0bb06a73d0398987eb1965e202941382e4eb8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Nov 2024 15:45:32 -0600 Subject: [PATCH 02/18] fix junky getRowId and use new QueryTable on sleds and physical disks --- app/pages/system/inventory/DisksTab.tsx | 18 +++++++++++++----- app/pages/system/inventory/SledsTab.tsx | 19 ++++++++++++------- app/table/QueryTable2.tsx | 12 ++++-------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index e029e66f9e..961054b46e 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -8,14 +8,15 @@ import { createColumnHelper } from '@tanstack/react-table' import { - apiQueryClient, + apiq, + 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 { PAGE_SIZE, useQueryTable } from '~/table/QueryTable2' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -37,8 +38,11 @@ const EmptyState = () => ( /> ) +const diskList = (limit: number, pageToken?: string) => + apiq('physicalDiskList', { query: { limit, pageToken } }, { placeholderData: (x) => x }) + export async function loader() { - await apiQueryClient.prefetchQuery('physicalDiskList', { query: { limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(diskList(PAGE_SIZE)) return null } @@ -68,6 +72,10 @@ const staticCols = [ Component.displayName = 'DisksTab' export function Component() { - const { Table } = useQueryTable('physicalDiskList', {}) - return
} columns={staticCols} /> + const { table } = useQueryTable({ + optionsFn: diskList, + columns: staticCols, + emptyState: , + }) + return table } diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 94b8da375f..ac8c9ca599 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -7,11 +7,11 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { apiQueryClient, type Sled, type SledPolicy, type SledState } from '@oxide/api' +import { apiq, 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 { PAGE_SIZE, useQueryTable } from '~/table/QueryTable2' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' @@ -36,10 +36,11 @@ const EmptyState = () => { ) } +const sledList = (limit: number, pageToken?: string) => + apiq('sledList', { query: { limit, pageToken } }, { placeholderData: (x) => x }) + export async function loader() { - await apiQueryClient.prefetchQuery('sledList', { - query: { limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(sledList(PAGE_SIZE)) return null } @@ -69,6 +70,10 @@ const staticCols = [ Component.displayName = 'SledsTab' export function Component() { - const { Table } = useQueryTable('sledList', {}, { placeholderData: (x) => x }) - return
} columns={staticCols} /> + const { table } = useQueryTable({ + optionsFn: sledList, + columns: staticCols, + emptyState: , + }) + return table } diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 0815f2fb4f..fb1e2ea580 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -7,7 +7,7 @@ */ import { useQuery, type QueryKey, type QueryOptions } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useCallback, useMemo } from 'react' +import React, { useMemo } from 'react' import { ensure, type ApiError } from '@oxide/api' @@ -35,7 +35,8 @@ type QueryTableProps = { columns: ColumnDef[] } -export function useQueryTable({ +// require ID only so we can use it in getRowId +export function useQueryTable({ optionsFn, pageSize = PAGE_SIZE, rowHeight = 'small', @@ -49,15 +50,10 @@ export function useQueryTable({ const { data, isLoading } = queryResult const tableData = useMemo(() => data?.items || [], [data]) - // TODO: need a better function that takes name or ID. sleds in the sleds - // table have no name, for example - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getRowId = useCallback((row: any) => row.name, []) - const table = useReactTable({ columns, data: tableData, - getRowId, + getRowId: (row) => row.id, getCoreRowModel: getCoreRowModel(), manualPagination: true, }) From 94064441b6ada9c2ed1c30de387e9813ef2016d3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 10:22:27 -0600 Subject: [PATCH 03/18] get wild with a list-specific helper to make the call sites clean --- app/api/client.ts | 10 ++++- app/api/hooks.ts | 40 ++++++++++++++++++- app/api/util.ts | 2 + app/pages/project/snapshots/SnapshotsPage.tsx | 13 +++--- app/pages/system/inventory/DisksTab.tsx | 9 ++--- app/pages/system/inventory/SledsTab.tsx | 15 ++++--- app/table/QueryTable.tsx | 5 ++- app/table/QueryTable2.tsx | 11 +++-- 8 files changed, 77 insertions(+), 28 deletions(-) diff --git a/app/api/client.ts b/app/api/client.ts index 2985d800b7..1e9715214a 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -10,6 +10,7 @@ import { QueryClient } from '@tanstack/react-query' import { Api } from './__generated__/Api' import { getApiQueryOptions, + getListQueryOptionsFn, getUseApiMutation, getUseApiQueries, getUseApiQuery, @@ -18,7 +19,7 @@ import { wrapQueryClient, } from './hooks' -export { ensure } from './hooks' +export { ensurePrefetched, PAGE_SIZE } from './hooks' export const api = new Api({ // unit tests run in Node, whose fetch implementation requires a full URL @@ -27,7 +28,14 @@ export const api = new Api({ 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) /** diff --git a/app/api/hooks.ts b/app/api/hooks.ts index ebb9dcf8fc..ddac2f6efb 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -28,6 +28,7 @@ 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 extends (p: infer P) => any ? P : never @@ -123,6 +124,37 @@ export const getApiQueryOptions = ...options, }) +export const PAGE_SIZE = 25 + +/** + * 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 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 = + (api: A) => + < + M extends string & + { + // this helper can only be used with endpoints that return ResultsPage + [K in keyof A]: Result extends ResultsPage ? K : never + }[keyof A], + >( + method: M, + params: Params, + options: UseQueryOtherOptions, ApiError> = {} + ) => + (limit: number = PAGE_SIZE, pageToken?: string) => + getApiQueryOptions(api)( + method, + { ...params, query: { ...params.query, limit, pageToken } }, + options + ) + export const getUseApiQuery = (api: A) => ( @@ -140,7 +172,7 @@ export const getUsePrefetchedApiQuery = options: UseQueryOtherOptions, ApiError> = {} ) => { const qOptions = getApiQueryOptions(api)(method, params, options) - return ensure(useQuery(qOptions), qOptions.queryKey) + return ensurePrefetched(useQuery(qOptions), qOptions.queryKey) } const prefetchError = (key?: QueryKey) => @@ -152,7 +184,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( +/** + * Ensure a query result came from the cache by blowing up if `data` comes + * back undefined. + */ +export function ensurePrefetched( result: UseQueryResult, /** * Optional because if we call this manually from a component like diff --git a/app/api/util.ts b/app/api/util.ts index 954cd77b0e..d552aa4f36 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -23,6 +23,8 @@ import type { VpcFirewallRuleUpdate, } from './__generated__/Api' +export type ResultsPage = { 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 diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 405b8e00e9..4f33132774 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -10,8 +10,8 @@ import { useCallback } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { - apiq, apiQueryClient, + getListQFn, queryClient, useApiMutation, useApiQueryClient, @@ -27,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/QueryTable2' +import { useQueryTable } from '~/table/QueryTable2' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -53,14 +53,13 @@ const EmptyState = () => ( buttonTo={pb.snapshotsNew(useProjectSelector())} /> ) -// clearly we need to shorten this -const snapshotListOptions = (project: string) => (limit: number, pageToken?: string) => - apiq('snapshotList', { query: { project, pageToken, limit } }) + +const snapshotList = (project: string) => getListQFn('snapshotList', { query: { project } }) SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) await Promise.all([ - queryClient.prefetchQuery(snapshotListOptions(project)(PAGE_SIZE)), + queryClient.prefetchQuery(snapshotList(project)()), // Fetch disks and preload into RQ cache so fetches by ID in DiskNameFromId // can be mostly instant yet gracefully fall back to fetching individually @@ -135,7 +134,7 @@ export function SnapshotsPage() { ) const columns = useColsWithActions(staticCols, makeActions) const { table } = useQueryTable({ - optionsFn: snapshotListOptions(project), + optionsFn: snapshotList(project), columns, emptyState: , }) diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index 961054b46e..64fbe2989a 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -8,7 +8,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { - apiq, + getListQFn, queryClient, type PhysicalDisk, type PhysicalDiskPolicy, @@ -16,7 +16,7 @@ import { } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' -import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable2' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -38,11 +38,10 @@ const EmptyState = () => ( /> ) -const diskList = (limit: number, pageToken?: string) => - apiq('physicalDiskList', { query: { limit, pageToken } }, { placeholderData: (x) => x }) +const diskList = getListQFn('physicalDiskList', {}, { placeholderData: (x) => x }) export async function loader() { - await queryClient.prefetchQuery(diskList(PAGE_SIZE)) + await queryClient.prefetchQuery(diskList()) return null } diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index ac8c9ca599..82e92ed934 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -7,11 +7,17 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { apiq, queryClient, 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/QueryTable2' +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' @@ -36,11 +42,10 @@ const EmptyState = () => { ) } -const sledList = (limit: number, pageToken?: string) => - apiq('sledList', { query: { limit, pageToken } }, { placeholderData: (x) => x }) +const sledList = getListQFn('sledList', {}, { placeholderData: (x) => x }) export async function loader() { - await queryClient.prefetchQuery(sledList(PAGE_SIZE)) + await queryClient.prefetchQuery(sledList()) return null } diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index 4c78da2cef..c73f7a25cd 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -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, @@ -27,6 +28,8 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { Table } from './Table' +export { PAGE_SIZE } + interface UseQueryTableResult> { Table: ComponentType> } @@ -59,8 +62,6 @@ type QueryTableProps = { columns: ColumnDef[] } -export const PAGE_SIZE = 25 - // eslint-disable-next-line @typescript-eslint/no-explicit-any const makeQueryTable = >( query: any, diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index fb1e2ea580..4985bd2d7a 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -9,7 +9,7 @@ import { useQuery, type QueryKey, type QueryOptions } from '@tanstack/react-quer import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' import React, { useMemo } from 'react' -import { ensure, type ApiError } from '@oxide/api' +import { ensurePrefetched, PAGE_SIZE, type ApiError, type ResultsPage } from '@oxide/api' import { Pagination } from '~/components/Pagination' import { usePagination } from '~/hooks/use-pagination' @@ -18,13 +18,11 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { Table } from './Table' -export const PAGE_SIZE = 25 - type QueryTableProps = { optionsFn: ( limit: number, page_token?: string - ) => QueryOptions<{ items: TItem[]; nextPage?: string }, ApiError> & { + ) => QueryOptions, ApiError> & { queryKey: QueryKey } pageSize?: number @@ -44,9 +42,10 @@ export function useQueryTable({ columns, }: QueryTableProps) { const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() - const queryResult = useQuery(optionsFn(pageSize, currentPage)) + const queryOptions = optionsFn(pageSize, currentPage) + const queryResult = useQuery(queryOptions) // only ensure prefetched if we're on the first page - if (currentPage === undefined) ensure(queryResult) + if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) const { data, isLoading } = queryResult const tableData = useMemo(() => data?.items || [], [data]) From 8cab460ba3fab7641edb14b4199cb3ffa6832c81 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 15:31:31 -0600 Subject: [PATCH 04/18] encapsulate pageSize in the query config so it is defined in one place --- app/api/client.ts | 2 +- app/api/hooks.ts | 42 ++++++++++++++----- app/pages/project/snapshots/SnapshotsPage.tsx | 4 +- app/pages/system/inventory/DisksTab.tsx | 9 ++-- app/pages/system/inventory/SledsTab.tsx | 9 ++-- app/table/QueryTable2.tsx | 21 ++++------ 6 files changed, 48 insertions(+), 39 deletions(-) diff --git a/app/api/client.ts b/app/api/client.ts index 1e9715214a..2d8da89976 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -19,7 +19,7 @@ import { wrapQueryClient, } from './hooks' -export { ensurePrefetched, PAGE_SIZE } 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 diff --git a/app/api/hooks.ts b/app/api/hooks.ts index ddac2f6efb..8ee11ea95e 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -21,6 +21,7 @@ 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' @@ -124,16 +125,32 @@ 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 = { + optionsFn: ( + pageToken?: string + ) => UseQueryOptions & { 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 function that - * takes `limit` and `pageToken` and merges them into the query params so - * that these can be passed in by `QueryTable`. + * 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 = (api: A) => @@ -147,13 +164,18 @@ export const getListQueryOptionsFn = method: M, params: Params, options: UseQueryOtherOptions, ApiError> = {} - ) => - (limit: number = PAGE_SIZE, pageToken?: string) => - getApiQueryOptions(api)( - method, - { ...params, query: { ...params.query, limit, pageToken } }, - options - ) + ): PaginatedQuery> => { + // 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) + }, + pageSize: limit, + } + } export const getUseApiQuery = (api: A) => diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 4f33132774..828f849eb0 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -59,7 +59,7 @@ const snapshotList = (project: string) => getListQFn('snapshotList', { query: { SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) await Promise.all([ - queryClient.prefetchQuery(snapshotList(project)()), + 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 @@ -134,7 +134,7 @@ export function SnapshotsPage() { ) const columns = useColsWithActions(staticCols, makeActions) const { table } = useQueryTable({ - optionsFn: snapshotList(project), + query: snapshotList(project), columns, emptyState: , }) diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index 64fbe2989a..dc8f057dc0 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -41,7 +41,7 @@ const EmptyState = () => ( const diskList = getListQFn('physicalDiskList', {}, { placeholderData: (x) => x }) export async function loader() { - await queryClient.prefetchQuery(diskList()) + await queryClient.prefetchQuery(diskList.optionsFn()) return null } @@ -71,10 +71,7 @@ const staticCols = [ Component.displayName = 'DisksTab' export function Component() { - const { table } = useQueryTable({ - optionsFn: diskList, - columns: staticCols, - emptyState: , - }) + const emptyState = + const { table } = useQueryTable({ query: diskList, columns: staticCols, emptyState }) return table } diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 82e92ed934..1ac5c55548 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -45,7 +45,7 @@ const EmptyState = () => { const sledList = getListQFn('sledList', {}, { placeholderData: (x) => x }) export async function loader() { - await queryClient.prefetchQuery(sledList()) + await queryClient.prefetchQuery(sledList.optionsFn()) return null } @@ -75,10 +75,7 @@ const staticCols = [ Component.displayName = 'SledsTab' export function Component() { - const { table } = useQueryTable({ - optionsFn: sledList, - columns: staticCols, - emptyState: , - }) + const emptyState = + const { table } = useQueryTable({ query: sledList, columns: staticCols, emptyState }) return table } diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 4985bd2d7a..9a38bcc568 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -5,11 +5,11 @@ * * Copyright Oxide Computer Company */ -import { useQuery, type QueryKey, type QueryOptions } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' import React, { useMemo } from 'react' -import { ensurePrefetched, PAGE_SIZE, type ApiError, type ResultsPage } from '@oxide/api' +import { ensurePrefetched, type PaginatedQuery, type ResultsPage } from '@oxide/api' import { Pagination } from '~/components/Pagination' import { usePagination } from '~/hooks/use-pagination' @@ -19,13 +19,7 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { Table } from './Table' type QueryTableProps = { - optionsFn: ( - limit: number, - page_token?: string - ) => QueryOptions, ApiError> & { - queryKey: QueryKey - } - pageSize?: number + query: PaginatedQuery> rowHeight?: 'small' | 'large' emptyState: React.ReactElement // React Table does the same in the type of `columns` on `useReactTable` @@ -35,14 +29,13 @@ type QueryTableProps = { // require ID only so we can use it in getRowId export function useQueryTable({ - optionsFn, - pageSize = PAGE_SIZE, + query, rowHeight = 'small', emptyState, columns, }: QueryTableProps) { const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() - const queryOptions = optionsFn(pageSize, currentPage) + const queryOptions = query.optionsFn(currentPage) const queryResult = useQuery(queryOptions) // only ensure prefetched if we're on the first page if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) @@ -65,8 +58,8 @@ export function useQueryTable({ <>
Date: Thu, 21 Nov 2024 16:26:25 -0600 Subject: [PATCH 05/18] do the placeholderData thing for all lists --- app/api/hooks.ts | 6 +++++- app/pages/system/inventory/DisksTab.tsx | 2 +- app/pages/system/inventory/SledsTab.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/api/hooks.ts b/app/api/hooks.ts index 8ee11ea95e..af176bbf1a 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -171,7 +171,11 @@ export const getListQueryOptionsFn = return { optionsFn: (pageToken?: string) => { const newParams = { ...params, query: { ...params.query, limit, pageToken } } - return getApiQueryOptions(api)(method, newParams, options) + return getApiQueryOptions(api)(method, newParams, { + ...options, + // identity function so current page sticks around while next loads + placeholderData: (x) => x, + }) }, pageSize: limit, } diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index dc8f057dc0..3e9a3fb16f 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -38,7 +38,7 @@ const EmptyState = () => ( /> ) -const diskList = getListQFn('physicalDiskList', {}, { placeholderData: (x) => x }) +const diskList = getListQFn('physicalDiskList', {}) export async function loader() { await queryClient.prefetchQuery(diskList.optionsFn()) diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 1ac5c55548..9a7a5346ad 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -42,7 +42,7 @@ const EmptyState = () => { ) } -const sledList = getListQFn('sledList', {}, { placeholderData: (x) => x }) +const sledList = getListQFn('sledList', {}) export async function loader() { await queryClient.prefetchQuery(sledList.optionsFn()) From ca6f43032af530897c891b2e8ddefac86f92038c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 18:02:28 -0600 Subject: [PATCH 06/18] scroll to top when page changes --- app/layouts/helpers.tsx | 7 ++++++- app/table/QueryTable2.tsx | 11 ++++++++--- test/e2e/pagination.e2e.ts | 2 ++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index 1aef3d1865..d4769685d7 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -20,7 +20,12 @@ export function ContentPane() { const ref = useRef(null) useScrollRestoration(ref) return ( -
+
diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 9a38bcc568..7a2b65e56f 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useMemo } from 'react' +import React, { useEffect, useMemo } from 'react' import { ensurePrefetched, type PaginatedQuery, type ResultsPage } from '@oxide/api' @@ -39,9 +39,14 @@ export function useQueryTable({ const queryResult = useQuery(queryOptions) // only ensure prefetched if we're on the first page if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) - const { data, isLoading } = queryResult + const { data } = queryResult const tableData = useMemo(() => data?.items || [], [data]) + const firstItemId = tableData?.[0].id + useEffect(() => { + document.querySelector('#scroll-container')?.scrollTo(0, 0) + }, [firstItemId]) + const table = useReactTable({ columns, data: tableData, @@ -52,7 +57,7 @@ export function useQueryTable({ const isEmpty = tableData.length === 0 && !hasPrev - const tableElement = isLoading ? null : isEmpty ? ( + const tableElement = isEmpty ? ( {emptyState || } ) : ( <> diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index 2013675b36..4523966ab0 100644 --- a/test/e2e/pagination.e2e.ts +++ b/test/e2e/pagination.e2e.ts @@ -34,3 +34,5 @@ test('pagination', async ({ page }) => { await nextButton.click() await expect(nextButton).toBeDisabled() // no more pages }) + +// TODO: test scroll to top on page change From 65bb5fd9446b74d5e8fcf6d72a71149bea3fb557 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 20:03:14 -0600 Subject: [PATCH 07/18] loading spinner on page changes! --- app/table/QueryTable2.tsx | 5 ++++- app/ui/lib/Pagination.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 7a2b65e56f..06774cb9d4 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -39,7 +39,7 @@ export function useQueryTable({ const queryResult = useQuery(queryOptions) // only ensure prefetched if we're on the first page if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) - const { data } = queryResult + const { data, isPlaceholderData } = queryResult const tableData = useMemo(() => data?.items || [], [data]) const firstItemId = tableData?.[0].id @@ -69,6 +69,9 @@ export function useQueryTable({ nextPage={data?.nextPage} onNext={goToNextPage} onPrev={goToPrevPage} + // I can't believe how well this works, but it exactly matches when + // we want to show the spinner. Cached page changes don't need it. + loading={isPlaceholderData} /> ) diff --git a/app/ui/lib/Pagination.tsx b/app/ui/lib/Pagination.tsx index 1257e10e8c..005161a635 100644 --- a/app/ui/lib/Pagination.tsx +++ b/app/ui/lib/Pagination.tsx @@ -9,6 +9,8 @@ import cn from 'classnames' import { DirectionLeftIcon, DirectionRightIcon } from '@oxide/design-system/icons/react' +import { Spinner } from './Spinner' + interface PageInputProps { number: number className?: string @@ -34,6 +36,7 @@ export interface PaginationProps { onNext: (nextPage: string) => void onPrev: () => void className?: string + loading?: boolean } export const Pagination = ({ pageSize, @@ -43,6 +46,7 @@ export const Pagination = ({ onNext, onPrev, className, + loading, }: PaginationProps) => { return ( <> @@ -55,7 +59,8 @@ export const Pagination = ({ rows per page - + + {loading && } -
+ ) } diff --git a/mock-api/snapshot.ts b/mock-api/snapshot.ts index 4a70380f9a..33effdb4c6 100644 --- a/mock-api/snapshot.ts +++ b/mock-api/snapshot.ts @@ -14,7 +14,7 @@ import { disks } from './disk' import type { Json } from './json-type' import { project } from './project' -const generatedSnapshots: Json[] = Array.from({ length: 25 }, (_, i) => +const generatedSnapshots: Json[] = Array.from({ length: 80 }, (_, i) => generateSnapshot(i) ) @@ -91,7 +91,7 @@ export const snapshots: Json[] = [ function generateSnapshot(index: number): Json { return { id: uuid(), - name: `disk-1-snapshot-${index + 6}`, + name: `disk-1-snapshot-${index + 7}`, description: '', project_id: project.id, time_created: new Date().toISOString(), diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index 4523966ab0..30322a7901 100644 --- a/test/e2e/pagination.e2e.ts +++ b/test/e2e/pagination.e2e.ts @@ -5,34 +5,65 @@ * * Copyright Oxide Computer Company */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import { expectRowVisible } from './utils' +import { expectScrollTop, scrollTo } from './utils' + +// expectRowVisible is too have for all this +const expectCell = (page: Page, name: string) => + expect(page.getByRole('cell', { name, exact: true })).toBeVisible() test('pagination', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') const table = page.getByRole('table') - const rows = page.getByRole('row') + const rows = table.getByRole('rowgroup').last().getByRole('row') const nextButton = page.getByRole('button', { name: 'next' }) const prevButton = page.getByRole('button', { name: 'prev', exact: true }) + const spinner = page.getByLabel('Pagination').getByLabel('Spinner') + + await expect(spinner).toBeHidden() + await expect(prevButton).toBeDisabled() // we're on the first page - await expect(rows).toHaveCount(26) - await expectRowVisible(table, { name: 'snapshot-1' }) - await expectRowVisible(table, { name: 'disk-1-snapshot-24' }) + await expectCell(page, 'snapshot-1') + await expectCell(page, 'disk-1-snapshot-25') + await expect(rows).toHaveCount(25) + + await scrollTo(page, 100) await nextButton.click() - await expect(rows).toHaveCount(7) - await expectRowVisible(table, { name: 'disk-1-snapshot-25' }) - await expectRowVisible(table, { name: 'disk-1-snapshot-30' }) - await prevButton.click() - await expect(rows).toHaveCount(26) - await expectRowVisible(table, { name: 'snapshot-1' }) - await expectRowVisible(table, { name: 'disk-1-snapshot-24' }) + // spinner goes while the data is fetching... + await expect(spinner).toBeVisible() + await expectScrollTop(page, 100) // scroll resets to top on page change + // ...and goes away roughly when scroll resets + await expect(spinner).toBeHidden() + await expectScrollTop(page, 0) // scroll resets to top on page change + + await expectCell(page, 'disk-1-snapshot-26') + await expectCell(page, 'disk-1-snapshot-50') + await expect(rows).toHaveCount(25) + + await nextButton.click() + await expectCell(page, 'disk-1-snapshot-51') + await expectCell(page, 'disk-1-snapshot-75') + await expect(rows).toHaveCount(25) await nextButton.click() + await expectCell(page, 'disk-1-snapshot-76') + await expectCell(page, 'disk-1-snapshot-86') + await expect(rows).toHaveCount(11) await expect(nextButton).toBeDisabled() // no more pages -}) -// TODO: test scroll to top on page change + await scrollTo(page, 250) + + await prevButton.click() + await expect(spinner).toBeHidden({ timeout: 10 }) // no spinner, cached page + await expect(rows).toHaveCount(25) + await expectCell(page, 'disk-1-snapshot-51') + await expectCell(page, 'disk-1-snapshot-75') + await expectScrollTop(page, 0) // scroll resets to top on prev too + + await nextButton.click() + await expect(spinner).toBeHidden({ timeout: 10 }) // no spinner, cached page +}) diff --git a/test/e2e/scroll-restore.e2e.ts b/test/e2e/scroll-restore.e2e.ts index 318b2e8a59..483c0e9e82 100644 --- a/test/e2e/scroll-restore.e2e.ts +++ b/test/e2e/scroll-restore.e2e.ts @@ -5,18 +5,9 @@ * * Copyright Oxide Computer Company */ -import { expect, test, type Page } from './utils' +import { expect, test } from '@playwright/test' -async function expectScrollTop(page: Page, expected: number) { - const container = page.getByTestId('scroll-container') - const getScrollTop = () => container.evaluate((el: HTMLElement) => el.scrollTop) - await expect.poll(getScrollTop).toBe(expected) -} - -async function scrollTo(page: Page, to: number) { - const container = page.getByTestId('scroll-container') - await container.evaluate((el: HTMLElement, to) => el.scrollTo(0, to), to) -} +import { expectScrollTop, scrollTo } from './utils' test('scroll restore', async ({ page }) => { // open small window to make scrolling easier diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 2f8437d0cd..38e32a5ab1 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -232,3 +232,14 @@ export async function chooseFile( buffer: size === 'large' ? bigFile : smallFile, }) } + +export async function expectScrollTop(page: Page, expected: number) { + const container = page.getByTestId('scroll-container') + const getScrollTop = () => container.evaluate((el: HTMLElement) => el.scrollTop) + await expect.poll(getScrollTop).toBe(expected) +} + +export async function scrollTo(page: Page, to: number) { + const container = page.getByTestId('scroll-container') + await container.evaluate((el: HTMLElement, to) => el.scrollTo(0, to), to) +} From 5756daec26f32c78c178d999bb55f187d9ab259c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 22:18:43 -0600 Subject: [PATCH 09/18] fix other e2es, don't scroll reset on browser forward/back --- app/table/QueryTable2.tsx | 28 ++++++++++++++++++++++------ test/e2e/snapshots.e2e.ts | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 72125339fb..8f0c7b28ce 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useEffect, useMemo } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { ensurePrefetched, type PaginatedQuery, type ResultsPage } from '@oxide/api' @@ -27,8 +27,6 @@ type QueryTableProps = { columns: ColumnDef[] } -const resetScroll = () => document.querySelector('#scroll-container')?.scrollTo(0, 0) - // require ID only so we can use it in getRowId export function useQueryTable({ query, @@ -44,8 +42,20 @@ export function useQueryTable({ const { data, isPlaceholderData } = queryResult const tableData = useMemo(() => data?.items || [], [data]) + // this is annoying, but basically we only want this to happen when clicking + // next/prev to change page, not, for example, on initial pageload after + // browser forward/back. + const needScrollReset = useRef(false) const firstItemId = tableData?.[0].id - useEffect(resetScroll, [firstItemId]) + useEffect(() => { + if (needScrollReset.current) { + document.querySelector('#scroll-container')?.scrollTo(0, 0) + needScrollReset.current = false + } + // trigger by first item ID and not, e.g., currentPage because currentPage changes + // as soon as you click Next, while the item ID doesn't change until the page + // actually changes. + }, [firstItemId]) const table = useReactTable({ columns, @@ -67,8 +77,14 @@ export function useQueryTable({ hasNext={tableData.length === query.pageSize} hasPrev={hasPrev} nextPage={data?.nextPage} - onNext={goToNextPage} - onPrev={goToPrevPage} + onNext={(p) => { + needScrollReset.current = true + goToNextPage(p) + }} + onPrev={() => { + needScrollReset.current = true + goToPrevPage() + }} // I can't believe how well this works, but it exactly matches when // we want to show the spinner. Cached page changes don't need it. loading={isPlaceholderData} diff --git a/test/e2e/snapshots.e2e.ts b/test/e2e/snapshots.e2e.ts index 2e211b4d6e..4467a50ad5 100644 --- a/test/e2e/snapshots.e2e.ts +++ b/test/e2e/snapshots.e2e.ts @@ -28,7 +28,7 @@ test('Click through snapshots', async ({ page }) => { test('Confirm delete snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - const row = page.getByRole('row', { name: 'disk-1-snapshot-6' }) + const row = page.getByRole('row', { name: 'disk-1-snapshot-7' }) // scroll a little so the dropdown menu isn't behind the pagination bar await page.getByRole('table').click() // focus the content pane From 31ddca8ded9e424408a830c329b8ba5ca9b9a55b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 23:23:27 -0600 Subject: [PATCH 10/18] fix bug found while converting other tables, extract useScrollReset --- app/api/hooks.ts | 6 +++++- app/table/QueryTable2.tsx | 39 +++++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/api/hooks.ts b/app/api/hooks.ts index af176bbf1a..9f267bf6ed 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -165,7 +165,11 @@ export const getListQueryOptionsFn = params: Params, options: UseQueryOtherOptions, ApiError> = {} ): PaginatedQuery> => { - // pathOr plays nice when the properties don't exist + // 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 { diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 8f0c7b28ce..55dd7b623b 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -27,6 +27,23 @@ type QueryTableProps = { columns: ColumnDef[] } +/** + * Reset scroll to top when clicking * next/prev to change page but not, + * for example, on initial pageload after browser forward/back. + */ +function useScrollReset(triggerDep: string | undefined) { + const resetRequested = useRef(false) + useEffect(() => { + if (resetRequested.current) { + document.querySelector('#scroll-container')?.scrollTo(0, 0) + resetRequested.current = false + } + }, [triggerDep]) + return () => { + resetRequested.current = true + } +} + // require ID only so we can use it in getRowId export function useQueryTable({ query, @@ -42,20 +59,10 @@ export function useQueryTable({ const { data, isPlaceholderData } = queryResult const tableData = useMemo(() => data?.items || [], [data]) - // this is annoying, but basically we only want this to happen when clicking - // next/prev to change page, not, for example, on initial pageload after - // browser forward/back. - const needScrollReset = useRef(false) - const firstItemId = tableData?.[0].id - useEffect(() => { - if (needScrollReset.current) { - document.querySelector('#scroll-container')?.scrollTo(0, 0) - needScrollReset.current = false - } - // trigger by first item ID and not, e.g., currentPage because currentPage changes - // as soon as you click Next, while the item ID doesn't change until the page - // actually changes. - }, [firstItemId]) + // trigger by first item ID and not, e.g., currentPage because currentPage + // changes as soon as you click Next, while the item ID doesn't change until + // the page actually changes. + const requestScrollReset = useScrollReset(tableData.at(0)?.id) const table = useReactTable({ columns, @@ -78,11 +85,11 @@ export function useQueryTable({ hasPrev={hasPrev} nextPage={data?.nextPage} onNext={(p) => { - needScrollReset.current = true + requestScrollReset() goToNextPage(p) }} onPrev={() => { - needScrollReset.current = true + requestScrollReset() goToPrevPage() }} // I can't believe how well this works, but it exactly matches when From ada4f41941f3b1ba7d264a4d85b6a3e30fbb8456 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Nov 2024 14:34:51 -0600 Subject: [PATCH 11/18] move columns up --- app/pages/project/instances/InstancesPage.tsx | 94 +++++++++---------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 97de119b4f..6890c66596 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -69,6 +69,52 @@ export function InstancesPage() { { onSuccess: refetchInstances, onDelete: refetchInstances } ) + const columns = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((instance) => pb.instance({ project, instance })), + }), + colHelper.accessor('ncpus', { + header: 'CPU', + cell: (info) => ( + <> + {info.getValue()} vCPU + + ), + }), + colHelper.accessor('memory', { + header: 'Memory', + cell: (info) => { + const memory = filesize(info.getValue(), { output: 'object', base: 2 }) + return ( + <> + {memory.value} {memory.unit} + + ) + }, + }), + colHelper.accessor( + (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }), + { + header: 'state', + cell: (info) => , + } + ), + colHelper.accessor('timeCreated', Columns.timeCreated), + getActionsCol((instance: Instance) => [ + ...makeButtonActions(instance), + ...makeMenuActions(instance), + ]), + ], + [project, makeButtonActions, makeMenuActions] + ) + + const { Table } = useQueryTable( + 'instanceList', + { query: { project } }, + { placeholderData: (x) => x } + ) + // this is a whole thing. sit down. // We initialize this set as empty because we don't have the instances on hand @@ -143,54 +189,6 @@ export function InstancesPage() { ) ) - const { Table } = useQueryTable( - 'instanceList', - { query: { project } }, - { placeholderData: (x) => x } - ) - - const columns = useMemo( - () => [ - colHelper.accessor('name', { - cell: makeLinkCell((instance) => pb.instance({ project, instance })), - }), - colHelper.accessor('ncpus', { - header: 'CPU', - cell: (info) => ( - <> - {info.getValue()} vCPU - - ), - }), - colHelper.accessor('memory', { - header: 'Memory', - cell: (info) => { - const memory = filesize(info.getValue(), { output: 'object', base: 2 }) - return ( - <> - {memory.value} {memory.unit} - - ) - }, - }), - colHelper.accessor( - (i) => ({ runState: i.runState, timeRunStateUpdated: i.timeRunStateUpdated }), - { - header: 'state', - cell: (info) => , - } - ), - colHelper.accessor('timeCreated', Columns.timeCreated), - getActionsCol((instance: Instance) => [ - ...makeButtonActions(instance), - ...makeMenuActions(instance), - ]), - ], - [project, makeButtonActions, makeMenuActions] - ) - - if (!instances) return null - return ( <> From 022b5f690d48853e577c461bf2eda911c3168824 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Nov 2024 14:57:11 -0600 Subject: [PATCH 12/18] convert instance list to new QueryTable, fix polling bug --- app/pages/project/instances/InstancesPage.tsx | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 6890c66596..17e1ada8f5 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -5,12 +5,20 @@ * * Copyright Oxide Computer Company */ +import { type UseQueryOptions } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { filesize } from 'filesize' import { useMemo, useRef } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api' +import { + apiQueryClient, + getListQFn, + queryClient, + type ApiError, + type Instance, + type InstanceResultsPage, +} from '@oxide/api' import { Instances24Icon } from '@oxide/design-system/icons/react' import { instanceTransitioning } from '~/api/util' @@ -22,7 +30,7 @@ import { InstanceStateCell } from '~/table/cells/InstanceStateCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { useQueryTable } from '~/table/QueryTable2' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -46,11 +54,16 @@ const EmptyState = () => ( const colHelper = createColumnHelper() +const instanceList = ( + project: string, + // kinda gnarly, but we need refetchInterval in the component but not in the loader. + // pick refetchInterval to avoid annoying type conflicts on the full object + options?: Pick, 'refetchInterval'> +) => getListQFn('instanceList', { query: { project } }, options) + InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) - await apiQueryClient.prefetchQuery('instanceList', { - query: { project, limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(instanceList(project).optionsFn()) return null } @@ -109,12 +122,6 @@ export function InstancesPage() { [project, makeButtonActions, makeMenuActions] ) - const { Table } = useQueryTable( - 'instanceList', - { query: { project } }, - { placeholderData: (x) => x } - ) - // this is a whole thing. sit down. // We initialize this set as empty because we don't have the instances on hand @@ -123,10 +130,8 @@ export function InstancesPage() { const transitioningInstances = useRef>(new Set()) const pollingStartTime = useRef(Date.now()) - const { data: instances, dataUpdatedAt } = usePrefetchedApiQuery( - 'instanceList', - { query: { project, limit: PAGE_SIZE } }, - { + const { table, query } = useQueryTable({ + query: instanceList(project, { // The point of all this is to poll quickly for a certain amount of time // after some instance in the current page enters a transitional state // like starting or stopping. After that, it will keep polling, but more @@ -168,8 +173,12 @@ export function InstancesPage() { ? POLL_INTERVAL_FAST : POLL_INTERVAL_SLOW }, - } - ) + }), + columns, + emptyState: , + }) + + const { data: instances, dataUpdatedAt } = query const navigate = useNavigate() useQuickActions( @@ -211,7 +220,7 @@ export function InstancesPage() {
New Instance -
} /> + {table} ) } From df9e9ad46009b87b134bc30693d7106a6c9948df Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Nov 2024 23:30:39 -0600 Subject: [PATCH 13/18] convert the rest of the tables --- app/api/client.ts | 7 +++- app/api/hooks.ts | 3 ++ app/pages/ProjectsPage.tsx | 28 +++++++------- app/pages/project/disks/DisksPage.tsx | 18 ++++++--- .../project/floating-ips/FloatingIpsPage.tsx | 31 ++++++++------- app/pages/project/images/ImagesPage.tsx | 29 +++++++++----- app/pages/project/vpcs/RouterPage.tsx | 38 +++++++++++-------- 7 files changed, 94 insertions(+), 60 deletions(-) diff --git a/app/api/client.ts b/app/api/client.ts index 2d8da89976..b44cf53497 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -19,7 +19,12 @@ import { wrapQueryClient, } from './hooks' -export { ensurePrefetched, PAGE_SIZE, type PaginatedQuery } from './hooks' +export { + ensurePrefetched, + usePrefetchedQuery, + PAGE_SIZE, + type PaginatedQuery, +} from './hooks' export const api = new Api({ // unit tests run in Node, whose fetch implementation requires a full URL diff --git a/app/api/hooks.ts b/app/api/hooks.ts index 9f267bf6ed..363ac86815 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -232,6 +232,9 @@ export function ensurePrefetched( return result as SetNonNullable } +export const usePrefetchedQuery = (options: UseQueryOptions) => + ensurePrefetched(useQuery(options), options.queryKey) + const ERRORS_ALLOWED = 'errors-allowed' /** Result that includes both success and error so it can be cached by RQ */ diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 7a43282554..6f56760c70 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -11,9 +11,9 @@ import { Outlet, useNavigate } from 'react-router-dom' import { apiQueryClient, + getListQFn, + queryClient, useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, type Project, } from '@oxide/api' import { Folder16Icon, Folder24Icon } from '@oxide/design-system/icons/react' @@ -24,7 +24,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { makeLinkCell } from '~/table/cells/LinkCell' 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -42,8 +42,10 @@ const EmptyState = () => ( /> ) +const projectList = getListQFn('projectList', {}) + export async function loader() { - await apiQueryClient.prefetchQuery('projectList', { query: { limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(projectList.optionsFn()) return null } @@ -60,17 +62,11 @@ Component.displayName = 'ProjectsPage' export function Component() { const navigate = useNavigate() - const queryClient = useApiQueryClient() - const { Table } = useQueryTable('projectList', {}) - const { data: projects } = usePrefetchedApiQuery('projectList', { - query: { limit: PAGE_SIZE }, - }) - const { mutateAsync: deleteProject } = useApiMutation('projectDelete', { onSuccess() { // TODO: figure out if this is invalidating as expected, can we leave out the query // altogether, etc. Look at whether limit param matters. - queryClient.invalidateQueries('projectList') + apiQueryClient.invalidateQueries('projectList') }, }) @@ -100,6 +96,12 @@ export function Component() { [deleteProject, navigate] ) + const columns = useColsWithActions(staticCols, makeActions) + const { + table, + query: { data: projects }, + } = useQueryTable({ query: projectList, columns, emptyState: }) + useQuickActions( useMemo( () => [ @@ -117,8 +119,6 @@ export function Component() { ) ) - const columns = useColsWithActions(staticCols, makeActions) - return ( <> @@ -133,7 +133,7 @@ export function Component() { New Project -
} /> + {table} ) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 298e4af5f9..756eebb5bd 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -13,6 +13,8 @@ import { apiQueryClient, diskCan, genName, + getListQFn, + queryClient, useApiMutation, useApiQueryClient, type Disk, @@ -28,7 +30,7 @@ import { addToast } from '~/stores/toast' import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -48,12 +50,12 @@ const EmptyState = () => ( /> ) +const diskList = (project: string) => getListQFn('diskList', { query: { project } }) + DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) await Promise.all([ - apiQueryClient.prefetchQuery('diskList', { - query: { project, limit: PAGE_SIZE }, - }), + queryClient.prefetchQuery(diskList(project).optionsFn()), // fetch instances and preload into RQ cache so fetches by ID in // InstanceLinkCell can be mostly instant yet gracefully fall back to @@ -97,7 +99,6 @@ const staticCols = [ export function DisksPage() { const queryClient = useApiQueryClient() const { project } = useProjectSelector() - const { Table } = useQueryTable('diskList', { query: { project } }) const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { onSuccess(_data, variables) { @@ -160,6 +161,11 @@ export function DisksPage() { ) const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: diskList(project), + columns, + emptyState: , + }) return ( <> @@ -175,7 +181,7 @@ export function DisksPage() { New Disk -
} /> + {table} ) diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index ae5b95b57c..97bf51fe83 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -12,9 +12,11 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQueryClient, - usePrefetchedApiQuery, + usePrefetchedQuery, type FloatingIp, type Instance, } from '@oxide/api' @@ -31,7 +33,7 @@ import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' 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 { CopyableIp } from '~/ui/lib/CopyableIp' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -53,15 +55,15 @@ const EmptyState = () => ( /> ) +const fipList = (project: string) => getListQFn('floatingIpList', { query: { project } }) +const instanceList = (project: string) => + getListQFn('instanceList', { query: { project, limit: ALL_ISH } }) + FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) await Promise.all([ - apiQueryClient.prefetchQuery('floatingIpList', { - query: { project, limit: PAGE_SIZE }, - }), - apiQueryClient.prefetchQuery('instanceList', { - query: { project }, - }), + queryClient.prefetchQuery(fipList(project).optionsFn()), + queryClient.prefetchQuery(instanceList(project).optionsFn()), // fetch IP Pools and preload into RQ cache so fetches by ID in // IpPoolCell can be mostly instant yet gracefully fall back to // fetching individually if we don't fetch them all here @@ -102,9 +104,7 @@ export function FloatingIpsPage() { const [floatingIpToModify, setFloatingIpToModify] = useState(null) const queryClient = useApiQueryClient() const { project } = useProjectSelector() - const { data: instances } = usePrefetchedApiQuery('instanceList', { - query: { project }, - }) + const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { @@ -202,9 +202,12 @@ export function FloatingIpsPage() { [deleteFloatingIp, floatingIpDetach, navigate, project, instances] ) - const { Table } = useQueryTable('floatingIpList', { query: { project } }) - const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: fipList(project), + columns, + emptyState: , + }) return ( <> @@ -220,7 +223,7 @@ export function FloatingIpsPage() { New Floating IP -
} /> + {table} {floatingIpToModify && ( ( const colHelper = createColumnHelper() +const imageList = (project: string) => getListQFn('imageList', { query: { project } }) + ImagesPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) - await apiQueryClient.prefetchQuery('imageList', { - query: { project, limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(imageList(project).optionsFn()) return null } export function ImagesPage() { const { project } = useProjectSelector() - const { Table } = useQueryTable('imageList', { query: { project } }) - const queryClient = useApiQueryClient() const [promoteImageName, setPromoteImageName] = useState(null) const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { addToast(<>Image {variables.path.image} deleted) // prettier-ignore - queryClient.invalidateQueries('imageList') + apiQueryClient.invalidateQueries('imageList') }, }) @@ -97,6 +102,12 @@ export function ImagesPage() { ] }, [project, makeActions]) + const { table } = useQueryTable({ + query: imageList(project), + columns, + emptyState: , + }) + return ( <> @@ -111,7 +122,7 @@ export function ImagesPage() { Upload image -
} /> + {table} {promoteImageName && ( setPromoteImageName(null)} diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index b1aae9edce..c893645420 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -13,9 +13,12 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { + apiq, apiQueryClient, + getListQFn, + queryClient, useApiMutation, - usePrefetchedApiQuery, + usePrefetchedQuery, type RouteDestination, type RouterRoute, type RouteTarget, @@ -30,7 +33,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { useQueryTable } from '~/table/QueryTable2' import { Badge } from '~/ui/lib/Badge' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { DateTime } from '~/ui/lib/DateTime' @@ -41,16 +44,18 @@ import { TableControls, TableTitle } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +type RouterParams = { project: string; vpc: string; router: string } + +const routerView = ({ project, vpc, router }: RouterParams) => + apiq('vpcRouterView', { path: { router }, query: { vpc, project } }) + +const routeList = (query: RouterParams) => getListQFn('vpcRouterRouteList', { query }) + export async function loader({ params }: LoaderFunctionArgs) { - const { project, vpc, router } = getVpcRouterSelector(params) + const routerSelector = getVpcRouterSelector(params) await Promise.all([ - apiQueryClient.prefetchQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }), - apiQueryClient.prefetchQuery('vpcRouterRouteList', { - query: { project, router, vpc, limit: PAGE_SIZE }, - }), + queryClient.prefetchQuery(routerView(routerSelector)), + queryClient.prefetchQuery(routeList(routerSelector).optionsFn()), ]) return null } @@ -83,10 +88,7 @@ const RouterRouteTypeValueBadge = ({ Component.displayName = 'RouterPage' export function Component() { const { project, vpc, router } = useVpcRouterSelector() - const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }) + const { data: routerData } = usePrefetchedQuery(routerView({ project, vpc, router })) const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { onSuccess() { @@ -107,7 +109,6 @@ export function Component() { ], [routerData] ) - const { Table } = useQueryTable('vpcRouterRouteList', { query: { project, router, vpc } }) const emptyState = ( )} -
+ {table} ) From 505f3db72add51aa3fa8bf279b2fc899f7b69e93 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 12:54:23 -0600 Subject: [PATCH 14/18] convert a few more --- app/pages/project/vpcs/VpcsPage.tsx | 48 +++++++++++-------- app/pages/settings/SSHKeysPage.tsx | 20 ++++---- .../inventory/sled/SledInstancesTab.tsx | 27 +++++------ app/pages/system/networking/IpPoolsPage.tsx | 23 +++++---- app/pages/system/silos/SiloIdpsTab.tsx | 21 ++++---- app/pages/system/silos/SilosPage.tsx | 30 ++++++------ 6 files changed, 95 insertions(+), 74 deletions(-) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 69df4371e3..bb065b415b 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -11,10 +11,11 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQuery, useApiQueryClient, - usePrefetchedApiQuery, type Vpc, } from '@oxide/api' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' @@ -29,7 +30,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol, 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -37,6 +38,8 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +const vpcList = (project: string) => getListQFn('vpcList', { query: { project } }) + const EmptyState = () => ( } @@ -70,17 +73,13 @@ const colHelper = createColumnHelper() // sure it matches the call in the QueryTable VpcsPage.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) - await apiQueryClient.prefetchQuery('vpcList', { query: { project, limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(vpcList(project).optionsFn()) return null } export function VpcsPage() { const queryClient = useApiQueryClient() const { project } = useProjectSelector() - // to have same params as QueryTable - const { data: vpcs } = usePrefetchedApiQuery('vpcList', { - query: { project, limit: PAGE_SIZE }, - }) const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { @@ -114,18 +113,6 @@ export function VpcsPage() { [deleteVpc, navigate, project] ) - useQuickActions( - useMemo( - () => - vpcs.items.map((v) => ({ - value: v.name, - onSelect: () => navigate(pb.vpc({ project, vpc: v.name })), - navGroup: 'Go to VPC', - })), - [project, vpcs, navigate] - ) - ) - const columns = useMemo( () => [ colHelper.accessor('name', { @@ -145,7 +132,26 @@ export function VpcsPage() { [project, makeActions] ) - const { Table } = useQueryTable('vpcList', { query: { project } }) + const { table, query } = useQueryTable({ + query: vpcList(project), + columns, + emptyState: , + }) + + const { data: vpcs } = query + + useQuickActions( + useMemo( + () => + (vpcs?.items || []).map((v) => ({ + value: v.name, + onSelect: () => navigate(pb.vpc({ project, vpc: v.name })), + navGroup: 'Go to VPC', + })), + [project, vpcs, navigate] + ) + ) + return ( <> @@ -155,7 +161,7 @@ export function VpcsPage() { New Vpc -
} /> + {table} ) diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 39735134dc..824604d6f5 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -9,7 +9,13 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback } from 'react' import { Link, Outlet, useNavigate } from 'react-router-dom' -import { apiQueryClient, useApiMutation, useApiQueryClient, type SshKey } from '@oxide/api' +import { + getListQFn, + queryClient, + useApiMutation, + useApiQueryClient, + type SshKey, +} from '@oxide/api' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -18,7 +24,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' 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 { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -26,10 +32,9 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +const sshKeyList = () => getListQFn('currentUserSshKeyList', {}) export async function loader() { - await apiQueryClient.prefetchQuery('currentUserSshKeyList', { - query: { limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(sshKeyList().optionsFn()) return null } @@ -44,7 +49,6 @@ Component.displayName = 'SSHKeysPage' export function Component() { const navigate = useNavigate() - const { Table } = useQueryTable('currentUserSshKeyList', {}) const queryClient = useApiQueryClient() const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { @@ -76,8 +80,8 @@ export function Component() { onClick={() => navigate(pb.sshKeysNew())} /> ) - const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ query: sshKeyList(), columns, emptyState }) return ( <> @@ -95,7 +99,7 @@ export function Component() { Add SSH key -
+ {table} ) diff --git a/app/pages/system/inventory/sled/SledInstancesTab.tsx b/app/pages/system/inventory/sled/SledInstancesTab.tsx index 8a14da3466..58476dd4e0 100644 --- a/app/pages/system/inventory/sled/SledInstancesTab.tsx +++ b/app/pages/system/inventory/sled/SledInstancesTab.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import type { LoaderFunctionArgs } from 'react-router-dom' import * as R from 'remeda' -import { apiQueryClient, type SledInstance } from '@oxide/api' +import { getListQFn, queryClient, type SledInstance } from '@oxide/api' import { Instances24Icon } from '@oxide/design-system/icons/react' import { InstanceStateBadge } from '~/components/StateBadge' @@ -17,9 +17,12 @@ import { requireSledParams, useSledParams } from '~/hooks/use-params' import { InstanceResourceCell } from '~/table/cells/InstanceResourceCell' 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 { EmptyMessage } from '~/ui/lib/EmptyMessage' +const sledInstanceList = (sledId: string) => + getListQFn('sledInstanceList', { path: { sledId } }) + const EmptyState = () => { return ( { export async function loader({ params }: LoaderFunctionArgs) { const { sledId } = requireSledParams(params) - await apiQueryClient.prefetchQuery('sledInstanceList', { - path: { sledId }, - query: { limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(sledInstanceList(sledId).optionsFn()) return null } @@ -72,13 +72,12 @@ const staticCols = [ Component.displayName = 'SledInstancesTab' export function Component() { const { sledId } = useSledParams() - const { Table } = useQueryTable( - 'sledInstanceList', - { path: { sledId }, query: { limit: PAGE_SIZE } }, - { placeholderData: (x) => x } - ) - const columns = useColsWithActions(staticCols, makeActions) - - return
} rowHeight="large" /> + const { table } = useQueryTable({ + query: sledInstanceList(sledId), + columns, + emptyState: , + rowHeight: 'large', + }) + return table } diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index a77be45173..6cf84d97c3 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -12,9 +12,10 @@ import { Outlet, useNavigate } from 'react-router-dom' import { apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQuery, - usePrefetchedApiQuery, type IpPool, } from '@oxide/api' import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' @@ -29,7 +30,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -66,18 +67,16 @@ const staticColumns = [ colHelper.accessor('timeCreated', Columns.timeCreated), ] +const ipPoolList = () => getListQFn('ipPoolList', {}) + export async function loader() { - await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(ipPoolList().optionsFn()) return null } Component.displayName = 'IpPoolsPage' export function Component() { const navigate = useNavigate() - const { Table } = useQueryTable('ipPoolList', {}) - const { data: pools } = usePrefetchedApiQuery('ipPoolList', { - query: { limit: PAGE_SIZE }, - }) const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { onSuccess(_data, variables) { @@ -109,6 +108,12 @@ export function Component() { ) const columns = useColsWithActions(staticColumns, makeActions) + const { table, query } = useQueryTable({ + query: ipPoolList(), + columns, + emptyState: , + }) + const { data: pools } = query useQuickActions( useMemo( @@ -117,7 +122,7 @@ export function Component() { value: 'New IP pool', onSelect: () => navigate(pb.projectsNew()), }, - ...(pools.items || []).map((p) => ({ + ...(pools?.items || []).map((p) => ({ value: p.name, onSelect: () => navigate(pb.ipPool({ pool: p.name })), navGroup: 'Go to IP pool', @@ -141,7 +146,7 @@ export function Component() { New IP Pool -
} /> + {table} ) diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index b8130edb3c..6b6b197a0b 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -11,11 +11,11 @@ import { Outlet } from 'react-router-dom' import { Cloud24Icon } from '@oxide/design-system/icons/react' -import type { IdentityProvider } from '~/api' +import { getListQFn, type IdentityProvider } from '~/api' import { useSiloSelector } from '~/hooks/use-params' import { LinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' -import { 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' @@ -27,14 +27,13 @@ const EmptyState = () => ( const colHelper = createColumnHelper() +const idpList = (silo: string) => + getListQFn('siloIdentityProviderList', { query: { silo } }) + export function SiloIdpsTab() { const { silo } = useSiloSelector() - const { Table } = useQueryTable('siloIdentityProviderList', { - query: { silo }, - }) - - const staticCols = useMemo( + const columns = useMemo( () => [ colHelper.accessor('name', { cell: (info) => { @@ -53,12 +52,18 @@ export function SiloIdpsTab() { [silo] ) + const { table } = useQueryTable({ + query: idpList(silo), + columns, + emptyState: , + }) + return ( <>
New provider
-
} columns={staticCols} /> + {table} ) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index f68655f4f2..ee40bdc1db 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -10,10 +10,10 @@ import { useCallback, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { - apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQueryClient, - usePrefetchedApiQuery, type Silo, } from '@oxide/api' import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' @@ -27,7 +27,7 @@ import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' 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' @@ -36,6 +36,8 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +const siloList = () => getListQFn('siloList', {}) + const EmptyState = () => ( } @@ -63,7 +65,7 @@ const staticCols = [ ] export async function loader() { - await apiQueryClient.prefetchQuery('siloList', { query: { limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(siloList().optionsFn()) return null } @@ -71,13 +73,7 @@ Component.displayName = 'SilosPage' export function Component() { const navigate = useNavigate() - const { Table } = useQueryTable('siloList', {}) const queryClient = useApiQueryClient() - - const { data: silos } = usePrefetchedApiQuery('siloList', { - query: { limit: PAGE_SIZE }, - }) - const { mutateAsync: deleteSilo } = useApiMutation('siloDelete', { onSuccess(silo, { path }) { queryClient.invalidateQueries('siloList') @@ -98,11 +94,19 @@ export function Component() { [deleteSilo] ) + const columns = useColsWithActions(staticCols, makeActions) + const { table, query } = useQueryTable({ + query: siloList(), + columns, + emptyState: , + }) + const { data: silos } = query + useQuickActions( useMemo( () => [ { value: 'New silo', onSelect: () => navigate(pb.silosNew()) }, - ...silos.items.map((o) => ({ + ...(silos?.items || []).map((o) => ({ value: o.name, onSelect: () => navigate(pb.silo({ silo: o.name })), navGroup: 'Silo detail', @@ -112,8 +116,6 @@ export function Component() { ) ) - const columns = useColsWithActions(staticCols, makeActions) - return ( <> @@ -128,7 +130,7 @@ export function Component() { New silo -
} /> + {table} ) From 727f5af7739b869c55aed1a6a2218249b1f4ede5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 15:55:07 -0600 Subject: [PATCH 15/18] a hard one: IpPoolPage, have to handle rows with no ID field --- app/pages/system/networking/IpPoolPage.tsx | 39 ++++++++++++++-------- app/table/QueryTable2.tsx | 26 ++++++++++++--- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 26f106b8c3..8f4112b1be 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -12,12 +12,16 @@ import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { + apiq, apiQueryClient, + getListQFn, parseIpUtilization, + queryClient, useApiMutation, useApiQuery, useApiQueryClient, usePrefetchedApiQuery, + usePrefetchedQuery, type IpPoolRange, type IpPoolSiloLink, } from '@oxide/api' @@ -38,7 +42,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' 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 { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -51,14 +55,16 @@ import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const query = { limit: PAGE_SIZE } +const ipPoolView = (pool: string) => apiq('ipPoolView', { path: { pool } }) +const ipPoolSiloList = (pool: string) => getListQFn('ipPoolSiloList', { path: { pool } }) +const ipPoolRangeList = (pool: string) => getListQFn('ipPoolRangeList', { path: { pool } }) export async function loader({ params }: LoaderFunctionArgs) { const { pool } = getIpPoolSelector(params) await Promise.all([ - apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }), - apiQueryClient.prefetchQuery('ipPoolSiloList', { path: { pool }, query }), - apiQueryClient.prefetchQuery('ipPoolRangeList', { path: { pool }, query }), + queryClient.prefetchQuery(ipPoolView(pool)), + queryClient.prefetchQuery(ipPoolSiloList(pool).optionsFn()), + queryClient.prefetchQuery(ipPoolRangeList(pool).optionsFn()), apiQueryClient.prefetchQuery('ipPoolUtilizationView', { path: { pool } }), // fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId @@ -76,11 +82,10 @@ export async function loader({ params }: LoaderFunctionArgs) { Component.displayName = 'IpPoolPage' export function Component() { const poolSelector = useIpPoolSelector() - const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) - const { data: ranges } = usePrefetchedApiQuery('ipPoolRangeList', { - path: poolSelector, - query, - }) + const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector.pool)) + const { data: ranges } = usePrefetchedQuery( + ipPoolRangeList(poolSelector.pool).optionsFn() + ) const navigate = useNavigate() const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { onSuccess(_data, variables) { @@ -190,7 +195,6 @@ const ipRangesStaticCols = [ function IpRangesTable() { const { pool } = useIpPoolSelector() - const { Table } = useQueryTable('ipPoolRangeList', { path: { pool } }) const queryClient = useApiQueryClient() const { mutateAsync: removeRange } = useApiMutation('ipPoolRangeRemove', { @@ -239,13 +243,14 @@ function IpRangesTable() { [pool, removeRange] ) const columns = useColsWithActions(ipRangesStaticCols, makeRangeActions) + const { table } = useQueryTable({ query: ipPoolRangeList(pool), columns, emptyState }) return ( <>
Add range
-
+ {table} ) } @@ -283,7 +288,6 @@ const silosStaticCols = [ function LinkedSilosTable() { const poolSelector = useIpPoolSelector() const queryClient = useApiQueryClient() - const { Table } = useQueryTable('ipPoolSiloList', { path: poolSelector }) const { mutateAsync: unlinkSilo } = useApiMutation('ipPoolSiloUnlink', { onSuccess() { @@ -335,12 +339,19 @@ function LinkedSilosTable() { ) const columns = useColsWithActions(silosStaticCols, makeActions) + const { table } = useQueryTable({ + query: ipPoolSiloList(poolSelector.pool), + columns, + emptyState, + getId: (link) => link.siloId, + }) + return ( <>
setShowLinkModal(true)}>Link silo
-
+ {table} {showLinkModal && setShowLinkModal(false)} />} ) diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx index 55dd7b623b..74207dab72 100644 --- a/app/table/QueryTable2.tsx +++ b/app/table/QueryTable2.tsx @@ -25,7 +25,18 @@ type QueryTableProps = { // React Table does the same in the type of `columns` on `useReactTable` // eslint-disable-next-line @typescript-eslint/no-explicit-any columns: ColumnDef[] -} + // Require getId if and only if TItem does not have an id field. Something + // to keep in mind for the future: if instead we used the `select` transform + // function on the query to add an ID to every row, we could just require TItem + // to extend `{ id: string }`, and we wouldn't need this `getId` function. The + // difficulty I ran into was propagating the result of `select` through the API + // query options helpers. But I think it can be done. +} & (TItem extends { id: string } + ? { getId?: never } + : { + /** Needed if and only if `TItem` has no `id` field */ + getId: (row: TItem) => string + }) /** * Reset scroll to top when clicking * next/prev to change page but not, @@ -45,11 +56,12 @@ function useScrollReset(triggerDep: string | undefined) { } // require ID only so we can use it in getRowId -export function useQueryTable({ +export function useQueryTable({ query, rowHeight = 'small', emptyState, columns, + getId, }: QueryTableProps) { const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() const queryOptions = query.optionsFn(currentPage) @@ -59,15 +71,21 @@ export function useQueryTable({ const { data, isPlaceholderData } = queryResult const tableData = useMemo(() => data?.items || [], [data]) + const getRowId = getId + ? getId + : // @ts-expect-error we know from the types that getId is only defined when there is no ID + (row: TItem) => row.id as string + // trigger by first item ID and not, e.g., currentPage because currentPage // changes as soon as you click Next, while the item ID doesn't change until // the page actually changes. - const requestScrollReset = useScrollReset(tableData.at(0)?.id) + const first = tableData.at(0) + const requestScrollReset = useScrollReset(first ? getRowId(first) : undefined) const table = useReactTable({ columns, data: tableData, - getRowId: (row) => row.id, + getRowId, getCoreRowModel: getCoreRowModel(), manualPagination: true, }) From 7b9c8b97f73d45a0d545bf0cbcf18cf3d55892c4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 16:07:33 -0600 Subject: [PATCH 16/18] last few easy ones --- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 27 ++++++++++++------- .../vpcs/VpcPage/tabs/VpcSubnetsTab.tsx | 24 ++++++++++------- app/pages/system/SiloImagesPage.tsx | 15 ++++++----- app/pages/system/inventory/InventoryPage.tsx | 10 ++++--- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 4f1bc835c7..918074cadc 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -9,7 +9,13 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { + apiQueryClient, + getListQFn, + queryClient, + useApiMutation, + type VpcRouter, +} from '@oxide/api' import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' @@ -19,18 +25,19 @@ import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' const colHelper = createColumnHelper() +const vpcRouterList = (params: { project: string; vpc: string }) => + getListQFn('vpcRouterList', { query: params }) + export async function loader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) - await apiQueryClient.prefetchQuery('vpcRouterList', { - query: { project, vpc, limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(vpcRouterList({ project, vpc }).optionsFn()) return null } @@ -39,9 +46,6 @@ export function Component() { const vpcSelector = useVpcSelector() const navigate = useNavigate() const { project, vpc } = vpcSelector - const { Table } = useQueryTable('vpcRouterList', { - query: { project, vpc, limit: PAGE_SIZE }, - }) const emptyState = (
New router
-
+ {table} ) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 253f98b871..59be2701b7 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -10,7 +10,8 @@ import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { - apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQueryClient, type VpcSubnet, @@ -24,18 +25,19 @@ import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' import { getActionsCol, 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 { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' const colHelper = createColumnHelper() +const subnetList = (params: { project: string; vpc: string }) => + getListQFn('vpcSubnetList', { query: params }) + export async function loader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) - await apiQueryClient.prefetchQuery('vpcSubnetList', { - query: { project, vpc, limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(subnetList({ project, vpc }).optionsFn()) return null } @@ -44,8 +46,6 @@ export function Component() { const vpcSelector = useVpcSelector() const queryClient = useApiQueryClient() - const { Table } = useQueryTable('vpcSubnetList', { query: vpcSelector }) - const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { onSuccess() { queryClient.invalidateQueries('vpcSubnetList') @@ -105,13 +105,19 @@ export function Component() { /> ) + const { table } = useQueryTable({ + query: subnetList(vpcSelector), + columns, + emptyState, + rowHeight: 'large', + }) + return ( <>
New subnet
- -
+ {table} ) diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 27dacff831..d502000a16 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -11,7 +11,8 @@ import { useForm, type FieldValues } from 'react-hook-form' import { Outlet } from 'react-router-dom' import { - apiQueryClient, + getListQFn, + queryClient, useApiMutation, useApiQuery, useApiQueryClient, @@ -29,7 +30,7 @@ import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' 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 { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -48,10 +49,10 @@ const EmptyState = () => ( /> ) +const imageList = getListQFn('imageList', {}) + export async function loader() { - await apiQueryClient.prefetchQuery('imageList', { - query: { limit: PAGE_SIZE }, - }) + await queryClient.prefetchQuery(imageList.optionsFn()) return null } @@ -67,7 +68,6 @@ const staticCols = [ Component.displayName = 'SiloImagesPage' export function Component() { - const { Table } = useQueryTable('imageList', {}) const [showModal, setShowModal] = useState(false) const [demoteImage, setDemoteImage] = useState(null) @@ -97,6 +97,7 @@ export function Component() { ) const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ query: imageList, columns, emptyState: }) return ( <> @@ -113,7 +114,7 @@ export function Component() { Promote image -
} /> + {table} {showModal && setShowModal(false)} />} {demoteImage && ( setDemoteImage(null)} image={demoteImage} /> diff --git a/app/pages/system/inventory/InventoryPage.tsx b/app/pages/system/inventory/InventoryPage.tsx index 9525261b1e..8e1a10df9b 100644 --- a/app/pages/system/inventory/InventoryPage.tsx +++ b/app/pages/system/inventory/InventoryPage.tsx @@ -5,23 +5,25 @@ * * Copyright Oxide Computer Company */ -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' + +import { getListQFn, queryClient, usePrefetchedQuery } from '@oxide/api' import { Servers16Icon, Servers24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' -import { PAGE_SIZE } from '~/table/QueryTable' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +const rackList = getListQFn('rackList', {}) + InventoryPage.loader = async () => { - await apiQueryClient.prefetchQuery('rackList', { query: { limit: PAGE_SIZE } }) + await queryClient.prefetchQuery(rackList.optionsFn()) return null } export function InventoryPage() { - const { data: racks } = usePrefetchedApiQuery('rackList', { query: { limit: PAGE_SIZE } }) + const { data: racks } = usePrefetchedQuery(rackList.optionsFn()) const rack = racks?.items[0] if (!rack) return null From 9918a5278082df605616f4d412b275967c9c1fac Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 16:31:19 -0600 Subject: [PATCH 17/18] last ones and delete QueryTable --- app/pages/system/silos/SiloIdpsTab.tsx | 4 +- app/pages/system/silos/SiloIpPoolsTab.tsx | 49 +++++---- app/pages/system/silos/SiloPage.tsx | 16 +-- app/table/QueryTable.tsx | 125 ---------------------- 4 files changed, 36 insertions(+), 158 deletions(-) delete mode 100644 app/table/QueryTable.tsx diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index 6b6b197a0b..1c80a95ac0 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -27,7 +27,7 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -const idpList = (silo: string) => +export const siloIdpList = (silo: string) => getListQFn('siloIdentityProviderList', { query: { silo } }) export function SiloIdpsTab() { @@ -53,7 +53,7 @@ export function SiloIdpsTab() { ) const { table } = useQueryTable({ - query: idpList(silo), + query: siloIdpList(silo), columns, emptyState: , }) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index b19a51ba88..afd8348334 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -6,11 +6,12 @@ * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' -import { useApiMutation, useApiQuery, useApiQueryClient, type SiloIpPool } from '@oxide/api' +import { getListQFn, useApiMutation, useApiQueryClient, type SiloIpPool } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { ComboboxField } from '~/components/form/fields/ComboboxField' @@ -22,7 +23,7 @@ import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable' +import { useQueryTable } from '~/table/QueryTable2' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -52,20 +53,25 @@ const staticCols = [ }), ] +const allPoolsQuery = getListQFn('ipPoolList', { query: { limit: ALL_ISH } }) + +const allSiloPoolsQuery = (silo: string) => + getListQFn('siloIpPoolList', { path: { silo }, query: { limit: ALL_ISH } }) + +// exported to call in silo page loader +export const siloIpPoolsQuery = (silo: string) => + getListQFn('siloIpPoolList', { path: { silo } }) + export function SiloIpPoolsTab() { const { silo } = useSiloSelector() const [showLinkModal, setShowLinkModal] = useState(false) - const { Table } = useQueryTable('siloIpPoolList', { path: { silo } }) const queryClient = useApiQueryClient() - // Fetch 1000 to we can be sure to get them all. There should only be a few - // anyway. Not prefetched because the prefetched one only gets 25 to match the - // query table. This req is better to do async because they can't click make - // default that fast anyway. - const { data: allPools } = useApiQuery('siloIpPoolList', { - path: { silo }, - query: { limit: ALL_ISH }, - }) + // Fetch all_ish, but there should only be a few anyway. Not prefetched + // because the prefetched one only gets 25 to match the query table. This req + // is better to do async because they can't click make default that fast + // anyway. + const { data: allPools } = useQuery(allSiloPoolsQuery(silo).optionsFn()) // used in change default confirm modal const defaultPool = useMemo( @@ -162,13 +168,18 @@ export function SiloIpPoolsTab() { ) const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: siloIpPoolsQuery(silo), + columns, + emptyState: , + }) return ( <>
setShowLinkModal(true)}>Link pool
-
} /> + {table} {showLinkModal && setShowLinkModal(false)} />} ) @@ -200,18 +211,16 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { linkPool.mutate({ path: { pool }, body: { silo, isDefault: false } }) } - const linkedPools = useApiQuery('siloIpPoolList', { - path: { silo }, - query: { limit: ALL_ISH }, - }) - const allPools = useApiQuery('ipPoolList', { query: { limit: ALL_ISH } }) + const allLinkedPools = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = useQuery(allPoolsQuery.optionsFn()) // in order to get the list of remaining unlinked pools, we have to get the // list of all pools and remove the already linked ones const linkedPoolIds = useMemo( - () => (linkedPools.data ? new Set(linkedPools.data.items.map((p) => p.id)) : undefined), - [linkedPools] + () => + allLinkedPools.data ? new Set(allLinkedPools.data.items.map((p) => p.id)) : undefined, + [allLinkedPools] ) const unlinkedPoolItems = useMemo( () => @@ -243,7 +252,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { name="pool" label="IP pool" items={unlinkedPoolItems} - isLoading={linkedPools.isPending || allPools.isPending} + isLoading={allLinkedPools.isPending || allPools.isPending} required control={control} /> diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 83b3553eea..1a546dbd3a 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -7,14 +7,13 @@ */ import { type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { apiQueryClient, queryClient, usePrefetchedApiQuery } from '@oxide/api' import { Cloud16Icon, Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { QueryParamTabs } from '~/components/QueryParamTabs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { DescriptionCell } from '~/table/cells/DescriptionCell' -import { PAGE_SIZE } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { DateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -24,8 +23,8 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { Tabs } from '~/ui/lib/Tabs' import { docLinks } from '~/util/links' -import { SiloIdpsTab } from './SiloIdpsTab' -import { SiloIpPoolsTab } from './SiloIpPoolsTab' +import { siloIdpList, SiloIdpsTab } from './SiloIdpsTab' +import { siloIpPoolsQuery, SiloIpPoolsTab } from './SiloIpPoolsTab' import { SiloQuotasTab } from './SiloQuotasTab' export async function loader({ params }: LoaderFunctionArgs) { @@ -33,13 +32,8 @@ export async function loader({ params }: LoaderFunctionArgs) { await Promise.all([ apiQueryClient.prefetchQuery('siloView', { path: { silo } }), apiQueryClient.prefetchQuery('siloUtilizationView', { path: { silo } }), - apiQueryClient.prefetchQuery('siloIdentityProviderList', { - query: { silo, limit: PAGE_SIZE }, - }), - apiQueryClient.prefetchQuery('siloIpPoolList', { - query: { limit: PAGE_SIZE }, - path: { silo }, - }), + queryClient.prefetchQuery(siloIdpList(silo).optionsFn()), + queryClient.prefetchQuery(siloIpPoolsQuery(silo).optionsFn()), ]) return null } diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx deleted file mode 100644 index c73f7a25cd..0000000000 --- a/app/table/QueryTable.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { hashKey, type UseQueryOptions } from '@tanstack/react-query' -import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useCallback, useMemo, type ComponentType } from 'react' - -import { - PAGE_SIZE, - useApiQuery, - type ApiError, - type ApiListMethods, - type Params, - type Result, - type ResultItem, -} from '@oxide/api' - -import { Pagination } from '~/components/Pagination' -import { usePagination } from '~/hooks/use-pagination' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableEmptyBox } from '~/ui/lib/Table' - -import { Table } from './Table' - -export { PAGE_SIZE } - -interface UseQueryTableResult> { - Table: ComponentType> -} -/** - * This hook builds a table that's linked to a given query. It's a combination - * of react-query and react-table. It generates a `Table` component that controls - * table level options and a `Column` component which governs the individual column - * configuration - */ -export const useQueryTable = ( - query: M, - params: Params, - options?: Omit, ApiError>, 'queryKey' | 'queryFn'> -): UseQueryTableResult> => { - const Table = useMemo( - () => makeQueryTable>(query, params, options), - // eslint-disable-next-line react-hooks/exhaustive-deps - [query, hashKey(params as any), hashKey(options as any)] - ) - - return { Table } -} - -type QueryTableProps = { - /** Prints table data in the console when enabled */ - debug?: boolean - pageSize?: number - rowHeight?: 'small' | 'large' - emptyState: React.ReactElement - columns: ColumnDef[] -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const makeQueryTable = >( - query: any, - params: any, - options: any -): ComponentType> => - function QueryTable({ - debug, - pageSize = PAGE_SIZE, - rowHeight = 'small', - emptyState, - columns, - }: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() - - const { data, isLoading } = useApiQuery( - query, - { - path: params.path, - query: { ...params.query, page_token: currentPage, limit: pageSize }, - }, - options - ) - - const tableData: any[] = useMemo(() => (data as any)?.items || [], [data]) - - const getRowId = useCallback((row: any) => row.name, []) - - const table = useReactTable({ - columns, - data: tableData, - getRowId, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }) - - if (debug) console.table((data as { items?: any[] })?.items || data) - - if (isLoading) return null - - const isEmpty = tableData.length === 0 && !hasPrev - if (isEmpty) { - return ( - {emptyState || } - ) - } - - return ( - <> -
- - - ) - } From 14205ecb38284be47bfa5aa1cda66b34af6e3e2c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 16:39:42 -0600 Subject: [PATCH 18/18] rename file back to QueryTable --- app/pages/ProjectsPage.tsx | 2 +- app/pages/project/disks/DisksPage.tsx | 2 +- app/pages/project/floating-ips/FloatingIpsPage.tsx | 2 +- app/pages/project/images/ImagesPage.tsx | 2 +- app/pages/project/instances/InstancesPage.tsx | 2 +- app/pages/project/snapshots/SnapshotsPage.tsx | 2 +- app/pages/project/vpcs/RouterPage.tsx | 2 +- app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 2 +- app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx | 2 +- app/pages/project/vpcs/VpcsPage.tsx | 2 +- app/pages/settings/SSHKeysPage.tsx | 2 +- app/pages/system/SiloImagesPage.tsx | 2 +- app/pages/system/inventory/DisksTab.tsx | 2 +- app/pages/system/inventory/SledsTab.tsx | 2 +- app/pages/system/inventory/sled/SledInstancesTab.tsx | 2 +- app/pages/system/networking/IpPoolPage.tsx | 2 +- app/pages/system/networking/IpPoolsPage.tsx | 2 +- app/pages/system/silos/SiloIdpsTab.tsx | 2 +- app/pages/system/silos/SiloIpPoolsTab.tsx | 2 +- app/pages/system/silos/SilosPage.tsx | 2 +- app/table/{QueryTable2.tsx => QueryTable.tsx} | 0 21 files changed, 20 insertions(+), 20 deletions(-) rename app/table/{QueryTable2.tsx => QueryTable.tsx} (100%) diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 6f56760c70..5b2134d401 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -24,7 +24,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 756eebb5bd..adaa7e7cce 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -30,7 +30,7 @@ import { addToast } from '~/stores/toast' import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 97bf51fe83..de198518a0 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -33,7 +33,7 @@ import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CopyableIp } from '~/ui/lib/CopyableIp' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 6f68b70262..b1dd3ecb10 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -27,7 +27,7 @@ import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 17e1ada8f5..7bdd3c7eb9 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -30,7 +30,7 @@ import { InstanceStateCell } from '~/table/cells/InstanceStateCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 828f849eb0..c73025abbd 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -27,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 { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index c893645420..33b04d1edd 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -33,7 +33,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { DateTime } from '~/ui/lib/DateTime' diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 918074cadc..a91e2aaa73 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -25,7 +25,7 @@ import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 59be2701b7..754762d774 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -25,7 +25,7 @@ import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' import { getActionsCol, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index bb065b415b..6cb7a7704a 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -30,7 +30,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 824604d6f5..9e2bd58d5a 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -24,7 +24,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index d502000a16..065fbfb561 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -30,7 +30,7 @@ import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index 3e9a3fb16f..36a442688b 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -16,7 +16,7 @@ import { } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 9a7a5346ad..d83fac68b2 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -17,7 +17,7 @@ import { import { Servers24Icon } from '@oxide/design-system/icons/react' import { makeLinkCell } from '~/table/cells/LinkCell' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' diff --git a/app/pages/system/inventory/sled/SledInstancesTab.tsx b/app/pages/system/inventory/sled/SledInstancesTab.tsx index 58476dd4e0..0f770b9378 100644 --- a/app/pages/system/inventory/sled/SledInstancesTab.tsx +++ b/app/pages/system/inventory/sled/SledInstancesTab.tsx @@ -17,7 +17,7 @@ import { requireSledParams, useSledParams } from '~/hooks/use-params' import { InstanceResourceCell } from '~/table/cells/InstanceResourceCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' const sledInstanceList = (sledId: string) => diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 8f4112b1be..2b01977ede 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -42,7 +42,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 6cf84d97c3..a90c3bd268 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -30,7 +30,7 @@ import { SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index 1c80a95ac0..2bd2e3bdc8 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -15,7 +15,7 @@ import { getListQFn, type IdentityProvider } from '~/api' import { useSiloSelector } from '~/hooks/use-params' import { LinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index afd8348334..726820a6f9 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -23,7 +23,7 @@ import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index ee40bdc1db..4cd655b264 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -27,7 +27,7 @@ import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable2' +import { useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable.tsx similarity index 100% rename from app/table/QueryTable2.tsx rename to app/table/QueryTable.tsx