Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/components/RadioCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import cn from 'classnames'
import { useId } from 'react'
import type { ReactNode } from 'react'

/*
* 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 { Warning12Icon } from '@oxide/design-system/icons/react'

import { RadioIndicator } from '~/ui/lib/Radio'

/**
* A radio button styled as a full-width card with a label, description, and
* an optional notice (e.g. explaining why the option is disabled). Compose a
* list of these sharing one `name` to build a set of mutually exclusive
* options, e.g. for `extraContent` in `confirmDelete`/`confirmAction`.
*/
export function RadioCard({
name,
checked,
onChange,
disabled,
label,
description,
notice,
}: {
name: string
checked: boolean
onChange: () => void
disabled?: boolean
label: ReactNode
description: ReactNode
notice?: ReactNode
}) {
// htmlFor + id because the a11y lint rule can't see the nested input
const id = useId()
return (
<label
htmlFor={id}
className={cn(
'has-[:focus-visible]:ring-accent-secondary block rounded-md border p-3 has-[:focus-visible]:ring-2',
disabled
? 'border-default bg-disabled cursor-not-allowed'
: cn(
'cursor-pointer',
checked
? 'bg-accent border-accent-secondary hover:border-accent'
: 'border-default hover:border-raise'
)
)}
>
<div className="flex items-center justify-between gap-3">
<div className="pr-4">
<div
className={cn(
'text-sans-semi-md',
disabled ? 'text-disabled' : checked ? 'text-accent' : 'text-raise'
)}
>
{label}
</div>
<div
className={cn(
'text-sans-md mt-0.5',
disabled
? 'text-disabled'
: checked
? 'text-accent-secondary'
: 'text-default'
)}
>
{description}
</div>
</div>
<RadioIndicator
id={id}
name={name}
checked={checked}
disabled={disabled}
onChange={onChange}
/>
</div>
{notice && (
<div className="text-sans-md text-secondary -m-3 mt-3 flex items-center gap-1.5 border-t px-3 py-2.5">
<Warning12Icon className="text-notice shrink-0" />
{notice}
</div>
)}
</label>
)
}
63 changes: 63 additions & 0 deletions app/forms/internet-gateway-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'

import { api, queryClient, useApiMutation, type InternetGatewayCreate } from '@oxide/api'

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 { useVpcSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

const defaultValues: InternetGatewayCreate = {
name: '',
description: '',
}

export const handle = titleCrumb('New Internet Gateway')

export default function InternetGatewayCreateForm() {
const vpcSelector = useVpcSelector()
const navigate = useNavigate()

const onDismiss = () => navigate(pb.vpcInternetGateways(vpcSelector))

const createGateway = useApiMutation(api.internetGatewayCreate, {
onSuccess(gateway) {
queryClient.invalidateEndpoint('internetGatewayList')
// prettier-ignore
addToast(<>Internet gateway <HL>{gateway.name}</HL> created</>)
onDismiss()
},
})

const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="internet gateway"
onDismiss={onDismiss}
onSubmit={(body) => createGateway.mutate({ query: vpcSelector, body })}
loading={createGateway.isPending}
submitError={createGateway.error}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<SideModalFormDocs docs={[docLinks.gateways]} />
</SideModalForm>
)
}
107 changes: 107 additions & 0 deletions app/forms/internet-gateway-ip-address-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'

import {
api,
queryClient,
useApiMutation,
usePrefetchedQuery,
type InternetGatewayIpAddressCreate,
} from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { noPasswordManager, TextField } from '~/components/form/fields/TextField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params'
import { gatewayIpAddressList } from '~/pages/project/vpcs/gateway-data'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { validateIp } from '~/util/ip'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

export const handle = titleCrumb('Attach IP Address')

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getInternetGatewaySelector(params)
await queryClient.prefetchQuery(gatewayIpAddressList(selector).optionsFn())
return null
}

const defaultValues: InternetGatewayIpAddressCreate = {
name: '',
description: '',
address: '',
}

const alreadyAttachedMessage =
'Internet gateways can have at most one IP address attached. Detach the existing address before attaching another.'

export default function InternetGatewayIpAddressCreateForm() {
const { project, vpc, gateway } = useInternetGatewaySelector()
const navigate = useNavigate()

const { data: addresses } = usePrefetchedQuery(
gatewayIpAddressList({ project, vpc, gateway }).optionsFn()
)
// gateways can have at most one IP address attached: the unique index is on
// internet_gateway_id alone
// https://github.com/oxidecomputer/omicron/blob/99249b4/schema/crdb/dbinit.sql#L2314-L2317
const alreadyAttached = addresses.items.length > 0

const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway }))

const attachAddress = useApiMutation(api.internetGatewayIpAddressCreate, {
onSuccess(address) {
queryClient.invalidateEndpoint('internetGatewayIpAddressList')
// prettier-ignore
addToast(<>IP address <HL>{address.name}</HL> attached</>)
onDismiss()
},
})

const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="IP address"
title="Attach IP address"
onDismiss={onDismiss}
onSubmit={(body) => attachAddress.mutate({ query: { project, vpc, gateway }, body })}
loading={attachAddress.isPending}
submitError={attachAddress.error}
submitDisabled={alreadyAttached ? alreadyAttachedMessage : undefined}
>
{alreadyAttached && <Message variant="info" content={alreadyAttachedMessage} />}
<NameField
name="name"
control={form.control}
description="A name for this attachment"
/>
<DescriptionField name="description" control={form.control} />
<TextField
name="address"
label="IP address"
description="An address from an IP pool attached to this gateway"
control={form.control}
required
validate={validateIp}
{...noPasswordManager}
/>
<SideModalFormDocs docs={[docLinks.gateways]} />
</SideModalForm>
)
}
98 changes: 98 additions & 0 deletions app/forms/internet-gateway-ip-pool-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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 { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'

import {
api,
q,
queryClient,
sortPools,
useApiMutation,
usePrefetchedQuery,
type InternetGatewayIpPoolCreate,
} 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 { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { toPoolItem } from '~/components/PoolListboxItem'
import { titleCrumb } from '~/hooks/use-crumbs'
import { useInternetGatewaySelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { ALL_ISH } from '~/util/consts'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

const poolList = q(api.ipPoolList, { query: { limit: ALL_ISH } })

export async function clientLoader() {
await queryClient.prefetchQuery(poolList)
return null
}

export const handle = titleCrumb('Attach IP Pool')

const defaultValues: InternetGatewayIpPoolCreate = {
name: '',
description: '',
ipPool: '',
}

export default function InternetGatewayIpPoolCreateForm() {
const { project, vpc, gateway } = useInternetGatewaySelector()
const navigate = useNavigate()

const { data: pools } = usePrefetchedQuery(poolList)

const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway }))

const attachPool = useApiMutation(api.internetGatewayIpPoolCreate, {
onSuccess(pool) {
queryClient.invalidateEndpoint('internetGatewayIpPoolList')
// prettier-ignore
addToast(<>IP pool <HL>{pool.name}</HL> attached</>)
onDismiss()
},
})

const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="IP pool"
title="Attach IP pool"
onDismiss={onDismiss}
onSubmit={(body) => attachPool.mutate({ query: { project, vpc, gateway }, body })}
loading={attachPool.isPending}
submitError={attachPool.error}
>
<NameField
name="name"
control={form.control}
description="A name for this attachment"
/>
<DescriptionField name="description" control={form.control} />
<ListboxField
name="ipPool"
label="IP pool"
control={form.control}
items={sortPools(pools.items).map(toPoolItem)}
required
placeholder="Select a pool"
noItemsPlaceholder="No pools available"
/>
<SideModalFormDocs docs={[docLinks.gateways]} />
</SideModalForm>
)
}
Loading
Loading