Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render the whole app in page-level tests #565

Merged
merged 10 commits into from Jan 4, 2022
153 changes: 69 additions & 84 deletions app/pages/ProjectCreatePage.tsx
Expand Up @@ -13,7 +13,6 @@ import {
Success16Icon,
FieldTitle,
} from '@oxide/ui'
import type { Project } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { useParams, useToast } from '../hooks'
import { getServerError } from '../util/errors'
Expand All @@ -24,105 +23,91 @@ const ERROR_CODES = {
'A project with that name already exists in this organization',
}

// exists primarily so we can test it without worrying about route params
export function ProjectCreateForm({
orgName,
onSuccess,
}: {
orgName: string
onSuccess: (p: Project) => void
}) {
const createProject = useApiMutation('organizationProjectsPost', {
onSuccess,
})
return (
<Formik
initialValues={{ name: '', description: '' }}
onSubmit={({ name, description }) => {
createProject.mutate({
organizationName: orgName,
body: { name, description },
})
}}
>
<Form>
<div className="mb-4">
<FieldTitle htmlFor="project-name">Choose a name</FieldTitle>
<TextField
id="project-name"
name="name"
placeholder="Enter name"
validate={validateName}
autoComplete="off"
/>
<TextFieldError name="name" />
</div>
<div className="mb-8">
<FieldTitle htmlFor="project-description">
Choose a description
</FieldTitle>
<TextFieldHint id="description-hint">
What is unique about your project?
</TextFieldHint>
<TextField
id="project-description"
name="description"
aria-describedby="description-hint"
placeholder="A project"
autoComplete="off"
/>
</div>
<Button
type="submit"
variant="dim"
className="w-[30rem]"
disabled={createProject.isLoading}
>
Create project
</Button>
<div className="text-red-500 mt-2">
{getServerError(createProject.error, ERROR_CODES)}
</div>
</Form>
</Formik>
)
}

export default function ProjectCreatePage() {
const queryClient = useApiQueryClient()
const addToast = useToast()
const navigate = useNavigate()

const { orgName } = useParams('orgName')

const createProject = useApiMutation('organizationProjectsPost', {
onSuccess(project) {
// refetch list of projects in sidebar
queryClient.invalidateQueries('organizationProjectsGet', {
organizationName: orgName,
})
// avoid the project fetch when the project page loads since we have the data
queryClient.setQueryData(
'organizationProjectsGetProject',
{ organizationName: orgName, projectName: project.name },
project
)
addToast({
icon: <Success16Icon />,
title: 'Success!',
content: 'Your project has been created.',
timeout: 5000,
})
navigate(`../${project.name}`)
},
})

return (
<>
<PageHeader>
<PageTitle icon={<Folder24Icon title="Projects" />}>
Create a new project
</PageTitle>
</PageHeader>
<ProjectCreateForm
orgName={orgName}
onSuccess={(project) => {
// refetch list of projects in sidebar
queryClient.invalidateQueries('organizationProjectsGet', {
<Formik
initialValues={{ name: '', description: '' }}
onSubmit={({ name, description }) => {
createProject.mutate({
organizationName: orgName,
body: { name, description },
})
// avoid the project fetch when the project page loads since we have the data
queryClient.setQueryData(
'organizationProjectsGetProject',
{ organizationName: orgName, projectName: project.name },
project
)
addToast({
icon: <Success16Icon />,
title: 'Success!',
content: 'Your project has been created.',
timeout: 5000,
})
navigate(`../${project.name}`)
}}
/>
>
<Form>
<div className="mb-4">
<FieldTitle htmlFor="project-name">Choose a name</FieldTitle>
<TextField
id="project-name"
name="name"
placeholder="Enter name"
validate={validateName}
autoComplete="off"
/>
<TextFieldError name="name" />
</div>
<div className="mb-8">
<FieldTitle htmlFor="project-description">
Choose a description
</FieldTitle>
<TextFieldHint id="description-hint">
What is unique about your project?
</TextFieldHint>
<TextField
id="project-description"
name="description"
aria-describedby="description-hint"
placeholder="A project"
autoComplete="off"
/>
</div>
<Button
type="submit"
variant="dim"
className="w-[30rem]"
disabled={createProject.isLoading}
>
Create project
</Button>
<div className="text-red-500 mt-2">
{getServerError(createProject.error, ERROR_CODES)}
</div>
</Form>
</Formik>
</>
)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

changes to this file and InstanceCreatePage are much less interesting than they look, and they shrink quite a bit with whitespace changes hidden — just moving the contents of the Form component into the page since we no longer need the former for testing purposes

@@ -1,17 +1,14 @@
import React from 'react'
import {
fireEvent,
lastBody,
renderWithRouter,
lastPostBody,
renderAppAt,
screen,
waitFor,
} from '../../test-utils'
import fetchMock from 'fetch-mock'

import { org, project, instance } from '@oxide/api-mocks'

import { InstanceCreateForm } from '../project/instances/create/InstancesCreatePage'

const submitButton = () =>
screen.getByRole('button', { name: 'Create instance' })

Expand All @@ -20,29 +17,22 @@ const instancesUrl = `${projectUrl}/instances`
const disksUrl = `${projectUrl}/disks`
const vpcsUrl = `${projectUrl}/vpcs`

let successSpy: jest.Mock

describe('InstanceCreateForm', () => {
beforeEach(() => {
// existing disk modal fetches disks on render even if it's not visible
fetchMock.get(disksUrl, 200)
fetchMock.get(vpcsUrl, 200)
successSpy = jest.fn()
renderWithRouter(
<InstanceCreateForm
orgName={org.name}
projectName={project.name}
onSuccess={successSpy}
/>
)
})
const renderPage = () => {
// existing disk modal fetches disks on render even if it's not visible
fetchMock.get(disksUrl, 200)
fetchMock.get(vpcsUrl, 200)
fetchMock.get(projectUrl, 200)
return renderAppAt(`/orgs/${org.name}/projects/${project.name}/instances/new`)
}

describe('InstanceCreatePage', () => {
afterEach(() => {
fetchMock.reset()
})

it('disables submit button on submit and enables on response', async () => {
const mock = fetchMock.post(instancesUrl, 201)
renderPage()

const submit = submitButton()
expect(submit).not.toBeDisabled()
Expand All @@ -52,14 +42,14 @@ describe('InstanceCreateForm', () => {
expect(mock.called(instancesUrl)).toBeFalsy()
await waitFor(() => expect(submit).toBeDisabled())
expect(mock.done()).toBeTruthy()
expect(submit).not.toBeDisabled()
})

it('shows specific message for known server error code', async () => {
fetchMock.post(instancesUrl, {
status: 400,
body: { error_code: 'ObjectAlreadyExists' },
})
renderPage()

fireEvent.click(submitButton())

Expand All @@ -73,6 +63,7 @@ describe('InstanceCreateForm', () => {
status: 400,
body: { error_code: 'UnknownCode' },
})
renderPage()

fireEvent.click(submitButton())

Expand All @@ -81,6 +72,7 @@ describe('InstanceCreateForm', () => {

it('posts form on submit', async () => {
const mock = fetchMock.post(instancesUrl, 201)
renderPage()

fireEvent.change(screen.getByLabelText('Choose a name'), {
target: { value: 'new-instance' },
Expand All @@ -89,7 +81,7 @@ describe('InstanceCreateForm', () => {
fireEvent.click(submitButton())

await waitFor(() =>
expect(lastBody(mock)).toEqual({
expect(lastPostBody(mock)).toEqual({
name: 'new-instance',
description: 'An instance in project: mock-project',
hostname: '',
Expand All @@ -99,15 +91,17 @@ describe('InstanceCreateForm', () => {
)
})

it('calls onSuccess on success', async () => {
it('navigates to project instances page on success', async () => {
const mock = fetchMock.post(instancesUrl, { status: 201, body: instance })
renderPage()

expect(successSpy).not.toHaveBeenCalled()
const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances`
expect(window.location.pathname).not.toEqual(instancesPage)

fireEvent.click(submitButton())

await waitFor(() => expect(mock.called(instancesUrl)).toBeTruthy())
await waitFor(() => expect(mock.done()).toBeTruthy())
await waitFor(() => expect(successSpy).toHaveBeenCalled())
await waitFor(() => expect(window.location.pathname).toEqual(instancesPage))
})
})