Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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 })
})
})
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So this test is kind of wild. It takes a few seconds. I'm not sure how brittle it is, it's pretty generic and would be robust to a lot of insignificant things about the page changing. I have liked these kinds of tests in the past because they recreate the kind of manual testing you would do in the browser to make sure happy paths work. But I've never had a whole app full of them, so I don't know how they scale. Curious how you feel about it @zephraph.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

One thing I was thinking while doing the mocks is that MSW would make that part better because when we create the subnet it would actually go into the list of subnets and automatically be returned in the subsequent fetch.

Copy link
Contributor

@just-be-dev just-be-dev Jan 7, 2022

Choose a reason for hiding this comment

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

Yeah, I'm not super keen on the fetchMock usage. It's not the end of the word and having a few here and there are okay, but generally this isn't the best practice. It reminds me of a PR I did for auto. There were a lot of usages of mocks for fs.existsSync and fs.readFile that meant you really had to know what the implementation was doing to write the tests. Changing the order of file reads or using a different method could break things. In that PR, I helped move over to mock-fs which actually mocked out the whole filesystem... meaning all the read/writes were the same, we just had to mock out what exists on the disk. This is ultimately what MSW will give us and is a really strong argument for why it's worth investing the time to build that out.

That said, impact over perfection. I'm fine with this as is b/c it gives us more test coverage than we had. It'd just be nice to come back through and clean it up once we have a better primitive in place.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I'd like to leave this in, as it gives me a lot more to work with when I make MSW work in #571. It will be very cool if we can delete the mock lines and have the rest of test work as-is.

18 changes: 12 additions & 6 deletions app/pages/project/networking/VpcPage/VpcPage.tsx
Original file line number Diff line number Diff line change
@@ -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}
Comment on lines -28 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice, thanks for taking care of this!

</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
Original file line number Diff line number Diff line change
@@ -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>
)
}
Loading