diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e30ee4b3c7..c91a0e9a8a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -104,7 +104,7 @@ module.exports = { { // default exports are needed in the route modules and the config files, // but we want to avoid them anywhere else - files: ['app/pages/**/*', 'app/layouts/**/*', '*.config.ts'], + files: ['app/pages/**/*', 'app/layouts/**/*', 'app/forms/**/*', '*.config.ts'], rules: { 'import/no-default-export': 'off' }, }, { diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index df1b74642e..6aeacbee67 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -19,6 +19,7 @@ import { import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' @@ -27,6 +28,8 @@ import { pb } from '~/util/path-builder' import { CommonFields } from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' +export const handle = titleCrumb('New Rule') + /** Empty form for when we're not creating from an existing rule */ const defaultValuesEmpty: FirewallRuleValues = { enabled: true, @@ -55,7 +58,7 @@ const ruleToValues = (rule: VpcFirewallRule): FirewallRuleValues => ({ hosts: rule.filters.hosts || [], }) -CreateFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) await Promise.all([ apiQueryClient.prefetchQuery('vpcFirewallRulesView', { query: { project, vpc } }), @@ -69,7 +72,7 @@ CreateFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { return null } -export function CreateFirewallRuleForm() { +export default function CreateFirewallRuleForm() { const vpcSelector = useVpcSelector() const queryClient = useApiQueryClient() diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 89d4310a2f..c2100375d9 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -19,6 +19,7 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { getFirewallRuleSelector, useFirewallRuleSelector, @@ -32,7 +33,9 @@ import { pb } from '~/util/path-builder' import { CommonFields } from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' -EditFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { +export const handle = titleCrumb('Edit Rule') + +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc, rule } = getFirewallRuleSelector(params) const [firewallRules] = await Promise.all([ @@ -50,7 +53,7 @@ EditFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { return null } -export function EditFirewallRuleForm() { +export default function EditFirewallRuleForm() { const { project, vpc, rule } = useFirewallRuleSelector() const vpcSelector = useVpcSelector() const queryClient = useApiQueryClient() diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 6ba7d385f3..11e55b7e0a 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -24,6 +24,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -36,7 +37,9 @@ const defaultValues: Omit = { pool: undefined, } -export function CreateFloatingIpSideModalForm() { +export const handle = titleCrumb('New Floating IP') + +export default function CreateFloatingIpSideModalForm() { // Fetch 1000 to we can be sure to get them all. Don't bother prefetching // because the list is hidden under the Advanced accordion. const { data: allPools } = useApiQuery('projectIpPoolList', { query: { limit: ALL_ISH } }) diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index c1de6fae11..46465edf7f 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -20,6 +20,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' @@ -35,7 +36,7 @@ const floatingIpView = ({ project, floatingIp }: PP.FloatingIp) => const instanceList = (project: string) => getListQFn('instanceList', { query: { project, limit: ALL_ISH } }) -EditFloatingIpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getFloatingIpSelector(params) await Promise.all([ queryClient.fetchQuery(floatingIpView(selector)), @@ -44,7 +45,9 @@ EditFloatingIpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { return null } -export function EditFloatingIpSideModalForm() { +export const handle = titleCrumb('Edit Floating IP') + +export default function EditFloatingIpSideModalForm() { const navigate = useNavigate() const floatingIpSelector = useFloatingIpSelector() diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 806ff8cad1..04d230abfa 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -17,6 +17,7 @@ import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Checkbox } from '~/ui/lib/Checkbox' @@ -50,7 +51,9 @@ const defaultValues: IdpCreateFormValues = { }, } -export function CreateIdpSideModalForm() { +export const handle = titleCrumb('New Identity Provider') + +export default function CreateIdpSideModalForm() { const navigate = useNavigate() const queryClient = useApiQueryClient() diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index 1b7700c13d..1afdc84263 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -15,13 +15,14 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' import { getIdpSelector, useIdpSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' import { pb } from '~/util/path-builder' -EditIdpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo, provider } = getIdpSelector(params) await apiQueryClient.prefetchQuery('samlIdentityProviderView', { path: { provider }, @@ -30,7 +31,9 @@ EditIdpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { return null } -export function EditIdpSideModalForm() { +export const handle = titleCrumb('Edit Identity Provider') + +export default function EditIdpSideModalForm() { const { silo, provider } = useIdpSelector() const { data: idp } = usePrefetchedApiQuery('samlIdentityProviderView', { path: { provider }, diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index ec0fb526e1..2c5f08035c 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -157,7 +157,7 @@ const baseDefaultValues: InstanceCreateInput = { externalIps: [{ type: 'ephemeral' }], } -CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ // fetch both project and silo images @@ -173,7 +173,9 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { return null } -export function CreateInstanceForm() { +export const handle = { crumb: 'New instance' } + +export default function CreateInstanceForm() { const [isSubmitting, setIsSubmitting] = useState(false) const queryClient = useApiQueryClient() const { project } = useProjectSelector() diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 474e640060..95da46ec0a 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -20,6 +20,7 @@ import { TextField } from '~/components/form/fields/TextField' import { TlsCertsField } from '~/components/form/fields/TlsCertsField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -48,7 +49,9 @@ const defaultValues: SiloCreateFormValues = { }, } -export function CreateSiloSideModalForm() { +export const handle = titleCrumb('New Silo') + +export default function CreateSiloSideModalForm() { const navigate = useNavigate() const queryClient = useApiQueryClient() diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx index 15bed8b74c..71d24fff51 100644 --- a/app/forms/ssh-key-edit.tsx +++ b/app/forms/ssh-key-edit.tsx @@ -15,19 +15,22 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' import { getSshKeySelector, useSshKeySelector } from '~/hooks/use-params' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' import { pb } from '~/util/path-builder' -EditSSHKeySideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { sshKey } = getSshKeySelector(params) await apiQueryClient.prefetchQuery('currentUserSshKeyView', { path: { sshKey } }) return null } -export function EditSSHKeySideModalForm() { +export const handle = titleCrumb('View SSH Key') + +export default function EditSSHKeySideModalForm() { const navigate = useNavigate() const { sshKey } = useSshKeySelector() diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 248feec0b2..01561bd987 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -14,21 +14,24 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' 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' +export const handle = titleCrumb('Edit VPC') + const vpcView = ({ project, vpc }: PP.Vpc) => apiq('vpcView', { path: { vpc }, query: { project } }) -EditVpcSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) await queryClient.prefetchQuery(vpcView({ project, vpc })) return null } -export function EditVpcSideModalForm() { +export default function EditVpcSideModalForm() { const { vpc: vpcName, project } = useVpcSelector() const navigate = useNavigate() diff --git a/app/layouts/AuthLayout.tsx b/app/layouts/AuthLayout.tsx index 7a4f73b132..98cf88fe25 100644 --- a/app/layouts/AuthLayout.tsx +++ b/app/layouts/AuthLayout.tsx @@ -9,17 +9,19 @@ import { Outlet } from 'react-router' import { OxideLogo } from '~/components/OxideLogo' -export const AuthLayout = () => ( -
- -
- -
-
-) +export default function AuthLayout() { + return ( +
+ +
+ +
+
+ ) +} diff --git a/app/layouts/LoginLayout.tsx b/app/layouts/LoginLayout.tsx index a530dfcac8..47bcfe8f6f 100644 --- a/app/layouts/LoginLayout.tsx +++ b/app/layouts/LoginLayout.tsx @@ -10,7 +10,7 @@ import { Outlet } from 'react-router' import heroRackImg from '~/assets/oxide-hero-rack.webp' import { OxideLogo } from '~/components/OxideLogo' -export function LoginLayout() { +export default function LoginLayout() { return (
diff --git a/app/layouts/SettingsLayout.tsx b/app/layouts/SettingsLayout.tsx index d860ca26b4..a93076c8ef 100644 --- a/app/layouts/SettingsLayout.tsx +++ b/app/layouts/SettingsLayout.tsx @@ -11,6 +11,7 @@ import { useLocation, useNavigate } from 'react-router' import { Folder16Icon, Key16Icon, Profile16Icon } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' +import { makeCrumb } from '~/hooks/use-crumbs' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -18,7 +19,9 @@ import { pb } from '~/util/path-builder' import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar' import { ContentPane, PageContainer } from './helpers' -export function SettingsLayout() { +export const handle = makeCrumb('Settings', pb.profile()) + +export default function SettingsLayout() { const navigate = useNavigate() const { pathname } = useLocation() diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 1a3ff5e0ac..166cb67616 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -24,7 +24,7 @@ import { pb } from '~/util/path-builder' import { ContentPane, PageContainer } from './helpers' -export function SiloLayout() { +export default function SiloLayout() { const navigate = useNavigate() const { pathname } = useLocation() const { me } = useCurrentUser() diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index ae8909e61a..c85680d7ba 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -34,7 +34,7 @@ import { ContentPane, PageContainer } from './helpers' * error. We're being a little cavalier here with the error. If it's something * other than a 403, that would be strange and we would want to know. */ -export async function loader() { +export async function clientLoader() { // we don't need to use the ErrorsAllowed version here because we're 404ing // immediately on error, so we don't need to pick the result up from the cache const isFleetViewer = await apiQueryClient @@ -49,8 +49,7 @@ export async function loader() { return null } -Component.displayName = 'SystemLayout' -export function Component() { +export default function SystemLayout() { // Only show silo picker if we are looking at a particular silo. The more // robust way of doing this would be to make a separate layout for the // silo-specific routes in the route config, but it's overkill considering diff --git a/app/pages/DeviceAuthSuccessPage.tsx b/app/pages/DeviceAuthSuccessPage.tsx index 4fcc84fcb6..ca54d99353 100644 --- a/app/pages/DeviceAuthSuccessPage.tsx +++ b/app/pages/DeviceAuthSuccessPage.tsx @@ -10,7 +10,7 @@ import { Success12Icon } from '@oxide/design-system/icons/react' /** * Device authorization success page */ -export function DeviceAuthSuccessPage() { +export default function DeviceAuthSuccessPage() { return (
diff --git a/app/pages/DeviceAuthVerifyPage.tsx b/app/pages/DeviceAuthVerifyPage.tsx index 6638deb2f4..e023c5f170 100644 --- a/app/pages/DeviceAuthVerifyPage.tsx +++ b/app/pages/DeviceAuthVerifyPage.tsx @@ -14,25 +14,14 @@ import { Warning12Icon } from '@oxide/design-system/icons/react' import { AuthCodeInput } from '~/ui/lib/AuthCodeInput' import { Button } from '~/ui/lib/Button' import { pb } from '~/util/path-builder' +import { addDashes } from '~/util/str' const DASH_AFTER_IDXS = [3] -// nexus wants the dash. we plan on changing that so it doesn't care -export function addDashes(dashAfterIdxs: number[], code: string) { - let result = '' - for (let i = 0; i < code.length; i++) { - result += code[i] - if (dashAfterIdxs.includes(i)) { - result += '-' - } - } - return result -} - /** * Device authorization verification page */ -export function DeviceAuthVerifyPage() { +export default function DeviceAuthVerifyPage() { const navigate = useNavigate() const confirmPost = useApiMutation('deviceAuthConfirm', { onSuccess: () => { diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index 3c08591371..899c8f2531 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -24,7 +24,7 @@ const defaultValues: UsernamePasswordCredentials = { } /** Username/password form for local silo login */ -export function LoginPage() { +export default function LoginPage() { const [searchParams] = useSearchParams() const navigate = useNavigate() const { silo } = useSiloSelector() diff --git a/app/pages/LoginPageSaml.tsx b/app/pages/LoginPageSaml.tsx index 6228788460..0b4c4cb3c8 100644 --- a/app/pages/LoginPageSaml.tsx +++ b/app/pages/LoginPageSaml.tsx @@ -13,7 +13,7 @@ import { buttonStyle } from '~/ui/lib/Button' import { Identicon } from '~/ui/lib/Identicon' /** SAML "login page" that just links to the actual IdP */ -export function LoginPageSaml() { +export default function LoginPageSaml() { const [searchParams] = useSearchParams() const { silo, provider } = useIdpSelector() diff --git a/app/pages/add-dashes.spec.ts b/app/pages/add-dashes.spec.ts deleted file mode 100644 index ca261fd87b..0000000000 --- a/app/pages/add-dashes.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { expect, test } from 'vitest' - -import { addDashes } from './DeviceAuthVerifyPage' - -test('addDashes', () => { - expect(addDashes([], 'abcdefgh')).toEqual('abcdefgh') - expect(addDashes([3], 'abcdefgh')).toEqual('abcd-efgh') - expect(addDashes([2, 5], 'abcdefgh')).toEqual('abc-def-gh') - // too-high idxs are ignored - expect(addDashes([7], 'abcd')).toEqual('abcd') -}) diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 50ef40c562..a7c5f4d4b2 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -24,6 +24,7 @@ import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react import { DocsPopover } from '~/components/DocsPopover' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' @@ -58,7 +59,11 @@ const fipList = (project: string) => getListQFn('floatingIpList', { query: { pro const instanceList = (project: string) => getListQFn('instanceList', { query: { project, limit: ALL_ISH } }) -FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { +export const handle = makeCrumb('Floating IPs', (p) => + pb.floatingIps(getProjectSelector(p)) +) + +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ queryClient.fetchQuery(fipList(project).optionsFn()), @@ -96,7 +101,7 @@ const staticCols = [ }), ] -export function FloatingIpsPage() { +export default function FloatingIpsPage() { const [floatingIpToModify, setFloatingIpToModify] = useState(null) const { project } = useProjectSelector() const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 17a18d62c8..5babc9ddc0 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -63,7 +63,7 @@ const instanceList = ( options?: Pick, 'refetchInterval'> ) => getListQFn('instanceList', { query: { project } }, options) -export async function loader({ params }: LoaderFunctionArgs) { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await queryClient.prefetchQuery(instanceList(project).optionsFn()) return null @@ -77,8 +77,7 @@ const POLL_FAST_TIMEOUT = 30 * sec const POLL_INTERVAL_FAST = 3 * sec const POLL_INTERVAL_SLOW = 60 * sec -Component.displayName = 'InstancesPage' -export function Component() { +export default function InstancesPage() { const { project } = useProjectSelector() const [resizeInstance, setResizeInstance] = useState(null) diff --git a/app/pages/settings/ProfilePage.tsx b/app/pages/settings/ProfilePage.tsx index da8bbeb95b..4e85e5febb 100644 --- a/app/pages/settings/ProfilePage.tsx +++ b/app/pages/settings/ProfilePage.tsx @@ -26,7 +26,9 @@ const columns = [ getActionsCol((_row: Group) => []), ] -export function ProfilePage() { +export const handle = { crumb: 'Profile' } + +export default function ProfilePage() { const { me, myGroups } = useCurrentUser() const groupsTable = useReactTable({ diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index b3a89545c1..e3a2fd26db 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -20,6 +20,7 @@ import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -34,15 +35,16 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' const sshKeyList = () => getListQFn('currentUserSshKeyList', {}) -export async function loader() { +export const handle = makeCrumb('SSH Keys', pb.sshKeys) + +export async function clientLoader() { await queryClient.prefetchQuery(sshKeyList().optionsFn()) return null } const colHelper = createColumnHelper() -Component.displayName = 'SSHKeysPage' -export function Component() { +export default function SSHKeysPage() { const navigate = useNavigate() const queryClient = useApiQueryClient() diff --git a/app/routes.tsx b/app/routes.tsx index ecbbb2b691..8f46713ff6 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -15,47 +15,27 @@ import { import { NotFound } from './components/ErrorPage' import { CreateDiskSideModalForm } from './forms/disk-create' -import { CreateFirewallRuleForm } from './forms/firewall-rules-create' -import { EditFirewallRuleForm } from './forms/firewall-rules-edit' -import { CreateFloatingIpSideModalForm } from './forms/floating-ip-create' -import { EditFloatingIpSideModalForm } from './forms/floating-ip-edit' -import { CreateIdpSideModalForm } from './forms/idp/create' -import { EditIdpSideModalForm } from './forms/idp/edit' import { ProjectImageEdit, SiloImageEdit } from './forms/image-edit' import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import * as ImageCreate from './forms/image-upload' -import { CreateInstanceForm } from './forms/instance-create' import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' import * as IpPoolEdit from './forms/ip-pool-edit' import * as IpPoolAddRange from './forms/ip-pool-range-add' import * as ProjectCreate from './forms/project-create' import { EditProjectSideModalForm } from './forms/project-edit' -import { CreateSiloSideModalForm } from './forms/silo-create' import * as SnapshotCreate from './forms/snapshot-create' import * as SSHKeyCreate from './forms/ssh-key-create' -import { EditSSHKeySideModalForm } from './forms/ssh-key-edit' import { CreateSubnetForm } from './forms/subnet-create' import { EditSubnetForm } from './forms/subnet-edit' import { CreateVpcSideModalForm } from './forms/vpc-create' -import { EditVpcSideModalForm } from './forms/vpc-edit' import * as RouterCreate from './forms/vpc-router-create' import { EditRouterSideModalForm } from './forms/vpc-router-edit' import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' import { makeCrumb, titleCrumb, type Crumb } from './hooks/use-crumbs' import { getInstanceSelector, getProjectSelector, getVpcSelector } from './hooks/use-params' -import { AuthLayout } from './layouts/AuthLayout' -import { LoginLayout } from './layouts/LoginLayout' -import { SettingsLayout } from './layouts/SettingsLayout' -import { SiloLayout } from './layouts/SiloLayout' -import * as SystemLayout from './layouts/SystemLayout' -import { DeviceAuthSuccessPage } from './pages/DeviceAuthSuccessPage' -import { DeviceAuthVerifyPage } from './pages/DeviceAuthVerifyPage' -import { LoginPage } from './pages/LoginPage' -import { LoginPageSaml } from './pages/LoginPageSaml' import { instanceLookupLoader } from './pages/lookups' import * as ProjectAccess from './pages/project/access/ProjectAccessPage' -import { FloatingIpsPage } from './pages/project/floating-ips/FloatingIpsPage' import { ImagesPage } from './pages/project/images/ImagesPage' import { InstancePage } from './pages/project/instances/instance/InstancePage' import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' @@ -72,8 +52,6 @@ import * as VpcSubnetsTab from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab' import { VpcPage } from './pages/project/vpcs/VpcPage/VpcPage' import { VpcsPage } from './pages/project/vpcs/VpcsPage' import * as Projects from './pages/ProjectsPage' -import { ProfilePage } from './pages/settings/ProfilePage' -import * as SSHKeysPage from './pages/settings/SSHKeysPage' import * as SiloAccess from './pages/SiloAccessPage' import * as DisksTab from './pages/system/inventory/DisksTab' import { InventoryPage } from './pages/system/inventory/InventoryPage' @@ -91,7 +69,7 @@ import { pb } from './util/path-builder' type RouteModule = { // eslint-disable-next-line @typescript-eslint/no-explicit-any clientLoader?: (a: LoaderFunctionArgs) => Promise - default: () => ReactElement + default: () => ReactElement | null shouldRevalidate?: () => boolean ErrorBoundary?: () => ReactElement handle?: Crumb @@ -109,51 +87,64 @@ function convert(m: RouteModule) { export const routes = createRoutesFromElements( import('./layouts/RootLayout').then(convert)}> } /> - }> - } /> - } /> + import('./layouts/LoginLayout.tsx').then(convert)}> + import('./pages/LoginPage').then(convert)} + /> + import('./pages/LoginPageSaml').then(convert)} + /> - }> - } /> - } /> + import('./layouts/AuthLayout').then(convert)}> + import('./pages/DeviceAuthVerifyPage').then(convert)} + /> + import('./pages/DeviceAuthSuccessPage').then(convert)} + /> {/* This wraps all routes that are supposed to be authenticated */} import('./layouts/AuthenticatedLayout').then(convert)}> - } - > + import('./layouts/SettingsLayout').then(convert)}> } /> - } handle={{ crumb: 'Profile' }} /> - + import('./pages/settings/ProfilePage').then(convert)} + /> + import('./pages/settings/SSHKeysPage').then(convert)}> } - handle={titleCrumb('View SSH Key')} + lazy={() => import('./forms/ssh-key-edit').then(convert)} /> - + import('./layouts/SystemLayout').then(convert)}> - } /> + import('./forms/silo-create').then(convert)} + /> p.silo!)}> - } /> + import('./forms/idp/create').then(convert)} + /> } - loader={EditIdpSideModalForm.loader} - handle={titleCrumb('Edit Identity Provider')} + lazy={() => import('./forms/idp/edit').then(convert)} /> @@ -209,7 +200,7 @@ export const routes = createRoutesFromElements( } /> - }> + import('./layouts/SiloLayout').then(convert)}> @@ -272,12 +263,13 @@ export const routes = createRoutesFromElements( } /> } - loader={CreateInstanceForm.loader} - handle={{ crumb: 'New instance' }} + lazy={() => import('./forms/instance-create').then(convert)} /> - import('./pages/project/instances/InstancesPage')} /> + import('./pages/project/instances/InstancesPage').then(convert)} + /> } - loader={EditVpcSideModalForm.loader} - handle={{ crumb: 'Edit VPC' }} + lazy={() => import('./forms/vpc-edit').then(convert)} /> } - loader={CreateFirewallRuleForm.loader} - handle={titleCrumb('New Rule')} + lazy={() => import('./forms/firewall-rules-create').then(convert)} /> } - loader={EditFirewallRuleForm.loader} - handle={titleCrumb('Edit Rule')} + lazy={() => import('./forms/firewall-rules-edit').then(convert)} /> @@ -458,21 +444,18 @@ export const routes = createRoutesFromElements( } - loader={FloatingIpsPage.loader} - handle={makeCrumb('Floating IPs', (p) => pb.floatingIps(getProjectSelector(p)))} + lazy={() => + import('./pages/project/floating-ips/FloatingIpsPage').then(convert) + } > } - handle={titleCrumb('New Floating IP')} + lazy={() => import('./forms/floating-ip-create').then(convert)} /> } - loader={EditFloatingIpSideModalForm.loader} - handle={titleCrumb('Edit Floating IP')} + lazy={() => import('./forms/floating-ip-edit').then(convert)} /> import('./pages/project/disks/DisksPage').then(convert)}> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 7d384a741d..9ef4d59ea8 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -726,10 +726,6 @@ exports[`breadcrumbs 2`] = ` "label": "v", "path": "/projects/p/vpcs/v/firewall-rules", }, - { - "label": "Edit VPC", - "path": "/projects/p/vpcs/v/edit", - }, ], "vpcFirewallRuleClone (/projects/p/vpcs/v/firewall-rules-new/fr)": [ { diff --git a/app/util/str.spec.tsx b/app/util/str.spec.tsx index 8b774bc9a3..52ee240e2e 100644 --- a/app/util/str.spec.tsx +++ b/app/util/str.spec.tsx @@ -5,9 +5,10 @@ * * Copyright Oxide Computer Company */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, test } from 'vitest' import { + addDashes, camelCase, capitalize, commaSeries, @@ -158,3 +159,11 @@ describe('normalizeName', () => { expect(normalizeName('a--b')).toBe('a--b') }) }) + +test('addDashes', () => { + expect(addDashes([], 'abcdefgh')).toEqual('abcdefgh') + expect(addDashes([3], 'abcdefgh')).toEqual('abcd-efgh') + expect(addDashes([2, 5], 'abcdefgh')).toEqual('abc-def-gh') + // too-high idxs are ignored + expect(addDashes([7], 'abcd')).toEqual('abcd') +}) diff --git a/app/util/str.ts b/app/util/str.ts index 1e5b5f6843..5e6b4ac1ad 100644 --- a/app/util/str.ts +++ b/app/util/str.ts @@ -93,3 +93,15 @@ export const extractText = (children: React.ReactNode): string => .join(' ') .trim() .replace(/\s+/g, ' ') + +// nexus wants the dash. we plan on changing that so it doesn't care +export function addDashes(dashAfterIdxs: number[], code: string) { + let result = '' + for (let i = 0; i < code.length; i++) { + result += code[i] + if (dashAfterIdxs.includes(i)) { + result += '-' + } + } + return result +} diff --git a/test/unit/setup.ts b/test/unit/setup.ts index a29642efb7..482166d250 100644 --- a/test/unit/setup.ts +++ b/test/unit/setup.ts @@ -18,6 +18,10 @@ import { afterAll, afterEach, beforeAll } from 'vitest' import { resetDb } from '../../mock-api/msw/db' import { server } from './server' +// xterm calls this when it's imported, so defining it here suppresses +// an error that the method is not implemented +HTMLCanvasElement.prototype.getContext = () => null + beforeAll(() => server.listen()) afterEach(() => { resetDb()