Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
MiniTable,
SideModal,
} from '@oxide/ui'
import type { FormValues } from '../forms'
import type { FormValues } from '../../forms'

type DiskTableItem =
| (FormValues<'disk-create'> & { type: 'create' })
Expand Down Expand Up @@ -38,7 +38,7 @@ export function DisksTableField() {
<MiniTable.Body>
{items.map((item, index) => (
<MiniTable.Row
tabindex="0"
tabIndex="0"
aria-rowindex={index + 1}
aria-label={`Name: ${item.name}, Type: ${item.type}`}
key={item.name}
Expand Down
121 changes: 121 additions & 0 deletions app/components/fields/NetworkInterfaceField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useState } from 'react'
import { useField } from 'formik'
import { Button, Error16Icon, MiniTable, Radio, SideModal } from '@oxide/ui'
import type {
InstanceNetworkInterfaceAttachment,
NetworkInterfaceCreate,
} from '@oxide/api'
import { RadioField } from '@oxide/form'
import CreateNetworkInterfaceForm from 'app/forms/network-interface-create'

export function NetworkInterfaceField() {
const [showForm, setShowForm] = useState(false)

/**
* Used to preserve previous user choices in case they accidentally
* change the radio selection
*/
const [oldParams, setOldParams] = useState<NetworkInterfaceCreate[]>([])

const [, { value }, { setValue }] =
useField<InstanceNetworkInterfaceAttachment>({ name: 'networkInterfaces' })

return (
<div className="max-w-lg space-y-5">
<RadioField
id="network-interface-type"
name="networkInterfaceType"
column
label="Network interface"
className="pt-1"
onChange={(event) => {
const newType = event.target
.value as InstanceNetworkInterfaceAttachment['type']

if (value.type === 'Create') {
setOldParams(value.params)
}

newType === 'Create'
? setValue({ type: newType, params: oldParams })
: setValue({
type: newType,
})
}}
>
<Radio value="None">None</Radio>
<Radio value="Default">Default</Radio>
<Radio value="Create">Custom</Radio>
</RadioField>
{value.type === 'Create' && (
<>
{value.params.length > 0 && (
<MiniTable className="mb-4">
<MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>VPC</MiniTable.HeadCell>
<MiniTable.HeadCell>Subnet</MiniTable.HeadCell>
{/* For remove button */}
<MiniTable.HeadCell className="w-12" />
</MiniTable.Header>
<MiniTable.Body>
{value.params.map((item, index) => (
<MiniTable.Row
tabIndex="0"
aria-rowindex={index + 1}
aria-label={`Name: ${item.name}, Vpc: ${item.vpcName}, Subnet: ${item.subnetName}`}
key={item.name}
>
<MiniTable.Cell>{item.name}</MiniTable.Cell>
<MiniTable.Cell>{item.vpcName}</MiniTable.Cell>
<MiniTable.Cell>{item.subnetName}</MiniTable.Cell>
<MiniTable.Cell>
<Button
variant="link"
onClick={() =>
setValue({
type: 'Create',
params: value.params.filter(
(i) => i.name !== item.name
),
})
}
>
<Error16Icon title={`remove ${item.name}`} />
</Button>
</MiniTable.Cell>
</MiniTable.Row>
))}
</MiniTable.Body>
</MiniTable>
)}

<SideModal
id="create-disk-modal"
isOpen={showForm}
onDismiss={() => setShowForm(false)}
>
<CreateNetworkInterfaceForm
onSubmit={(networkInterface) => {
setValue({
type: 'Create',
params: [...value.params, networkInterface],
})
setShowForm(false)
}}
/>
</SideModal>
<div className="space-x-3">
<Button
variant="secondary"
size="sm"
onClick={() => setShowForm(true)}
>
Add network interface
</Button>
</div>
</>
)}
</div>
)
}
2 changes: 2 additions & 0 deletions app/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CreateProjectForm } from './project-create'
import type { CreateVpcForm } from './vpc-create'
import type CreateInstanceForm from './instance-create'
import type AttachDiskForm from './disk-attach'
import type CreateNetworkInterfaceForm from './network-interface-create'

import type { FormProps } from '@oxide/form'
import type { ErrorResponse } from '@oxide/api'
Expand All @@ -26,6 +27,7 @@ export interface FormTypes {
'disk-create': typeof CreateDiskForm
'subnet-create': typeof CreateSubnetForm
'subnet-edit': typeof EditSubnetForm
'network-interface-create': typeof CreateNetworkInterfaceForm
'vpc-create': typeof CreateVpcForm
}

Expand Down
11 changes: 10 additions & 1 deletion app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ import {
WindowsResponsiveIcon,
} from '@oxide/ui'
import { useParams, useToast } from 'app/hooks'
import { DisksTableField } from 'app/components/DisksTableField'
import { DisksTableField } from 'app/components/fields/DisksTableField'
import filesize from 'filesize'
import { NetworkInterfaceField } from 'app/components/fields/NetworkInterfaceField'

const values = {
name: '',
Expand All @@ -36,6 +37,12 @@ const values = {
hostname: '',
disks: [],
attachedDisks: [],
networkInterfaces: { type: 'Default' },
/**
* This is a hack to ensure the network interface radio has a default selection.
* We actually don't care about this value outside of that.
*/
networkInterfaceType: 'Default',
}

export default function CreateInstanceForm({
Expand Down Expand Up @@ -210,6 +217,8 @@ export default function CreateInstanceForm({
<Divider />
<Form.Heading id="networking">Networking</Form.Heading>

<NetworkInterfaceField />

<TextField
id="hostname"
description="Will be generated if not provided"
Expand Down
84 changes: 84 additions & 0 deletions app/forms/network-interface-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react'
import { DescriptionField, Form, NameField, TextField } from '@oxide/form'
import { Divider } from '@oxide/ui'
import type { NetworkInterfaceCreate, NetworkInterface } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'

import type { PrebuiltFormProps } from 'app/forms'
import { useParams } from 'app/hooks'
import invariant from 'tiny-invariant'

const values: NetworkInterfaceCreate = {
name: '',
description: '',
ip: '',
subnetName: '',
vpcName: '',
}

export default function CreateNetworkInterfaceForm({
id = 'create-network-interface-form',
title = 'Add Network Interface',
initialValues = values,
onSubmit,
onSuccess,
onError,
...props
}: PrebuiltFormProps<NetworkInterfaceCreate, NetworkInterface>) {
const queryClient = useApiQueryClient()
const pathParams = useParams('orgName', 'projectName')

const createNetworkInterface = useApiMutation(
'instanceNetworkInterfacesPost',
{
onSuccess(data) {
const { instanceName, ...others } = pathParams
invariant(
instanceName,
'instanceName is required when posting a network interface'
)
queryClient.invalidateQueries('instanceNetworkInterfacesGet', {
instanceName,
...others,
})
onSuccess?.(data)
},
onError,
}
)

return (
<Form
id={id}
title={title}
initialValues={initialValues}
onSubmit={
onSubmit ||
((body) => {
const { instanceName, ...others } = pathParams
invariant(
instanceName,
'instanceName is required when posting a network interface'
)
createNetworkInterface.mutate({ instanceName, ...others, body })
})
}
mutation={createNetworkInterface}
{...props}
>
<NameField id="nic-name" />
<DescriptionField id="nic-description" />
<Divider />

{/* TODO: Convert these into combo boxes */}
<TextField id="nic-vpc" name="vpcName" label="VPC" required />
<TextField id="nic-subnet" name="subnetName" label="Subnet" required />
<TextField id="nic-ip" name="ip" label="IP Address" />

<Form.Actions>
<Form.Submit>{title}</Form.Submit>
<Form.Cancel />
</Form.Actions>
</Form>
)
}
4 changes: 4 additions & 0 deletions libs/ui/lib/radio-group/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type RadioGroupProps = {
// For vertical layout of regular Radios. Leave it off for RadioCards.
column?: boolean
className?: string

onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}

export const RadioGroup = ({
Expand All @@ -67,6 +69,7 @@ export const RadioGroup = ({
disabled,
column,
className,
onChange,
}: RadioGroupProps) => (
<div
className={cn(
Expand All @@ -75,6 +78,7 @@ export const RadioGroup = ({
className
)}
role="radiogroup"
onChange={onChange}
>
{React.Children.map(children, (radio) =>
React.cloneElement(radio, { name, required, disabled })
Expand Down
2 changes: 1 addition & 1 deletion libs/ui/lib/side-modal/SideModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ SideModal.Title = ({ id, children }: SideModalTitleProps) => {
)
}

SideModal.Body = classed.div`body relative overflow-y-auto h-full`
SideModal.Body = classed.div`body relative overflow-y-auto h-full pb-6`

SideModal.Section = classed.div`p-8 space-y-6 border-secondary`

Expand Down