From 175d75b71a8304666f9cf0127ad72837833056fa Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 22 Nov 2024 18:12:11 -0600 Subject: [PATCH] refactor: Convert all remaining tables to new useQueryTable (#2575) * redo useQueryTable so that the types are legit and it returns the data * fix junky getRowId and use new QueryTable on sleds and physical disks * get wild with a list-specific helper to make the call sites clean * encapsulate pageSize in the query config so it is defined in one place * do the placeholderData thing for all lists * scroll to top when page changes * loading spinner on page changes! * fix the pagination test, test lovely new scroll reset logic * fix other e2es, don't scroll reset on browser forward/back * fix bug found while converting other tables, extract useScrollReset * move columns up * convert instance list to new QueryTable, fix polling bug * convert the rest of the tables * convert a few more * a hard one: IpPoolPage, have to handle rows with no ID field * last few easy ones * last ones and delete QueryTable * rename file back to QueryTable --- 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/instances/InstancesPage.tsx | 2 +- app/pages/project/snapshots/SnapshotsPage.tsx | 2 +- app/pages/project/vpcs/RouterPage.tsx | 38 ++-- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 27 ++- .../vpcs/VpcPage/tabs/VpcSubnetsTab.tsx | 24 ++- app/pages/project/vpcs/VpcsPage.tsx | 48 +++-- app/pages/settings/SSHKeysPage.tsx | 20 +- app/pages/system/SiloImagesPage.tsx | 15 +- app/pages/system/inventory/DisksTab.tsx | 2 +- app/pages/system/inventory/InventoryPage.tsx | 10 +- app/pages/system/inventory/SledsTab.tsx | 2 +- .../inventory/sled/SledInstancesTab.tsx | 27 ++- app/pages/system/networking/IpPoolPage.tsx | 39 ++-- app/pages/system/networking/IpPoolsPage.tsx | 23 +- app/pages/system/silos/SiloIdpsTab.tsx | 19 +- app/pages/system/silos/SiloIpPoolsTab.tsx | 47 +++-- app/pages/system/silos/SiloPage.tsx | 16 +- app/pages/system/silos/SilosPage.tsx | 30 +-- app/table/QueryTable.tsx | 198 +++++++++--------- app/table/QueryTable2.tsx | 103 --------- 26 files changed, 394 insertions(+), 414 deletions(-) delete mode 100644 app/table/QueryTable2.tsx 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..5b2134d401 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/QueryTable' 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..adaa7e7cce 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/QueryTable' 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..de198518a0 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/QueryTable' 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/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 b1aae9edce..33b04d1edd 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/QueryTable' 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} ) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 4f1bc835c7..a91e2aaa73 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/QueryTable' 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..754762d774 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/QueryTable' 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/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 69df4371e3..6cb7a7704a 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/QueryTable' 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..9e2bd58d5a 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/QueryTable' 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/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 27dacff831..065fbfb561 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/QueryTable' 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/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/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 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 8a14da3466..0f770b9378 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/QueryTable' 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/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 26f106b8c3..2b01977ede 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/QueryTable' 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/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index a77be45173..a90c3bd268 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/QueryTable' 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..2bd2e3bdc8 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -11,7 +11,7 @@ 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' @@ -27,14 +27,13 @@ const EmptyState = () => ( const colHelper = createColumnHelper() +export const siloIdpList = (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: siloIdpList(silo), + columns, + emptyState: , + }) + return ( <>
New provider
-
} columns={staticCols} /> + {table} ) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index b19a51ba88..726820a6f9 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' @@ -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/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index f68655f4f2..4cd655b264 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/QueryTable' 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} ) diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index c73f7a25cd..74207dab72 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -5,21 +5,11 @@ * * Copyright Oxide Computer Company */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { hashKey, type UseQueryOptions } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useCallback, useMemo, type ComponentType } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' -import { - PAGE_SIZE, - useApiQuery, - type ApiError, - type ApiListMethods, - type Params, - type Result, - type ResultItem, -} from '@oxide/api' +import { ensurePrefetched, type PaginatedQuery, type ResultsPage } from '@oxide/api' import { Pagination } from '~/components/Pagination' import { usePagination } from '~/hooks/use-pagination' @@ -28,98 +18,104 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { Table } from './Table' -export { PAGE_SIZE } +type QueryTableProps = { + query: PaginatedQuery> + 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[] + // 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 + }) -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 + * Reset scroll to top when clicking * next/prev to change page but not, + * for example, on initial pageload after browser forward/back. */ -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[] +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 + } } -// 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, +// require ID only so we can use it in getRowId +export function useQueryTable({ + query, + rowHeight = 'small', + emptyState, + columns, + getId, +}: QueryTableProps) { + const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() + 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) + 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 first = tableData.at(0) + const requestScrollReset = useScrollReset(first ? getRowId(first) : undefined) + + const table = useReactTable({ 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 || } - ) - } + data: tableData, + getRowId, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + const isEmpty = tableData.length === 0 && !hasPrev + + const tableElement = isEmpty ? ( + {emptyState || } + ) : ( + <> +
+ { + requestScrollReset() + goToNextPage(p) + }} + onPrev={() => { + requestScrollReset() + 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} + /> + + ) - return ( - <> -
- - - ) - } + return { table: tableElement, query: queryResult } +} diff --git a/app/table/QueryTable2.tsx b/app/table/QueryTable2.tsx deleted file mode 100644 index 55dd7b623b..0000000000 --- a/app/table/QueryTable2.tsx +++ /dev/null @@ -1,103 +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 - */ -import { useQuery } from '@tanstack/react-query' -import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useEffect, useMemo, useRef } from 'react' - -import { ensurePrefetched, type PaginatedQuery, type ResultsPage } 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' - -type QueryTableProps = { - query: PaginatedQuery> - 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[] -} - -/** - * 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, - rowHeight = 'small', - emptyState, - columns, -}: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() - 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) - const { data, isPlaceholderData } = queryResult - const tableData = useMemo(() => data?.items || [], [data]) - - // 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, - data: tableData, - getRowId: (row) => row.id, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }) - - const isEmpty = tableData.length === 0 && !hasPrev - - const tableElement = isEmpty ? ( - {emptyState || } - ) : ( - <> -
- { - requestScrollReset() - goToNextPage(p) - }} - onPrev={() => { - requestScrollReset() - 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} - /> - - ) - - return { table: tableElement, query: queryResult } -}