diff --git a/app/components/Breadcrumbs.tsx b/app/components/Breadcrumbs.tsx index a4a57423ba..3b3d9ea94f 100644 --- a/app/components/Breadcrumbs.tsx +++ b/app/components/Breadcrumbs.tsx @@ -19,7 +19,8 @@ const useCrumbs = () => useMatches() .map((m) => { invariant( - !m.handle || ['string', 'function'].includes(typeof m.handle.crumb), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !m.handle || ['string', 'function'].includes(typeof (m.handle as any).crumb), `Route crumb must be a string or function if present. Check Route for ${m.pathname}.` ) return m as ValidatedMatch diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index cbe422936a..a8d6f2f905 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -26,6 +26,7 @@ export const ErrorBoundary = (props: { children: React.ReactNode }) => ( ) export function RouterDataErrorBoundary() { - const error = useRouteError() + // TODO: validate this unknown at runtime _before_ passing to ErrorFallback + const error = useRouteError() as Props['error'] return } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5ec82744fc..35bccfe912 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -5,6 +5,27 @@ import invariant from 'tiny-invariant' const err = (param: string) => `Param '${param}' not found in route. You might be rendering a component under the wrong route.` +export const requireParams = + (...requiredKeys: K[]) => + (params: Readonly>) => { + const requiredParams: { [k in K]?: string } = {} + if (process.env.NODE_ENV !== 'production') { + for (const k of requiredKeys) { + const value = params[k] + invariant(k in params && value, err(k)) + requiredParams[k] = value + } + } + return requiredParams as { readonly [k in K]: string } + } + +export const requireOrgParams = requireParams('orgName') +export const requireProjectParams = requireParams('orgName', 'projectName') +export const requireInstanceParams = requireParams('orgName', 'projectName', 'instanceName') +export const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName') + +export const useVpcParams = () => requireVpcParams(useParams()) + /** * Wrapper for RR's `useParams` that guarantees (in dev) that the specified * params are present. No keys besides those specified are present on the result @@ -13,17 +34,7 @@ const err = (param: string) => // default of never is required to prevent the highly undesirable property that if // you don't pass any arguments, the result object thinks every property is defined export function useRequiredParams(...requiredKeys: K[]) { - const params = useParams() - // same as below except we build an object with only the specified keys - const requiredParams: { [k in K]?: string } = {} - if (process.env.NODE_ENV !== 'production') { - for (const k of requiredKeys) { - const value = params[k] - invariant(k in params && value, err(k)) - requiredParams[k] = value - } - } - return requiredParams as { readonly [k in K]: string } + return requireParams(...requiredKeys)(useParams()) } /** diff --git a/app/main.tsx b/app/main.tsx index 7b2460ce9d..24250d9c6f 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -1,7 +1,8 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' import ReactDOM from 'react-dom' +import { queryClient } from '@oxide/api' import { SkipLink } from '@oxide/ui' import { ErrorBoundary } from './components/ErrorBoundary' @@ -18,16 +19,6 @@ if (process.env.SHA) { ) } -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 2000, - networkMode: 'offlineFirst', - }, - }, -}) - function render() { ReactDOM.render( diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx index 07cdd2b592..3a07efdb28 100644 --- a/app/pages/OrgAccessPage.tsx +++ b/app/pages/OrgAccessPage.tsx @@ -1,8 +1,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { + apiQueryClient, orgRoleOrder, setUserRole, useApiMutation, @@ -24,9 +26,7 @@ import { } from '@oxide/ui' import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access' -import { useRequiredParams } from 'app/hooks' - -type UserRow = UserAccessRow +import { requireOrgParams, useRequiredParams } from 'app/hooks' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -40,9 +40,19 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) +OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { + await Promise.all([ + apiQueryClient.prefetchQuery('organizationPolicyView', requireOrgParams(params)), + // used in useUserAccessRows to resolve user names + apiQueryClient.prefetchQuery('userList', { limit: 200 }), + ]) +} + +type UserRow = UserAccessRow + const colHelper = createColumnHelper() -export const OrgAccessPage = () => { +export function OrgAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const orgParams = useRequiredParams('orgName') diff --git a/app/pages/OrgsPage.tsx b/app/pages/OrgsPage.tsx index b0c143d004..058a36b6da 100644 --- a/app/pages/OrgsPage.tsx +++ b/app/pages/OrgsPage.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Organization } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiQueryClient } from '@oxide/api' import { useApiMutation, useApiQuery } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -30,11 +31,15 @@ const EmptyState = () => ( /> ) +OrgsPage.loader = async () => { + await apiQueryClient.prefetchQuery('organizationList', { limit: 10 }) +} + interface OrgsPageProps { modal?: 'createOrg' | 'editOrg' } -const OrgsPage = ({ modal }: OrgsPageProps) => { +export default function OrgsPage({ modal }: OrgsPageProps) { const navigate = useNavigate() const location = useLocation() @@ -107,5 +112,3 @@ const OrgsPage = ({ modal }: OrgsPageProps) => { ) } - -export default OrgsPage diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index ace33e74ab..f8a61507ba 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -1,7 +1,9 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Project } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { DateCell, linkCell, useQueryTable } from '@oxide/table' @@ -17,7 +19,7 @@ import { import CreateProjectSideModalForm from 'app/forms/project-create' import EditProjectSideModalForm from 'app/forms/project-edit' -import { useQuickActions, useRequiredParams } from '../hooks' +import { requireOrgParams, useQuickActions, useRequiredParams } from '../hooks' const EmptyState = () => ( ( /> ) +ProjectsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('projectList', { + ...requireOrgParams(params), + limit: 10, + }) +} + interface ProjectsPageProps { modal?: 'createProject' | 'editProject' } -const ProjectsPage = ({ modal }: ProjectsPageProps) => { +export default function ProjectsPage({ modal }: ProjectsPageProps) { const navigate = useNavigate() const location = useLocation() @@ -113,5 +122,3 @@ const ProjectsPage = ({ modal }: ProjectsPageProps) => { ) } - -export default ProjectsPage diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 26ee5336de..0ad95afe08 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -1,8 +1,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { + apiQueryClient, projectRoleOrder, setUserRole, useApiMutation, @@ -27,7 +29,7 @@ import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from 'app/forms/project-access' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -41,11 +43,19 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) +ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { + await Promise.all([ + apiQueryClient.prefetchQuery('projectPolicyView', requireProjectParams(params)), + // used in useUserAccessRows to resolve user names + apiQueryClient.prefetchQuery('userList', { limit: 200 }), + ]) +} + type UserRow = UserAccessRow const colHelper = createColumnHelper() -export const ProjectAccessPage = () => { +export function ProjectAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const projectParams = useRequiredParams('orgName', 'projectName') diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index a5fd328fa0..749340b065 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -1,6 +1,8 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom' import type { Disk } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useApiQuery } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -18,7 +20,7 @@ import { import { DiskStatusBadge } from 'app/components/StatusBadge' import CreateDiskSideModalForm from 'app/forms/disk-create' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' function AttachedInstance(props: { orgName: string @@ -50,6 +52,13 @@ interface DisksPageProps { modal?: 'createDisk' } +DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('diskList', { + ...requireProjectParams(params), + limit: 10, + }) +} + export function DisksPage({ modal }: DisksPageProps) { const navigate = useNavigate() diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index b8c4aedb52..1a40e54f25 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -1,7 +1,10 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient } from '@oxide/api' import { DateCell, SizeCell, useQueryTable } from '@oxide/table' import { EmptyMessage, Images24Icon, PageHeader, PageTitle } from '@oxide/ui' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = () => ( ( /> ) -export const ImagesPage = () => { +ImagesPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('imageList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function ImagesPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('imageList', projectParams) return ( diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index bd73698dc9..1a758b64b9 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom' -import { useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api' import { DateCell, InstanceResourceCell, @@ -18,7 +19,7 @@ import { buttonStyle, } from '@oxide/ui' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireProjectParams, useQuickActions, useRequiredParams } from 'app/hooks' import { useMakeInstanceActions } from './actions' @@ -32,7 +33,14 @@ const EmptyState = () => ( /> ) -export const InstancesPage = () => { +InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('instanceList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function InstancesPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { orgName, projectName } = projectParams diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index c231a93fe6..df8f483fa7 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -1,15 +1,16 @@ import filesize from 'filesize' import { memo, useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { useNavigate } from 'react-router-dom' -import { useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api' import { Instances24Icon, PageHeader, PageTitle, PropertiesTable, Tab } from '@oxide/ui' import { pick } from '@oxide/util' import { MoreActionsMenu } from 'app/components/MoreActionsMenu' import { InstanceStatusBadge } from 'app/components/StatusBadge' import { Tabs } from 'app/components/Tabs' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireInstanceParams, useQuickActions, useRequiredParams } from 'app/hooks' import { useMakeInstanceActions } from '../actions' import { MetricsTab } from './tabs/MetricsTab' @@ -38,7 +39,11 @@ const InstanceTabs = memo(() => ( )) -export const InstancePage = () => { +InstancePage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('instanceView', requireInstanceParams(params)) +} + +export function InstancePage() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const navigate = useNavigate() diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx index 8bcb81bff1..11b01a01ea 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.tsx +++ b/app/pages/project/networking/VpcPage/VpcPage.tsx @@ -1,10 +1,11 @@ import { format } from 'date-fns' +import type { LoaderFunctionArgs } from 'react-router-dom' -import { useApiQuery } from '@oxide/api' +import { apiQueryClient, useApiQuery } from '@oxide/api' import { Networking24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui' import { Tab, Tabs } from 'app/components/Tabs' -import { useRequiredParams } from 'app/hooks' +import { requireVpcParams, useVpcParams } from 'app/hooks' import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab' import { VpcRoutersTab } from './tabs/VpcRoutersTab' @@ -13,8 +14,12 @@ import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab' const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa') -export const VpcPage = () => { - const vpcParams = useRequiredParams('orgName', 'projectName', 'vpcName') +VpcPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('vpcView', requireVpcParams(params)) +} + +export function VpcPage() { + const vpcParams = useVpcParams() const { data: vpc } = useApiQuery('vpcView', vpcParams) return ( diff --git a/app/pages/project/networking/VpcsPage.tsx b/app/pages/project/networking/VpcsPage.tsx index 0e2be1c820..5b91cdf60c 100644 --- a/app/pages/project/networking/VpcsPage.tsx +++ b/app/pages/project/networking/VpcsPage.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import type { Vpc } from '@oxide/api' -import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' +import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { DateCell, linkCell, useQueryTable } from '@oxide/table' import { @@ -16,7 +17,7 @@ import { import CreateVpcSideModalForm from 'app/forms/vpc-create' import EditVpcSideModalForm from 'app/forms/vpc-edit' -import { useQuickActions, useRequiredParams } from 'app/hooks' +import { requireProjectParams, useQuickActions, useRequiredParams } from 'app/hooks' const EmptyState = () => ( ( /> ) +// just as in the vpcList call for the quick actions menu, include limit: 10 to make +// sure it matches the call in the QueryTable +VpcsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('vpcList', { + ...requireProjectParams(params), + limit: 10, + }) +} + interface VpcsPageProps { modal?: 'createVpc' | 'editVpc' } -export const VpcsPage = ({ modal }: VpcsPageProps) => { +export function VpcsPage({ modal }: VpcsPageProps) { const queryClient = useApiQueryClient() const { orgName, projectName } = useRequiredParams('orgName', 'projectName') const location = useLocation() diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 4ad3082a7a..9277d6ddcb 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -1,7 +1,10 @@ +import type { LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient } from '@oxide/api' import { DateCell, SizeCell, useQueryTable } from '@oxide/table' import { EmptyMessage, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' -import { useRequiredParams } from 'app/hooks' +import { requireProjectParams, useRequiredParams } from 'app/hooks' const EmptyState = () => ( ( /> ) -export const SnapshotsPage = () => { +SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('snapshotList', { + ...requireProjectParams(params), + limit: 10, + }) +} + +export function SnapshotsPage() { const projectParams = useRequiredParams('orgName', 'projectName') const { Table, Column } = useQueryTable('snapshotList', projectParams) return ( diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 52745acc50..a5a865ccce 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import type { SshKey } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiQueryClient } from '@oxide/api' import { useApiMutation } from '@oxide/api' import type { MenuAction } from '@oxide/table' @@ -16,6 +17,10 @@ import { import { CreateSSHKeySideModalForm } from 'app/forms/ssh-key-create' +SSHKeysPage.loader = async () => { + await apiQueryClient.prefetchQuery('sessionSshkeyList', { limit: 10 }) +} + export function SSHKeysPage() { const { Table, Column } = useQueryTable('sessionSshkeyList', {}) const [createModalOpen, setCreateModalOpen] = useState(false) diff --git a/app/routes.tsx b/app/routes.tsx index 9f0b5aeba7..a5353ee4aa 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -40,25 +40,51 @@ const instanceCrumb: CrumbFunc = (m) => m.params.instanceName! const vpcCrumb: CrumbFunc = (m) => m.params.vpcName! export const Router = () => ( - loading}> + } /> }> } /> + {/* TODO: prefetch sessionMe in a loader on a route wrapping all relevant pages, handle possible 401 */} }> } /> } /> + }> + } /> + } handle={{ crumb: 'Profile' }} /> + } + handle={{ crumb: 'Appearance' }} + /> + } + loader={SSHKeysPage.loader} + handle={{ crumb: 'SSH Keys' }} + /> + } handle={{ crumb: 'Hotkeys' }} /> + + } /> }> }> - } /> - } /> + } loader={OrgsPage.loader} /> + } + loader={OrgsPage.loader} + /> - } /> + } + loader={OrgsPage.loader} + /> @@ -68,16 +94,25 @@ export const Router = () => ( } + loader={OrgAccessPage.loader} handle={{ crumb: 'Access & IAM' }} /> {/* ORG */} }> - } /> - } /> + } loader={ProjectsPage.loader} /> + } + loader={ProjectsPage.loader} + /> - } /> + } + loader={ProjectsPage.loader} + /> @@ -89,10 +124,10 @@ export const Router = () => ( > } /> - } /> + } loader={InstancesPage.loader} /> } /> - } /> + } loader={InstancePage.loader} /> } @@ -101,43 +136,55 @@ export const Router = () => ( - } /> - } /> + } loader={VpcsPage.loader} /> + } + loader={VpcsPage.loader} + /> - } /> + } + loader={VpcsPage.loader} + /> - } handle={{ crumb: vpcCrumb }} /> + } + loader={VpcPage.loader} + handle={{ crumb: vpcCrumb }} + /> - } /> - } /> + } loader={DisksPage.loader} /> + } + loader={DisksPage.loader} + /> } + loader={SnapshotsPage.loader} handle={{ crumb: 'Snapshots' }} /> - } handle={{ crumb: 'Images' }} /> + } + loader={ImagesPage.loader} + handle={{ crumb: 'Images' }} + /> } + loader={ProjectAccessPage.loader} handle={{ crumb: 'Access & IAM' }} /> - - }> - } /> - } handle={{ crumb: 'Profile' }} /> - } - handle={{ crumb: 'Appearance' }} - /> - } handle={{ crumb: 'SSH Keys' }} /> - } handle={{ crumb: 'Hotkeys' }} /> - ) diff --git a/app/test/server.ts b/app/test/server.ts index ea345855e9..c8157ad254 100644 --- a/app/test/server.ts +++ b/app/test/server.ts @@ -1,7 +1,7 @@ import { rest } from 'msw' import { setupServer } from 'msw/node' -import { handlers, json } from '@oxide/api-mocks' +import { handlers } from '@oxide/api-mocks' export const server = setupServer(...handlers) @@ -15,9 +15,10 @@ export function overrideOnce( server.use( rest[method](path, (_req, res, ctx) => // https://mswjs.io/docs/api/response/once - typeof body === 'string' - ? res.once(ctx.status(status), ctx.text(body)) - : res.once(json(body, status)) + res.once( + ctx.status(status), + typeof body === 'string' ? ctx.text(body) : ctx.json(body) + ) ) ) } diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 350f5edd80..49786b8d5d 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -10,7 +10,7 @@ import { json } from './util' const notFoundBody = { error_code: 'ObjectNotFound' } as const export type NotFound = typeof notFoundBody -export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, 404) +export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, { status: 404 }) type Ok = [T, null] type LookupError = typeof notFoundErr // Lookups can only 404 diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index ad4a7f2e33..da32b2a936 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -56,11 +56,11 @@ function getTimestamps() { const alreadyExistsBody = { error_code: 'ObjectAlreadyExists' } as const type AlreadyExists = typeof alreadyExistsBody -const alreadyExistsErr = json(alreadyExistsBody, 400) +const alreadyExistsErr = json(alreadyExistsBody, { status: 400 }) const unavailableBody = { error_code: 'ServiceUnavailable' } as const type Unavailable = typeof unavailableBody -const unavailableErr = json(unavailableBody, 503) +const unavailableErr = json(unavailableBody, { status: 503 }) const badRequest = (msg: string) => compose( @@ -115,7 +115,7 @@ export const handlers = [ ...getTimestamps(), } db.sshKeys.push(newSshKey) - return res(json(newSshKey, 201)) + return res(json(newSshKey, { status: 201 })) } ), @@ -150,7 +150,7 @@ export const handlers = [ ...getTimestamps(), } db.orgs.push(newOrg) - return res(json(newOrg, 201)) + return res(json(newOrg, { status: 201 })) } ), @@ -180,7 +180,7 @@ export const handlers = [ org.name = req.body.name org.description = req.body.description || '' - return res(json(org, 200)) + return res(json(org)) } ), @@ -262,7 +262,7 @@ export const handlers = [ ...getTimestamps(), } db.projects.push(newProject) - return res(json(newProject, 201)) + return res(json(newProject, { status: 201 })) } ), @@ -287,7 +287,7 @@ export const handlers = [ project.name = req.body.name project.description = req.body.description || '' - return res(json(project, 200)) + return res(json(project)) } ), @@ -391,7 +391,7 @@ export const handlers = [ time_run_state_updated: new Date().toISOString(), } db.instances.push(newInstance) - return res(json(newInstance, 201, 2000)) + return res(json(newInstance, { status: 201, delay: 2000 })) } ), @@ -401,7 +401,7 @@ export const handlers = [ const [instance, err] = lookupInstance(req.params) if (err) return res(err) instance.run_state = 'running' - return res(json(instance, 202)) + return res(json(instance, { status: 202 })) } ), @@ -411,7 +411,7 @@ export const handlers = [ const [instance, err] = lookupInstance(req.params) if (err) return res(err) instance.run_state = 'stopped' - return res(json(instance, 202)) + return res(json(instance, { status: 202 })) } ), @@ -641,7 +641,7 @@ export const handlers = [ ...getTimestamps(), } db.disks.push(newDisk) - return res(json(newDisk, 201)) + return res(json(newDisk, { status: 201 })) } ), @@ -727,7 +727,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcs.push(newVpc) - return res(json(newVpc, 201)) + return res(json(newVpc, { status: 201 })) } ), @@ -748,7 +748,7 @@ export const handlers = [ if (req.body.dns_name) { vpc.dns_name = req.body.dns_name } - return res(json(vpc, 200)) + return res(json(vpc)) } ), @@ -788,7 +788,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcSubnets.push(newSubnet) - return res(json(newSubnet, 201)) + return res(json(newSubnet, { status: 201 })) } ), @@ -875,7 +875,7 @@ export const handlers = [ ...getTimestamps(), } db.vpcRouters.push(newRouter) - return res(json(newRouter, 201)) + return res(json(newRouter, { status: 201 })) } ), diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index b8c9a4b7b6..ddbf792612 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -2,13 +2,18 @@ import type { ResponseTransformer } from 'msw' import { compose, context } from 'msw' /** - * Custom transformer: convenience function for less typing. Equivalent to - * `res(ctx.status(status), ctx.json(body))` in a handler. + * Custom transformer: convenience function for setting response `status` and/or + * `delay`. * - * https://mswjs.io/docs/basics/response-transformer#custom-transformer + * @see https://mswjs.io/docs/basics/response-transformer#custom-transformer */ -export const json = (body: B, status = 200, delay = 0): ResponseTransformer => - compose(context.status(status), context.json(body), context.delay(delay)) +export function json( + body: B, + options: { status?: number; delay?: number } = {} +): ResponseTransformer { + const { status = 200, delay = 0 } = options + return compose(context.status(status), context.json(body), context.delay(delay)) +} export interface ResultsPage { items: I[] @@ -45,3 +50,8 @@ export const paginated = ( nextPage: `${items[startIndex + limit].id}`, } } + +// make a bunch of copies of an object with different names and IDs. useful for +// testing pagination +export const repeat = (obj: T, n: number): T[] => + new Array(n).fill(0).map((_, i) => ({ ...obj, id: obj.id + i, name: obj.name + i })) diff --git a/libs/api-mocks/vpc.ts b/libs/api-mocks/vpc.ts index cf46e14d88..b5e34b6f31 100644 --- a/libs/api-mocks/vpc.ts +++ b/libs/api-mocks/vpc.ts @@ -1,6 +1,6 @@ import type { RouterRoute } from 'libs/api/__generated__/Api' -import type { Vpc, VpcFirewallRule, VpcResultsPage, VpcRouter, VpcSubnet } from '@oxide/api' +import type { Vpc, VpcFirewallRule, VpcRouter, VpcSubnet } from '@oxide/api' import type { Json } from './json-type' import { project } from './project' @@ -20,8 +20,6 @@ export const vpc: Json = { time_modified, } -export const vpcs: Json = { items: [vpc] } - export const vpcSubnet: Json = { // this is supposed to be flattened into the top level. will fix in API id: 'vpc-subnet-id', diff --git a/libs/api/hooks.ts b/libs/api/hooks.ts index 9ca52916d9..3ddb0804e1 100644 --- a/libs/api/hooks.ts +++ b/libs/api/hooks.ts @@ -1,5 +1,7 @@ import type { + FetchQueryOptions, InvalidateQueryFilters, + QueryClient, QueryFilters, QueryKey, UseMutationOptions, @@ -84,33 +86,50 @@ export const getUseApiMutation = options ) +export const wrapQueryClient = (api: A, queryClient: QueryClient) => ({ + invalidateQueries: ( + method: M, + params?: Params, + filters?: InvalidateQueryFilters + ) => queryClient.invalidateQueries(params ? [method, params] : [method], filters), + setQueryData: (method: M, params: Params, data: Result) => + queryClient.setQueryData([method, params], data), + cancelQueries: ( + method: M, + params?: Params, + filters?: QueryFilters + ) => queryClient.cancelQueries(params ? [method, params] : [method], filters), + refetchQueries: ( + method: M, + params?: Params, + filters?: QueryFilters + ) => queryClient.refetchQueries(params ? [method, params] : [method], filters), + fetchQuery: ( + method: M, + params?: Params, + options: FetchQueryOptions, ErrorResponse> = {} + ) => + queryClient.fetchQuery({ + queryKey: [method, params], + queryFn: () => api[method](params).then((resp) => resp.data), + ...options, + }), + prefetchQuery: ( + method: M, + params?: Params, + options: FetchQueryOptions, ErrorResponse> = {} + ) => + queryClient.prefetchQuery({ + queryKey: [method, params], + queryFn: () => api[method](params).then((resp) => resp.data), + ...options, + }), +}) + export const getUseApiQueryClient = - () => - () => { - const queryClient = useQueryClient() - return { - invalidateQueries: ( - method: M, - params?: Params, - filters?: InvalidateQueryFilters - ) => queryClient.invalidateQueries(params ? [method, params] : [method], filters), - setQueryData: ( - method: M, - params: Params, - data: Result - ) => queryClient.setQueryData([method, params], data), - cancelQueries: ( - method: M, - params?: Params, - filters?: QueryFilters - ) => queryClient.cancelQueries(params ? [method, params] : [method], filters), - refetchQueries: ( - method: M, - params?: Params, - filters?: QueryFilters - ) => queryClient.refetchQueries(params ? [method, params] : [method], filters), - } - } + (api: A) => + () => + wrapQueryClient(api, useQueryClient()) /* 1. what's up with [method, params]? diff --git a/libs/api/index.ts b/libs/api/index.ts index d16a91fe6e..4198d6ef46 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -1,8 +1,15 @@ // for convenience so we can do `import type { ApiTypes } from '@oxide/api'` +import { QueryClient } from '@tanstack/react-query' + import type * as ApiTypes from './__generated__/Api' import { Api } from './__generated__/Api' import { handleErrors } from './errors' -import { getUseApiMutation, getUseApiQuery, getUseApiQueryClient } from './hooks' +import { + getUseApiMutation, + getUseApiQuery, + getUseApiQueryClient, + wrapQueryClient, +} from './hooks' const api = new Api({ baseUrl: process.env.API_URL, @@ -12,7 +19,24 @@ export type ApiMethods = typeof api.methods export const useApiQuery = getUseApiQuery(api.methods, handleErrors) export const useApiMutation = getUseApiMutation(api.methods, handleErrors) -export const useApiQueryClient = getUseApiQueryClient() + +// 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({ + defaultOptions: { + queries: { + retry: false, + staleTime: 10000, + }, + }, +}) + +// to be used in loaders, which are outside the component tree and therefore +// don't have access to context +export const apiQueryClient = wrapQueryClient(api.methods, queryClient) + +// to be used to retrieve the typed query client in components +export const useApiQueryClient = getUseApiQueryClient(api.methods) export * from './roles' export * from './util' diff --git a/package.json b/package.json index 675778e273..c4462a099e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "react-error-boundary": "^3.1.3", "react-is": "^17.0.2", "react-popper": "^2.2.5", - "react-router-dom": "^6.4.0-pre.8", + "react-router-dom": "^6.4.0-pre.11", "recharts": "^2.1.6", "tiny-invariant": "^1.2.0", "ts-dedent": "^2.2.0", diff --git a/patches/react-router+6.4.0-pre.11.patch b/patches/react-router+6.4.0-pre.11.patch new file mode 100644 index 0000000000..cb78c3f5d2 --- /dev/null +++ b/patches/react-router+6.4.0-pre.11.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-router/dist/index.js b/node_modules/react-router/dist/index.js +index 03d63dd..7970ae1 100644 +--- a/node_modules/react-router/dist/index.js ++++ b/node_modules/react-router/dist/index.js +@@ -1000,12 +1000,17 @@ function Navigate(_ref4) { + " may be used only in the context of a component.") : invariant(false) : void 0; + process.env.NODE_ENV !== "production" ? warning(!React.useContext(NavigationContext).static, " must not be used on the initial render in a . " + "This is a no-op, but you should modify your code so the is " + "only ever rendered in response to some user interaction or state change.") : void 0; + let navigate = useNavigate(); ++ // To be removed as soon as this fix is released: ++ // https://github.com/remix-run/react-router/pull/9124 ++ let isMountedRef = React.useRef(false); + React.useEffect(() => { ++ if (isMountedRef.current) return; ++ isMountedRef.current = true; + navigate(to, { + replace, + state + }); +- }); ++ }, []); + return null; + } + diff --git a/yarn.lock b/yarn.lock index f8150ba99a..633e9b2e24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2804,10 +2804,10 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" -"@remix-run/router@0.2.0-pre.3": - version "0.2.0-pre.3" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.3.tgz#2864cf275433ec39d2370a1aec28a5957060f47a" - integrity sha512-/1vRiMh8j31PBgLgB2lqcwDDEBEwsnucOouZ8GvJTRNHULkPOU3kvQ1ZUpt9IPd1lS5AVC93I9pVLnFhlNA4Mw== +"@remix-run/router@0.2.0-pre.6": + version "0.2.0-pre.6" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-0.2.0-pre.6.tgz#6a878c3f76e95b6d0860ca4bc0705d11c357f2cd" + integrity sha512-AhmScBjypbo/ZpFB/4L4TcPNvcRyGZNQVPWcCo68EaDbt1pNQFMFvyWxBebfvTgBek9LNLQNZQKvmKpCIh9MpQ== "@storybook/addon-actions@6.5.9": version "6.5.9" @@ -12939,19 +12939,19 @@ react-resize-detector@^6.6.3: lodash.throttle "^4.1.1" resize-observer-polyfill "^1.5.1" -react-router-dom@^6.4.0-pre.8: - version "6.4.0-pre.8" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.8.tgz#1f784fa09719801250070431703dd02c5fdcde6e" - integrity sha512-5hxQdiPOG98YOANbJ8IBUKbRgU6k4/TSX4VyO6jRWoG6KY0K2EG5wjEZgZ0tlAN9fXFgnHsro/HKi/r+HxugAg== +react-router-dom@^6.4.0-pre.11: + version "6.4.0-pre.11" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.0-pre.11.tgz#9f6d22d5e1a357a2ccdb451f39825e443d06f639" + integrity sha512-TTNg7QRknz/aGGmbuYdV68Cur7H9FKmVOIXYEmHlHixI3sf2/VtZnq15jXZAl0IdXEVHVa/730/NhIk4d0lvDA== dependencies: - react-router "6.4.0-pre.8" + react-router "6.4.0-pre.11" -react-router@6.4.0-pre.8: - version "6.4.0-pre.8" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.8.tgz#90ce4cc590b453fc26ad2d131f51d908783c9091" - integrity sha512-xwt2s9jW5NaakymlSRG0hZoGda6E/Skfgmj/4vlwvRX/kqoO8jU0g+Vi3sHDCatH/V+GjDyTQkdMEDm8qGsBsQ== +react-router@6.4.0-pre.11: + version "6.4.0-pre.11" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.0-pre.11.tgz#4ed8cb9619178a86caf8e65d8e921efdb831709c" + integrity sha512-y4vPA/jtLkz2doiqGqLHKpAQkwgUBZHRicSE5N8uqjZrZKUhBSHZcx4KSQK0sfTuPqOUtc7GFFyOZcGDDZSyzw== dependencies: - "@remix-run/router" "0.2.0-pre.3" + "@remix-run/router" "0.2.0-pre.6" react-smooth@^2.0.0: version "2.0.0"