Skip to content

Commit

Permalink
Create and edit VPC subnets and routers (#593)
Browse files Browse the repository at this point in the history
* first draft at create subnet SideModal

* stub test for create subnet modal

* working edit modal. wild!

* extremely basic server errors

* move classed into @oxide/util

* router edit and create modals

* extract form shared between edit and create, save 60 lines

* got full page create subnet integration test to work

* cut 50 lines updating everything new with orgName
  • Loading branch information
david-crespo committed Jan 9, 2022
1 parent 6d1adf6 commit 03d2693
Show file tree
Hide file tree
Showing 23 changed files with 657 additions and 44 deletions.
2 changes: 1 addition & 1 deletion app/layouts/helpers.tsx
@@ -1,5 +1,5 @@
import './helpers.css'
import { classed } from '@oxide/ui'
import { classed } from '@oxide/util'

export const PageContainer = classed.div`ox-page-container`
export const Sidebar = classed.div`ox-sidebar`
Expand Down
3 changes: 2 additions & 1 deletion app/pages/ToastTestPage.tsx
@@ -1,6 +1,7 @@
import { Button, classed, Comment16Icon, Success16Icon } from '@oxide/ui'
import { Button, Comment16Icon, Success16Icon } from '@oxide/ui'
import React, { useState } from 'react'
import { useToast } from '../hooks'
import { classed } from '@oxide/util'

const useCounter = (initialValue: number): [number, () => void] => {
const [value, setValue] = useState(initialValue)
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/InstancesPage.tsx
Expand Up @@ -46,7 +46,7 @@ export const InstancesPage = () => {
to={`/orgs/${orgName}/projects/${projectName}/instances/new`}
className={buttonStyle({ size: 'xs', variant: 'dim' })}
>
new instance
New Instance
</Link>
</div>
<Table selectable actions={actions}>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/create/InstancesCreatePage.tsx
Expand Up @@ -4,7 +4,6 @@ import cn from 'classnames'
import { Formik, Form } from 'formik'

import {
classed,
Button,
PageHeader,
PageTitle,
Expand All @@ -19,6 +18,7 @@ import {
FieldTitle,
Badge,
} from '@oxide/ui'
import { classed } from '@oxide/util'
import { useApiMutation } from '@oxide/api'
import { getServerError } from '../../../../util/errors'
import { INSTANCE_SIZES } from './instance-types'
Expand Down
85 changes: 85 additions & 0 deletions app/pages/project/networking/VpcPage/VpcPage.spec.ts
@@ -0,0 +1,85 @@
import {
fireEvent,
lastPostBody,
renderAppAt,
screen,
userEvent,
waitForElementToBeRemoved,
} from '../../../../test-utils'
import fetchMock from 'fetch-mock'
import {
org,
project,
vpc,
vpcSubnet,
vpcSubnet2,
vpcSubnets,
} from '@oxide/api-mocks'

const vpcUrl = `/api/organizations/${org.name}/projects/${project.name}/vpcs/default`
const subnetsUrl = `${vpcUrl}/subnets`
const getSubnetsUrl = `${subnetsUrl}?limit=10`

describe('VpcPage', () => {
describe('subnets tab', () => {
it('creating a subnet works', async () => {
fetchMock.get(vpcUrl, { status: 200, body: vpc })
fetchMock.getOnce(getSubnetsUrl, { status: 200, body: vpcSubnets })
const postMock = fetchMock.postOnce(subnetsUrl, {
status: 201,
body: vpcSubnet2,
})

renderAppAt('/orgs/mock-org/projects/mock-project/vpcs/default')
screen.getByText('Subnets')

// wait for subnet to show up in the table
await screen.findByRole('cell', { name: vpcSubnet.identity.name })

// modal is not already open
expect(screen.queryByRole('dialog', { name: 'Create subnet' })).toBeNull()

// click button to open modal
fireEvent.click(screen.getByRole('button', { name: 'New subnet' }))

// modal is open
screen.getByRole('dialog', { name: 'Create subnet' })

const ipv4 = screen.getByRole('textbox', { name: 'IPv4 block' })
userEvent.type(ipv4, '1.1.1.2/24')

const name = screen.getByRole('textbox', { name: 'Name' })
userEvent.type(name, 'mock-subnet-2')

// override the subnets GET to include both subnets
fetchMock.getOnce(
getSubnetsUrl,
{
status: 200,
body: { items: [vpcSubnet, vpcSubnet2] },
},
{ overwriteRoutes: true }
)

// submit the form
fireEvent.click(screen.getByRole('button', { name: 'Create subnet' }))

// wait for modal to close
await waitForElementToBeRemoved(() =>
screen.queryByRole('dialog', { name: 'Create subnet' })
)

// it posted the form
expect(lastPostBody(postMock)).toEqual({
ipv4Block: '1.1.1.2/24',
ipv6Block: null,
name: 'mock-subnet-2',
description: '',
})

// table should refetch and now include second subnet
screen.getByRole('cell', { name: vpcSubnet.identity.name })
screen.getByRole('cell', { name: vpcSubnet2.identity.name })
})
})
})
18 changes: 12 additions & 6 deletions app/pages/project/networking/VpcPage/VpcPage.tsx
@@ -1,4 +1,5 @@
import React from 'react'
import { format } from 'date-fns'
import {
Networking24Icon,
PageHeader,
Expand All @@ -11,32 +12,37 @@ import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab'
import { VpcRoutersTab } from './tabs/VpcRoutersTab'
import { useParams } from '../../../../hooks'
import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab'
import { useApiQuery } from '@oxide/api'

const formatDateTime = (s: string) => format(new Date(s), 'MMM d, yyyy H:mm aa')

export const VpcPage = () => {
const { vpcName } = useParams('vpcName')
const vpcParams = useParams('orgName', 'projectName', 'vpcName')
const { data: vpc } = useApiQuery('projectVpcsGetVpc', vpcParams)

return (
<>
<PageHeader>
<PageTitle icon={<Networking24Icon title="Vpcs" />}>
{vpcName}
{vpcParams.vpcName}
</PageTitle>
</PageHeader>

<PropertiesTable.Group className="mb-16">
<PropertiesTable>
<PropertiesTable.Row label="Description">
Default network for the project
{vpc?.description}
</PropertiesTable.Row>
<PropertiesTable.Row label="DNS Name">
frontend-production-vpc
{vpc?.dnsName}
</PropertiesTable.Row>
</PropertiesTable>
<PropertiesTable>
<PropertiesTable.Row label="Creation Date">
Default network for the project
{vpc?.timeCreated && formatDateTime(vpc.timeCreated)}
</PropertiesTable.Row>
<PropertiesTable.Row label="Last Modified">
Default network for the project
{vpc?.timeModified && formatDateTime(vpc.timeModified)}
</PropertiesTable.Row>
</PropertiesTable>
</PropertiesTable.Group>
Expand Down
170 changes: 170 additions & 0 deletions app/pages/project/networking/VpcPage/modals/vpc-routers.tsx
@@ -0,0 +1,170 @@
import React from 'react'
import { Formik, Form } from 'formik'

import { Button, FieldTitle, SideModal, TextField } from '@oxide/ui'
import type { VpcRouter, ErrorResponse } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { getServerError } from '../../../../../util/errors'

// this will get a lot more interesting once the API is updated to allow us to
// put rules in the router, which is the whole point of a router

type FormProps = {
error: ErrorResponse | null
id: string
}

// the moment the two forms diverge, inline them rather than introducing BS
// props here
const CommonForm = ({ error, id }: FormProps) => (
<Form id={id}>
<SideModal.Section className="border-t">
<div className="space-y-0.5">
<FieldTitle htmlFor="router-name" tip="The name of the router">
Name
</FieldTitle>
<TextField id="router-name" name="name" />
</div>
<div className="space-y-0.5">
<FieldTitle
htmlFor="router-description"
tip="A description for the router"
>
Description {/* TODO: indicate optional */}
</FieldTitle>
<TextField id="router-description" name="description" />
</div>
</SideModal.Section>
<SideModal.Section>
<div className="text-red-500">{getServerError(error)}</div>
</SideModal.Section>
</Form>
)

type CreateProps = {
isOpen: boolean
onDismiss: () => void
orgName: string
projectName: string
vpcName: string
}

export function CreateVpcRouterModal({
isOpen,
onDismiss,
orgName,
projectName,
vpcName,
}: CreateProps) {
const parentIds = { orgName, projectName, vpcName }
const queryClient = useApiQueryClient()

function dismiss() {
createRouter.reset()
onDismiss()
}

const createRouter = useApiMutation('vpcRoutersPost', {
onSuccess() {
queryClient.invalidateQueries('vpcRoutersGet', parentIds)
dismiss()
},
})

const formId = 'create-vpc-router-form'

return (
<SideModal
id="create-vpc-router-modal"
title="Create router"
isOpen={isOpen}
onDismiss={dismiss}
>
<Formik
initialValues={{ name: '', description: '' }}
onSubmit={({ name, description }) => {
createRouter.mutate({
...parentIds,
body: { name, description },
})
}}
>
<CommonForm id={formId} error={createRouter.error} />
</Formik>
<SideModal.Footer>
<Button variant="dim" className="mr-2.5" onClick={dismiss}>
Cancel
</Button>
<Button form={formId} type="submit">
Create router
</Button>
</SideModal.Footer>
</SideModal>
)
}

type EditProps = {
onDismiss: () => void
orgName: string
projectName: string
vpcName: string
originalRouter: VpcRouter | null
}

export function EditVpcRouterModal({
onDismiss,
orgName,
projectName,
vpcName,
originalRouter,
}: EditProps) {
const parentIds = { orgName, projectName, vpcName }
const queryClient = useApiQueryClient()

function dismiss() {
updateRouter.reset()
onDismiss()
}

const updateRouter = useApiMutation('vpcRoutersPutRouter', {
onSuccess() {
queryClient.invalidateQueries('vpcRoutersGet', parentIds)
dismiss()
},
})

if (!originalRouter) return null

const formId = 'edit-vpc-router-form'
return (
<SideModal
id="edit-vpc-router-modal"
title="Edit router"
onDismiss={dismiss}
>
<Formik
initialValues={{
name: originalRouter.identity.name,
description: originalRouter.identity.description,
}}
onSubmit={({ name, description }) => {
updateRouter.mutate({
...parentIds,
routerName: originalRouter.identity.name,
body: { name, description },
})
}}
>
<CommonForm id={formId} error={updateRouter.error} />
</Formik>
<SideModal.Footer>
<Button variant="dim" className="mr-2.5" onClick={dismiss}>
Cancel
</Button>
<Button form={formId} type="submit">
Update router
</Button>
</SideModal.Footer>
</SideModal>
)
}

1 comment on commit 03d2693

@vercel
Copy link

@vercel vercel bot commented on 03d2693 Jan 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.