From a5c2361668ed3b2a89222fe7207379488d47c53b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 16 Sep 2024 09:47:46 -0700 Subject: [PATCH] Extract common fields and refactor VPC router route forms (#2436) --- app/forms/vpc-router-route-common.tsx | 117 ++++++++++++++++++ app/forms/vpc-router-route-create.tsx | 48 ++----- app/forms/vpc-router-route-edit.tsx | 77 +++--------- app/forms/vpc-router-route/shared.tsx | 76 ------------ app/pages/project/vpcs/RouterPage.tsx | 2 +- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 2 +- 6 files changed, 141 insertions(+), 181 deletions(-) create mode 100644 app/forms/vpc-router-route-common.tsx delete mode 100644 app/forms/vpc-router-route/shared.tsx diff --git a/app/forms/vpc-router-route-common.tsx b/app/forms/vpc-router-route-common.tsx new file mode 100644 index 0000000000..069ae60f03 --- /dev/null +++ b/app/forms/vpc-router-route-common.tsx @@ -0,0 +1,117 @@ +/* + * 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 type { UseFormReturn } from 'react-hook-form' + +import type { + RouteDestination, + RouterRouteCreate, + RouterRouteUpdate, + RouteTarget, +} from '~/api' +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { NameField } from '~/components/form/fields/NameField' +import { TextField } from '~/components/form/fields/TextField' +import { Message } from '~/ui/lib/Message' + +export type RouteFormValues = RouterRouteCreate | Required + +export const routeFormMessage = { + vpcSubnetNotModifiable: + 'Routes of type VPC Subnet within the system router are not modifiable', + internetGatewayTargetValue: + 'For ‘Internet gateway’ targets, the value must be ‘outbound’', + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 + noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router', + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 + noDeletingRoutesOnSystemRouter: 'System routes cannot be deleted', + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L136-L138 + noDeletingSystemRouters: 'System routers cannot be deleted', +} + +// VPCs cannot be specified as a destination in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L363 +const destTypes: Record, string> = { + ip: 'IP', + ip_net: 'IP network', + subnet: 'Subnet', +} + +// Subnets and VPCs cannot be used as a target in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L362-L368 +const targetTypes: Record, string> = { + ip: 'IP', + instance: 'Instance', + internet_gateway: 'Internet gateway', + drop: 'Drop', +} + +const toItems = (mapping: Record) => + Object.entries(mapping).map(([value, label]) => ({ value, label })) + +type RouteFormFieldsProps = { + form: UseFormReturn + isDisabled?: boolean +} +export const RouteFormFields = ({ form, isDisabled }: RouteFormFieldsProps) => { + const { control } = form + const targetType = form.watch('target.type') + return ( + <> + {isDisabled && ( + + )} + + + + + { + form.setValue('target.value', value === 'internet_gateway' ? 'outbound' : '') + }} + disabled={isDisabled} + /> + {targetType !== 'drop' && ( + + )} + + ) +} diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 597c82ceda..d8c0d07690 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -8,19 +8,15 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' -import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api' +import { useApiMutation, useApiQueryClient } from '@oxide/api' -import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { NameField } from '~/components/form/fields/NameField' -import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { fields, targetValueDescription } from '~/forms/vpc-router-route/shared' +import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -const defaultValues: RouterRouteCreate = { +const defaultValues: RouteFormValues = { name: '', description: '', destination: { type: 'ip', value: '' }, @@ -32,27 +28,22 @@ export function CreateRouterRouteSideModalForm() { const routerSelector = useVpcRouterSelector() const navigate = useNavigate() - const onDismiss = () => { - navigate(pb.vpcRouter(routerSelector)) - } + const form = useForm({ defaultValues }) const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') addToast({ content: 'Your route has been created' }) - onDismiss() + navigate(pb.vpcRouter(routerSelector)) }, }) - const form = useForm({ defaultValues }) - const targetType = form.watch('target.type') - return ( navigate(pb.vpcRouter(routerSelector))} onSubmit={({ name, description, destination, target }) => createRouterRoute.mutate({ query: routerSelector, @@ -68,32 +59,7 @@ export function CreateRouterRouteSideModalForm() { loading={createRouterRoute.isPending} submitError={createRouterRoute.error} > - - - - - { - // 'outbound' is only valid option when targetType is 'internet_gateway' - if (value === 'internet_gateway') { - form.setValue('target.value', 'outbound') - } - if (value === 'drop') { - form.setValue('target.value', '') - } - }} - /> - {targetType !== 'drop' && ( - - )} + ) } diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index bfe52466bb..0399a02d4c 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -14,84 +14,63 @@ import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery, - type RouterRouteUpdate, } from '@oxide/api' -import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { NameField } from '~/components/form/fields/NameField' -import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { - fields, + RouteFormFields, routeFormMessage, - targetValueDescription, -} from '~/forms/vpc-router-route/shared' + type RouteFormValues, +} from '~/forms/vpc-router-route-common' import { getVpcRouterRouteSelector, useVpcRouterRouteSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { project, vpc, router, route } = getVpcRouterRouteSelector(params) + const { route, ...routerSelector } = getVpcRouterRouteSelector(params) await apiQueryClient.prefetchQuery('vpcRouterRouteView', { path: { route }, - query: { project, vpc, router }, + query: routerSelector, }) return null } export function EditRouterRouteSideModalForm() { const queryClient = useApiQueryClient() - const routeSelector = useVpcRouterRouteSelector() - const { project, vpc, router: routerName, route: routeName } = routeSelector + const { route: routeName, ...routerSelector } = useVpcRouterRouteSelector() const navigate = useNavigate() const { data: route } = usePrefetchedApiQuery('vpcRouterRouteView', { path: { route: routeName }, - query: { project, vpc, router: routerName }, + query: routerSelector, }) - const defaultValues: RouterRouteUpdate = R.pick(route, [ + const defaultValues: RouteFormValues = R.pick(route, [ 'name', 'description', 'target', 'destination', ]) - - const onDismiss = () => { - navigate(pb.vpcRouter({ project, vpc, router: routerName })) - } + const form = useForm({ defaultValues }) + const isDisabled = route?.kind === 'vpc_subnet' const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') addToast({ content: 'Your route has been updated' }) - onDismiss() + navigate(pb.vpcRouter(routerSelector)) }, }) - const form = useForm({ defaultValues }) - const targetType = form.watch('target.type') - - let isDisabled = false - let disabledReason = '' - - // Can simplify this if there aren't other disabling reasons - if (route?.kind === 'vpc_subnet') { - isDisabled = true - disabledReason = routeFormMessage.vpcSubnetNotModifiable - } - return ( navigate(pb.vpcRouter(routerSelector))} onSubmit={({ name, description, destination, target }) => updateRouterRoute.mutate({ - query: { project, vpc, router: routerName }, path: { route: routeName }, + query: routerSelector, body: { name, description, @@ -103,35 +82,9 @@ export function EditRouterRouteSideModalForm() { } loading={updateRouterRoute.isPending} submitError={updateRouterRoute.error} + submitDisabled={isDisabled ? routeFormMessage.vpcSubnetNotModifiable : undefined} > - {isDisabled && } - - - - - { - // 'outbound' is only valid option when targetType is 'internet_gateway' - if (value === 'internet_gateway') { - form.setValue('target.value', 'outbound') - } - if (value === 'drop') { - form.setValue('target.value', '') - } - }} - /> - {targetType !== 'drop' && ( - - )} + ) } diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx deleted file mode 100644 index ce528052f5..0000000000 --- a/app/forms/vpc-router-route/shared.tsx +++ /dev/null @@ -1,76 +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 type { RouteDestination, RouteTarget } from '~/api' - -// VPCs can not be specified as a destination in custom routers -// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L363 -const destTypes: Record, string> = { - ip: 'IP', - ip_net: 'IP network', - subnet: 'Subnet', -} - -// Subnets and VPCs cannot be used as a target in custom routers -// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L362-L368 -const targetTypes: Record, string> = { - ip: 'IP', - instance: 'Instance', - internet_gateway: 'Internet gateway', - drop: 'Drop', -} - -const toItems = (mapping: Record) => - Object.entries(mapping).map(([value, label]) => ({ value, label })) - -export const fields = { - destType: { - name: 'destination.type' as const, - items: toItems(destTypes), - label: 'Destination type', - placeholder: 'Select a destination type', - required: true, - }, - destValue: { - name: 'destination.value' as const, - label: 'Destination value', - placeholder: 'Enter a destination value', - required: true, - }, - targetType: { - name: 'target.type' as const, - items: toItems(targetTypes), - label: 'Target type', - placeholder: 'Select a target type', - required: true, - }, - targetValue: { - name: 'target.value' as const, - label: 'Target value', - placeholder: 'Enter a target value', - required: true, - }, -} - -export const routeFormMessage = { - vpcSubnetNotModifiable: - 'Routes of type VPC Subnet within the system router are not modifiable', - internetGatewayTargetValue: - 'For ‘Internet gateway’ targets, the value must be ‘outbound’', - // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 - noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router', - // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 - noDeletingRoutesOnSystemRouter: 'System routes cannot be deleted', - // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L136-L138 - noDeletingSystemRouters: 'System routers cannot be deleted', -} - -export const targetValueDescription = (targetType: RouteTarget['type']) => - targetType === 'internet_gateway' - ? routeFormMessage.internetGatewayTargetValue - : undefined diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index ca5c7cd8f7..b91ae862a5 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -23,7 +23,7 @@ import { import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' -import { routeFormMessage } from '~/forms/vpc-router-route/shared' +import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index b030c764fd..361d7a4921 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -11,7 +11,7 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' -import { routeFormMessage } from '~/forms/vpc-router-route/shared' +import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast'