Skip to content

Commit

Permalink
Convert instance form (#760)
Browse files Browse the repository at this point in the history
* WIP instance form work

* Fix button size

* Minor accessibilty updates to the table

* Wire up dismiss states

* Improve tests, name validation

* Update extract types

* Add disk attach

* Fix instance-create page not rendering

* Add ability to get form context from user

* WIP instance create

* Fix issue where form params weren't being set

* fixup disk attach form

* automatically update packer-id

* Move pre-built form types out of form lib

* Delete old instance create page

* Revert page param changes

* Remove old comment

* Remove some left over form param references

* Remove more form param references, cleanup type failures

* Fix lint issues

* Fix e2e tests

* Add e2e test for instance create

* Update app/forms/instance-create.tsx

Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>

* export use-forms from app/forms/index

* Wire up disk creates

* Remove disk size, capitalize source type

* Fix disk attach api

* revert disk attach api change

* update disk name thing

* Invert disks table logic flow (#777)

* it works!

* fully inline disks MiniTable

* DisksTable -> DisksTableField

* move DisksTableField to its own file to reduce noise in instant create

* convert VPC subnets and delete useForm

* allow any param, but it might be undefined (#779)

* Address PR feedback, validateName -> getNameValidator

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>
Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
  • Loading branch information
4 people authored Apr 11, 2022
1 parent a316e66 commit 610b945
Show file tree
Hide file tree
Showing 40 changed files with 759 additions and 1,144 deletions.
108 changes: 108 additions & 0 deletions app/components/DisksTableField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { useState } from 'react'
import { useField } from 'formik'
import { CreateDiskForm } from 'app/forms/disk-create'
import { AttachDiskForm } from 'app/forms/disk-attach'
import {
Button,
Error16Icon,
FieldLabel,
MiniTable,
SideModal,
} from '@oxide/ui'
import type { FormValues } from '../forms'

type DiskTableItem =
| (FormValues<'disk-create'> & { type: 'create' })
| (FormValues<'disk-attach'> & { type: 'attach' })

export function DisksTableField() {
const [showDiskCreate, setShowDiskCreate] = useState(false)
const [showDiskAttach, setShowDiskAttach] = useState(false)

const [, { value: items = [] }, { setValue: setItems }] = useField<
DiskTableItem[]
>({ name: 'disks' })

return (
<>
<div className="max-w-lg">
<FieldLabel id="new-disks-label">{/* this was empty */}</FieldLabel>
{!!items.length && (
<MiniTable className="mb-4">
<MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>Type</MiniTable.HeadCell>
{/* For remove button */}
<MiniTable.HeadCell className="w-12" />
</MiniTable.Header>
<MiniTable.Body>
{items.map((item, index) => (
<MiniTable.Row
tabindex="0"
aria-rowindex={index + 1}
aria-label={`Name: ${item.name}, Type: ${item.type}`}
key={item.name}
>
<MiniTable.Cell>{item.name}</MiniTable.Cell>
<MiniTable.Cell>{item.type}</MiniTable.Cell>
<MiniTable.Cell>
<Button
variant="link"
onClick={() =>
setItems(items.filter((i) => i.name !== item.name))
}
>
<Error16Icon title={`remove ${item.name}`} />
</Button>
</MiniTable.Cell>
</MiniTable.Row>
))}
</MiniTable.Body>
</MiniTable>
)}

<div className="space-x-3">
<Button
variant="secondary"
size="sm"
onClick={() => setShowDiskCreate(true)}
>
Create new disk
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowDiskAttach(true)}
>
Attach existing disk
</Button>
</div>
</div>

<SideModal
id="create-disk-modal"
isOpen={showDiskCreate}
onDismiss={() => setShowDiskCreate(false)}
>
<CreateDiskForm
onSubmit={(values) => {
setItems([...items, { type: 'create', ...values }])
setShowDiskCreate(false)
}}
/>
</SideModal>
<SideModal
id="attach-disk-modal"
isOpen={showDiskAttach}
onDismiss={() => setShowDiskAttach(false)}
>
<AttachDiskForm
onSubmit={(values) => {
setItems([...items, { type: 'attach', ...values }])
setShowDiskAttach(false)
}}
/>
</SideModal>
</>
)
}
26 changes: 26 additions & 0 deletions app/forms/__tests__/instance-create.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test'

test.describe('Instance Create Form', () => {
test('can invoke instance create form from instances page', async ({
page,
}) => {
await page.goto('/orgs/maze-war/projects/mock-project/instances')
await page.locator('text="New Instance"').click()
await expect(page.locator('h1:has-text("Create instance")')).toBeVisible()

await page.fill('input[name=name]', 'mock-instance')
await page.locator('.ox-radio-card').nth(0).click()

await page.locator('input[value=ubuntu] ~ .ox-radio-card').click()

await page.locator('button:has-text("Create instance")').click()

await page.waitForNavigation()

expect(page.url()).toContain(
'/orgs/maze-war/projects/mock-project/instances/mock-instance'
)

await expect(page.locator('h1:has-text("mock-instance")')).toBeVisible()
})
})
67 changes: 67 additions & 0 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Form, NameField } from '@oxide/form'
import React from 'react'
import type { Disk } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { invariant } from '@oxide/util'
import { useParams } from 'app/hooks'
import type { PrebuiltFormProps } from 'app/forms'

const values = {
name: '',
}

export function AttachDiskForm({
id = 'form-disk-attach',
title = 'Attach Disk',
initialValues = values,
onSubmit,
onSuccess,
onError,
...props
}: PrebuiltFormProps<typeof values, Disk>) {
const queryClient = useApiQueryClient()
const pathParams = useParams('orgName', 'projectName')

const attachDisk = useApiMutation('instanceDisksAttach', {
onSuccess(data) {
const { instanceName, ...others } = pathParams
invariant(instanceName, 'instanceName is required')
queryClient.invalidateQueries('instanceDisksGet', {
instanceName,
...others,
})
onSuccess?.(data)
},
onError,
})

return (
<Form
id={id}
title={title}
initialValues={initialValues}
onSubmit={
onSubmit ||
(({ name }) => {
const { instanceName, ...others } = pathParams
invariant(instanceName, 'instanceName is required')
attachDisk.mutate({
instanceName,
...others,
body: { name },
})
})
}
mutation={attachDisk}
{...props}
>
<NameField id="form-disk-attach-name" label="Disk name" />
<Form.Actions>
<Form.Submit>{title}</Form.Submit>
<Form.Cancel />
</Form.Actions>
</Form>
)
}

export default AttachDiskForm
15 changes: 7 additions & 8 deletions app/forms/disk-create.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react'
import {
DescriptionField,
Form,
Expand All @@ -7,17 +8,16 @@ import {
Radio,
} from '@oxide/form'
import { Divider } from '@oxide/ui'
import React from 'react'
import type { PrebuiltFormProps } from '@oxide/form'
import { useParams } from 'app/hooks'
import type { Disk } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'

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

const values = {
name: '',
description: '',
size: 0,
type: '',
sourceType: '',
deletionRule: '',
}
Expand All @@ -31,12 +31,12 @@ export function CreateDiskForm({
onError,
...props
}: PrebuiltFormProps<typeof values, Disk>) {
const parentNames = useParams('orgName', 'projectName')
const queryClient = useApiQueryClient()
const pathParams = useParams('orgName', 'projectName')

const createDisk = useApiMutation('projectDisksPost', {
onSuccess(data) {
queryClient.invalidateQueries('projectDisksGet', parentNames)
queryClient.invalidateQueries('projectDisksGet', pathParams)
onSuccess?.(data)
},
onError,
Expand All @@ -50,7 +50,7 @@ export function CreateDiskForm({
onSubmit={
onSubmit ||
((body) => {
createDisk.mutate({ ...parentNames, body })
createDisk.mutate({ ...pathParams, body })
})
}
mutation={createDisk}
Expand All @@ -59,7 +59,6 @@ export function CreateDiskForm({
<NameField id="disk-name" />
<DescriptionField id="disk-description" />
<Divider />
<TextField id="disk-type" name="type" />
<RadioField column id="disk-source-type" name="sourceType">
<Radio value="blank">Blank disk</Radio>
<Radio value="image">Image</Radio>
Expand Down
1 change: 1 addition & 0 deletions app/forms/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import babel from '@babel/core'
import { traverse } from '@babel/core'
import fs from 'fs/promises'
import path from 'path'
import './index'

test('FormTypes must contain references to all forms', async () => {
let formIds: string[] = []
Expand Down
49 changes: 49 additions & 0 deletions app/forms/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
// TODO: Make these just be default exports

import type { CreateSubnetForm } from './subnet-create'
import type { EditSubnetForm } from './subnet-edit'
import type { CreateOrgForm } from './org-create'
import type { CreateDiskForm } from './disk-create'
import type { CreateProjectForm } from './project-create'
import type CreateInstanceForm from './instance-create'
import type AttachDiskForm from './disk-attach'

import type { FormProps } from '@oxide/form'
import type { ErrorResponse } from '@oxide/api'
import type { ComponentType } from 'react'

/**
* A map of all existing forms. When a new form is created in the forms directory, a
* new entry should be added here with the key of the string name of the form's filename
* and a value of the form's type. There's a test to validate that this happens.
*/
export interface FormTypes {
'instance-create': typeof CreateInstanceForm
'org-create': typeof CreateOrgForm
'project-create': typeof CreateProjectForm
'disk-attach': typeof AttachDiskForm
'disk-create': typeof CreateDiskForm
'subnet-create': typeof CreateSubnetForm
'subnet-edit': typeof EditSubnetForm
}

export type FormValues<K extends keyof FormTypes> = ExtractFormValues<
FormTypes[K]
>

/**
* A form that's built out ahead of time and intended to be re-used dynamically. Fields
* that are expected to be provided by default are set to optional.
*/
export type PrebuiltFormProps<Values, Data> = Omit<
Optional<
FormProps<Values>,
'id' | 'title' | 'initialValues' | 'onSubmit' | 'mutation'
>,
'children'
> & {
children?: never
onSuccess?: (data: Data) => void
onError?: (err: ErrorResponse) => void
}

/**
* A utility type for a prebuilt form that extends another form
*/
export type ExtendedPrebuiltFormProps<C, D = void> = C extends ComponentType<
infer B
>
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
B extends PrebuiltFormProps<infer V, any>
? PrebuiltFormProps<V, D>
: never
: never

export type ExtractFormValues<C> = C extends ComponentType<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PrebuiltFormProps<infer V, any>
>
? V
: never
Loading

1 comment on commit 610b945

@vercel
Copy link

@vercel vercel bot commented on 610b945 Apr 11, 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.