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"