diff --git a/app/api/client.ts b/app/api/client.ts index b1b6bd380..5c1d2dcd1 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -5,7 +5,11 @@ * * Copyright Oxide Computer Company */ -import { QueryClient, useQuery, type UseQueryOptions } from '@tanstack/react-query' +import { + QueryClient as QueryClientOrig, + useQuery, + type UseQueryOptions, +} from '@tanstack/react-query' import { Api } from './__generated__/Api' import { type ApiError } from './errors' @@ -49,6 +53,24 @@ export const useApiMutation = getUseApiMutation(api.methods) export const usePrefetchedQuery = (options: UseQueryOptions) => ensurePrefetched(useQuery(options), options.queryKey) +/** + * Extends React Query's `QueryClient` with a couple of API-specific methods. + * Existing methods are never modified. + */ +class QueryClient extends QueryClientOrig { + /** + * Invalidate all cached queries for a given endpoint. + * + * Note that we only take a single argument, `method`, rather than allowing + * the full query key `[query, params]` to be specified. This is to avoid + * accidentally overspecifying and therefore failing to match the desired query. + * The params argument can be added in if we ever have a use case for it. + */ + invalidateEndpoint(method: keyof typeof api.methods) { + this.invalidateQueries({ queryKey: [method] }) + } +} + // Needs to be defined here instead of in app so we can use it to define // `apiQueryClient`, which provides API-typed versions of QueryClient methods export const queryClient = new QueryClient({ diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 26fe356f9..17469321e 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -8,12 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' +import { apiq, queryClient, useApiMutation, usePrefetchedApiQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -21,19 +16,19 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import type * as PP from '~/util/path-params' import { pb } from 'app/util/path-builder' +const floatingIpView = ({ project, floatingIp }: PP.FloatingIp) => + apiq('floatingIpView', { path: { floatingIp }, query: { project } }) + EditFloatingIpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { floatingIp, project } = getFloatingIpSelector(params) - await apiQueryClient.prefetchQuery('floatingIpView', { - path: { floatingIp }, - query: { project }, - }) + const selector = getFloatingIpSelector(params) + await queryClient.prefetchQuery(floatingIpView(selector)) return null } export function EditFloatingIpSideModalForm() { - const queryClient = useApiQueryClient() const navigate = useNavigate() const floatingIpSelector = useFloatingIpSelector() @@ -47,7 +42,7 @@ export function EditFloatingIpSideModalForm() { const editFloatingIp = useApiMutation('floatingIpUpdate', { onSuccess(_floatingIp) { - queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore onDismiss() }, diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 8d44a1d8e..b6e7e2a55 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -10,10 +10,10 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { - apiQueryClient, + apiq, + queryClient, useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, + usePrefetchedQuery, type ImageCreate, } from '@oxide/api' @@ -26,6 +26,7 @@ import { getProjectSnapshotSelector, useProjectSnapshotSelector } from '~/hooks/ import { addToast } from '~/stores/toast' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' const defaultValues: Omit = { name: '', @@ -34,29 +35,25 @@ const defaultValues: Omit = { version: '', } +const snapshotView = ({ project, snapshot }: PP.Snapshot) => + apiq('snapshotView', { path: { snapshot }, query: { project } }) + CreateImageFromSnapshotSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { project, snapshot } = getProjectSnapshotSelector(params) - await apiQueryClient.prefetchQuery('snapshotView', { - path: { snapshot }, - query: { project }, - }) + await queryClient.prefetchQuery(snapshotView({ project, snapshot })) return null } export function CreateImageFromSnapshotSideModalForm() { const { snapshot, project } = useProjectSnapshotSelector() - const { data } = usePrefetchedApiQuery('snapshotView', { - path: { snapshot }, - query: { project }, - }) + const { data } = usePrefetchedQuery(snapshotView({ project, snapshot })) const navigate = useNavigate() - const queryClient = useApiQueryClient() const onDismiss = () => navigate(pb.snapshots({ project })) const createImage = useApiMutation('imageCreate', { onSuccess(image) { - queryClient.invalidateQueries('imageList') + queryClient.invalidateEndpoint('imageList') addToast(<>Image {image.name} created) // prettier-ignore onDismiss() }, diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 7af23a172..45f05da9b 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -8,12 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' +import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -22,30 +17,32 @@ import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) EditProjectSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) - await apiQueryClient.prefetchQuery('projectView', { path: { project } }) + await queryClient.prefetchQuery(projectView({ project })) return null } export function EditProjectSideModalForm() { - const queryClient = useApiQueryClient() const navigate = useNavigate() const projectSelector = useProjectSelector() const onDismiss = () => navigate(pb.projects()) - const { data: project } = usePrefetchedApiQuery('projectView', { path: projectSelector }) + const { data: project } = usePrefetchedQuery(projectView(projectSelector)) const editProject = useApiMutation('projectUpdate', { onSuccess(project) { // refetch list of projects in sidebar - // TODO: check this invalidation - queryClient.invalidateQueries('projectList') + queryClient.invalidateEndpoint('projectList') // avoid the project fetch when the project page loads since we have the data - queryClient.setQueryData('projectView', { path: { project: project.name } }, project) + const { queryKey } = projectView({ project: project.name }) + queryClient.setQueryData(queryKey, project) addToast(<>Project {project.name} updated) // prettier-ignore onDismiss() }, diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 49ab973fb..204767b38 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -9,10 +9,10 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { - apiQueryClient, + apiq, + queryClient, useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, + usePrefetchedQuery, type VpcSubnetUpdate, } from '@oxide/api' @@ -30,31 +30,29 @@ import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const subnetView = ({ project, vpc, subnet }: PP.VpcSubnet) => + apiq('vpcSubnetView', { query: { project, vpc }, path: { subnet } }) EditSubnetForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { project, vpc, subnet } = getVpcSubnetSelector(params) - await apiQueryClient.prefetchQuery('vpcSubnetView', { - query: { project, vpc }, - path: { subnet }, - }) + const selector = getVpcSubnetSelector(params) + await queryClient.prefetchQuery(subnetView(selector)) return null } export function EditSubnetForm() { - const { project, vpc, subnet: subnetName } = useVpcSubnetSelector() - const queryClient = useApiQueryClient() + const subnetSelector = useVpcSubnetSelector() + const { project, vpc } = subnetSelector const navigate = useNavigate() const onDismiss = () => navigate(pb.vpcSubnets({ project, vpc })) - const { data: subnet } = usePrefetchedApiQuery('vpcSubnetView', { - query: { project, vpc }, - path: { subnet: subnetName }, - }) + const { data: subnet } = usePrefetchedQuery(subnetView(subnetSelector)) const updateSubnet = useApiMutation('vpcSubnetUpdate', { onSuccess(subnet) { - queryClient.invalidateQueries('vpcSubnetList') + queryClient.invalidateEndpoint('vpcSubnetList') addToast(<>Subnet {subnet.name} updated) // prettier-ignore onDismiss() }, diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 0982d17f1..473eedc64 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -8,12 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' +import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -22,26 +17,26 @@ import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const vpcView = ({ project, vpc }: PP.Vpc) => + apiq('vpcView', { path: { vpc }, query: { project } }) EditVpcSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { project, vpc } = getVpcSelector(params) - await apiQueryClient.prefetchQuery('vpcView', { path: { vpc }, query: { project } }) + await queryClient.prefetchQuery(vpcView({ project, vpc })) return null } export function EditVpcSideModalForm() { const { vpc: vpcName, project } = useVpcSelector() - const queryClient = useApiQueryClient() const navigate = useNavigate() - const { data: vpc } = usePrefetchedApiQuery('vpcView', { - path: { vpc: vpcName }, - query: { project }, - }) + const { data: vpc } = usePrefetchedQuery(vpcView({ project, vpc: vpcName })) const editVpc = useApiMutation('vpcUpdate', { onSuccess(updatedVpc) { - queryClient.invalidateQueries('vpcList') + queryClient.invalidateEndpoint('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) addToast(<>VPC {updatedVpc.name} updated) // prettier-ignore @@ -51,7 +46,7 @@ export function EditVpcSideModalForm() { // page's VPC gets cleared out while we're still on the page. If we're // navigating to a different page, its query will fetch anew regardless. if (vpc.name === updatedVpc.name) { - queryClient.invalidateQueries('vpcView') + queryClient.invalidateEndpoint('vpcView') } }, }) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 134aadcf2..fb808b590 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -13,10 +13,10 @@ import { } from 'react-router-dom' import { - apiQueryClient, + apiq, + queryClient, useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, + usePrefetchedQuery, type VpcRouterUpdate, } from '@oxide/api' @@ -27,24 +27,21 @@ import { HL } from '~/components/HL' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const routerView = ({ project, vpc, router }: PP.VpcRouter) => + apiq('vpcRouterView', { path: { router }, query: { project, vpc } }) EditRouterSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { router, project, vpc } = getVpcRouterSelector(params) - await apiQueryClient.prefetchQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }) + const selector = getVpcRouterSelector(params) + await queryClient.prefetchQuery(routerView(selector)) return null } export function EditRouterSideModalForm() { - const queryClient = useApiQueryClient() const routerSelector = useVpcRouterSelector() const { project, vpc, router } = routerSelector - const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }) + const { data: routerData } = usePrefetchedQuery(routerView(routerSelector)) const navigate = useNavigate() const onDismiss = (navigate: NavigateFunction) => { @@ -53,7 +50,7 @@ export function EditRouterSideModalForm() { const editRouter = useApiMutation('vpcRouterUpdate', { onSuccess(updatedRouter) { - queryClient.invalidateQueries('vpcRouterList') + queryClient.invalidateEndpoint('vpcRouterList') addToast(<>Router {updatedRouter.name} updated) // prettier-ignore navigate(pb.vpcRouters({ project, vpc })) }, diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index 3ba21d951..979ee2854 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -8,7 +8,7 @@ import { useMemo, type ReactElement } from 'react' import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' import { Access16Icon, Folder16Icon, @@ -25,6 +25,7 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar' import { ContentPane, PageContainer } from './helpers' @@ -36,10 +37,11 @@ type ProjectLayoutProps = { overrideContentPane?: ReactElement } +const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) + ProjectLayout.loader = async ({ params }: LoaderFunctionArgs) => { - await apiQueryClient.prefetchQuery('projectView', { - path: getProjectSelector(params), - }) + const { project } = getProjectSelector(params) + await queryClient.prefetchQuery(projectView({ project })) return null } @@ -47,7 +49,7 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { const navigate = useNavigate() // project will always be there, instance may not const projectSelector = useProjectSelector() - const { data: project } = usePrefetchedApiQuery('projectView', { path: projectSelector }) + const { data: project } = usePrefetchedQuery(projectView(projectSelector)) const { pathname } = useLocation() useQuickActions( diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 5b2134d40..3357a1574 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -9,13 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router-dom' -import { - apiQueryClient, - getListQFn, - queryClient, - useApiMutation, - type Project, -} from '@oxide/api' +import { apiq, getListQFn, queryClient, useApiMutation, type Project } from '@oxide/api' import { Folder16Icon, Folder24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -64,9 +58,7 @@ export function Component() { 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. - apiQueryClient.invalidateQueries('projectList') + queryClient.invalidateEndpoint('projectList') }, }) @@ -77,11 +69,8 @@ export function Component() { onActivate: () => { // the edit view has its own loader, but we can make the modal open // instantaneously by preloading the fetch result - apiQueryClient.setQueryData( - 'projectView', - { path: { project: project.name } }, - project - ) + const { queryKey } = apiq('projectView', { path: { project: project.name } }) + queryClient.setQueryData(queryKey, project) navigate(pb.projectEdit({ project: project.name })) }, }, diff --git a/app/pages/lookups.ts b/app/pages/lookups.ts index 52413f2e4..9fa98cd2e 100644 --- a/app/pages/lookups.ts +++ b/app/pages/lookups.ts @@ -7,19 +7,19 @@ */ import { redirect, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient } from '@oxide/api' +import { apiq, queryClient } from '@oxide/api' import { trigger404 } from '~/components/ErrorBoundary' import { pb } from '~/util/path-builder' export async function instanceLookupLoader({ params }: LoaderFunctionArgs) { try { - const instance = await apiQueryClient.fetchQuery('instanceView', { - path: { instance: params.instance! }, - }) - const project = await apiQueryClient.fetchQuery('projectView', { - path: { project: instance.projectId }, - }) + const instance = await queryClient.fetchQuery( + apiq('instanceView', { path: { instance: params.instance! } }) + ) + const project = await queryClient.fetchQuery( + apiq('projectView', { path: { project: instance.projectId } }) + ) return redirect(pb.instance({ project: project.name, instance: instance.name })) } catch (_e) { throw trigger404 diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 429530f7d..c1e480e7b 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -10,13 +10,12 @@ import { useCallback } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' import { - apiQueryClient, + apiq, diskCan, genName, getListQFn, queryClient, useApiMutation, - useApiQueryClient, type Disk, } from '@oxide/api' import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' @@ -51,6 +50,8 @@ const EmptyState = () => ( /> ) +const instanceList = ({ project }: PP.Project) => + getListQFn('instanceList', { query: { project, limit: 200 } }) const diskList = (query: PP.Project) => getListQFn('diskList', { query }) DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { @@ -61,17 +62,12 @@ DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { // fetch instances and preload into RQ cache so fetches by ID in // InstanceLinkCell can be mostly instant yet gracefully fall back to // fetching individually if we don't fetch them all here - apiQueryClient - .fetchQuery('instanceList', { query: { project, limit: 200 } }) - .then((instances) => { - for (const instance of instances.items) { - apiQueryClient.setQueryData( - 'instanceView', - { path: { instance: instance.id } }, - instance - ) - } - }), + queryClient.fetchQuery(instanceList({ project }).optionsFn()).then((instances) => { + for (const instance of instances.items) { + const { queryKey } = apiq('instanceView', { path: { instance: instance.id } }) + queryClient.setQueryData(queryKey, instance) + } + }), ]) return null } @@ -98,19 +94,18 @@ const staticCols = [ ] export function DisksPage() { - const queryClient = useApiQueryClient() const { project } = useProjectSelector() const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { onSuccess(_data, variables) { - queryClient.invalidateQueries('diskList') + queryClient.invalidateEndpoint('diskList') addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { onSuccess(_data, variables) { - queryClient.invalidateQueries('snapshotList') + queryClient.invalidateEndpoint('snapshotList') addToast(<>Snapshot {variables.body.name} created) // prettier-ignore }, onError(err) { diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index de198518a..39053df5e 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -11,11 +11,10 @@ import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { - apiQueryClient, + apiq, getListQFn, queryClient, useApiMutation, - useApiQueryClient, usePrefetchedQuery, type FloatingIp, type Instance, @@ -67,15 +66,12 @@ FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { // 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 - apiQueryClient - .fetchQuery('projectIpPoolList', { query: { limit: ALL_ISH } }) + queryClient + .fetchQuery(apiq('projectIpPoolList', { query: { limit: ALL_ISH } })) .then((pools) => { for (const pool of pools.items) { - apiQueryClient.setQueryData( - 'projectIpPoolView', - { path: { pool: pool.id } }, - pool - ) + const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } }) + queryClient.setQueryData(queryKey, pool) } }), ]) @@ -102,14 +98,13 @@ const staticCols = [ export function FloatingIpsPage() { const [floatingIpToModify, setFloatingIpToModify] = useState(null) - const queryClient = useApiQueryClient() const { project } = useProjectSelector() const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { onSuccess(floatingIp) { - queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore }, onError: (err) => { @@ -118,8 +113,8 @@ export function FloatingIpsPage() { }) const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { onSuccess(_data, variables) { - queryClient.invalidateQueries('floatingIpList') - queryClient.invalidateQueries('ipPoolUtilizationView') + queryClient.invalidateEndpoint('floatingIpList') + queryClient.invalidateEndpoint('ipPoolUtilizationView') addToast(<>Floating IP {variables.path.floatingIp} deleted) // prettier-ignore }, }) @@ -171,14 +166,11 @@ export function FloatingIpsPage() { { label: 'Edit', onActivate: () => { - apiQueryClient.setQueryData( - 'floatingIpView', - { - path: { floatingIp: floatingIp.name }, - query: { project }, - }, - floatingIp - ) + const { queryKey } = apiq('floatingIpView', { + path: { floatingIp: floatingIp.name }, + query: { project }, + }) + queryClient.setQueryData(queryKey, floatingIp) navigate(pb.floatingIpEdit({ project, floatingIp: floatingIp.name })) }, }, @@ -251,10 +243,9 @@ const AttachFloatingIpModal = ({ project: string onDismiss: () => void }) => { - const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { onSuccess(floatingIp) { - queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 736475c1f..0f461f328 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -9,14 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - getListQFn, - queryClient, - useApiMutation, - useApiQueryClient, - type Image, -} from '@oxide/api' +import { getListQFn, queryClient, useApiMutation, type Image } from '@oxide/api' import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -66,7 +59,7 @@ export function ImagesPage() { const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { addToast(<>Image {variables.path.image} deleted) // prettier-ignore - apiQueryClient.invalidateQueries('imageList') + queryClient.invalidateEndpoint('imageList') }, }) @@ -139,7 +132,6 @@ type PromoteModalProps = { onDismiss: () => void; imageName: string } const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const { project } = useProjectSelector() - const queryClient = useApiQueryClient() const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { @@ -154,7 +146,7 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { link: '/images', }, }) - queryClient.invalidateQueries('imageList') + queryClient.invalidateEndpoint('imageList') }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 97adda95e..dfb6a0f04 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -8,12 +8,7 @@ import { useMemo } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' +import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' @@ -27,28 +22,27 @@ import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' import { VpcDocsPopover } from '../VpcsPage' +const vpcView = ({ project, vpc }: PP.Vpc) => + apiq('vpcView', { path: { vpc }, query: { project } }) + VpcPage.loader = async ({ params }: LoaderFunctionArgs) => { - const { project, vpc } = getVpcSelector(params) - await apiQueryClient.prefetchQuery('vpcView', { path: { vpc }, query: { project } }) + await queryClient.prefetchQuery(vpcView(getVpcSelector(params))) return null } export function VpcPage() { - const queryClient = useApiQueryClient() const navigate = useNavigate() const vpcSelector = useVpcSelector() const { project, vpc: vpcName } = vpcSelector - const { data: vpc } = usePrefetchedApiQuery('vpcView', { - path: { vpc: vpcName }, - query: { project }, - }) + const { data: vpc } = usePrefetchedQuery(vpcView(vpcSelector)) const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { onSuccess(_data, variables) { - queryClient.invalidateQueries('vpcList') + queryClient.invalidateEndpoint('vpcList') navigate(pb.vpcs({ project })) addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx index e97dcc12a..b4cbe0d2e 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx @@ -11,10 +11,10 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import * as R from 'remeda' import { - apiQueryClient, + apiq, + queryClient, useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, + usePrefetchedQuery, type VpcFirewallRule, } from '@oxide/api' @@ -32,6 +32,7 @@ import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' import { titleCase } from '~/util/str' const colHelper = createColumnHelper() @@ -97,26 +98,26 @@ const staticColumns = [ colHelper.accessor('timeCreated', Columns.timeCreated), ] +const rulesView = (query: PP.Vpc) => apiq('vpcFirewallRulesView', { query }) + VpcFirewallRulesTab.loader = async ({ params }: LoaderFunctionArgs) => { const { project, vpc } = getVpcSelector(params) - await apiQueryClient.prefetchQuery('vpcFirewallRulesView', { query: { project, vpc } }) + await queryClient.prefetchQuery(rulesView({ project, vpc })) return null } export function VpcFirewallRulesTab() { - const queryClient = useApiQueryClient() const vpcSelector = useVpcSelector() - const { data } = usePrefetchedApiQuery('vpcFirewallRulesView', { - query: vpcSelector, - }) + const { data } = usePrefetchedQuery(rulesView(vpcSelector)) + const rules = useMemo(() => R.sortBy(data.rules, (r) => r.priority), [data]) const navigate = useNavigate() const { mutateAsync: updateRules } = useApiMutation('vpcFirewallRulesUpdate', { onSuccess() { - queryClient.invalidateQueries('vpcFirewallRulesView') + queryClient.invalidateEndpoint('vpcFirewallRulesView') }, }) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 1b8282a4a..6f23956f6 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -5,19 +5,12 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { - apiQueryClient, - getListQFn, - queryClient, - useApiMutation, - useApiQuery, - useApiQueryClient, - type Vpc, -} from '@oxide/api' +import { apiq, getListQFn, queryClient, useApiMutation, type Vpc } from '@oxide/api' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -61,7 +54,7 @@ export const VpcDocsPopover = () => ( ) const FirewallRuleCount = ({ project, vpc }: PP.Vpc) => { - const { data } = useApiQuery('vpcFirewallRulesView', { query: { project, vpc } }) + const { data } = useQuery(apiq('vpcFirewallRulesView', { query: { project, vpc } })) if (!data) return // loading @@ -79,13 +72,12 @@ VpcsPage.loader = async ({ params }: LoaderFunctionArgs) => { } export function VpcsPage() { - const queryClient = useApiQueryClient() const { project } = useProjectSelector() const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { onSuccess(_data, variables) { - queryClient.invalidateQueries('vpcList') + queryClient.invalidateEndpoint('vpcList') addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) @@ -95,9 +87,8 @@ export function VpcsPage() { { label: 'Edit', onActivate() { - apiQueryClient.setQueryData( - 'vpcView', - { path: { vpc: vpc.name }, query: { project } }, + queryClient.setQueryData( + apiq('vpcView', { path: { vpc: vpc.name }, query: { project } }).queryKey, vpc ) navigate(pb.vpcEdit({ project, vpc: vpc.name }), { state: vpc })