diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx
index e47e505d7..7624ac961 100644
--- a/app/layouts/helpers.tsx
+++ b/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`
diff --git a/app/pages/ToastTestPage.tsx b/app/pages/ToastTestPage.tsx
index 9aa366b3e..99cad8231 100644
--- a/app/pages/ToastTestPage.tsx
+++ b/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)
diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx
index 63f89e3ed..4593c4964 100644
--- a/app/pages/project/instances/InstancesPage.tsx
+++ b/app/pages/project/instances/InstancesPage.tsx
@@ -46,7 +46,7 @@ export const InstancesPage = () => {
to={`/orgs/${orgName}/projects/${projectName}/instances/new`}
className={buttonStyle({ size: 'xs', variant: 'dim' })}
>
- new instance
+ New Instance
diff --git a/app/pages/project/instances/create/InstancesCreatePage.tsx b/app/pages/project/instances/create/InstancesCreatePage.tsx
index ff761cb37..71d2d4334 100644
--- a/app/pages/project/instances/create/InstancesCreatePage.tsx
+++ b/app/pages/project/instances/create/InstancesCreatePage.tsx
@@ -4,7 +4,6 @@ import cn from 'classnames'
import { Formik, Form } from 'formik'
import {
- classed,
Button,
PageHeader,
PageTitle,
@@ -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'
diff --git a/app/pages/project/networking/VpcPage/VpcPage.spec.ts b/app/pages/project/networking/VpcPage/VpcPage.spec.ts
new file mode 100644
index 000000000..93e78d9e2
--- /dev/null
+++ b/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 })
+ })
+ })
+})
diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx
index 5fd8b40e9..dddd2d1a0 100644
--- a/app/pages/project/networking/VpcPage/VpcPage.tsx
+++ b/app/pages/project/networking/VpcPage/VpcPage.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+import { format } from 'date-fns'
import {
Networking24Icon,
PageHeader,
@@ -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 (
<>
}>
- {vpcName}
+ {vpcParams.vpcName}
- Default network for the project
+ {vpc?.description}
- frontend-production-vpc
+ {vpc?.dnsName}
- Default network for the project
+ {vpc?.timeCreated && formatDateTime(vpc.timeCreated)}
- Default network for the project
+ {vpc?.timeModified && formatDateTime(vpc.timeModified)}
diff --git a/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx b/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx
new file mode 100644
index 000000000..035732654
--- /dev/null
+++ b/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) => (
+
+)
+
+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 (
+
+ {
+ createRouter.mutate({
+ ...parentIds,
+ body: { name, description },
+ })
+ }}
+ >
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+ {
+ updateRouter.mutate({
+ ...parentIds,
+ routerName: originalRouter.identity.name,
+ body: { name, description },
+ })
+ }}
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx b/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx
new file mode 100644
index 000000000..e917f6e9a
--- /dev/null
+++ b/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx
@@ -0,0 +1,201 @@
+import React from 'react'
+import { Formik, Form } from 'formik'
+
+import { Button, FieldTitle, SideModal, TextField } from '@oxide/ui'
+import type { VpcSubnet, ErrorResponse } from '@oxide/api'
+import { useApiMutation, useApiQueryClient } from '@oxide/api'
+import { getServerError } from '../../../../../util/errors'
+
+type FormProps = {
+ error: ErrorResponse | null
+ id: string
+}
+
+// the moment the two forms diverge, inline them rather than introducing BS
+// props here
+const CommonForm = ({ id, error }: FormProps) => (
+
+)
+
+type CreateProps = {
+ isOpen: boolean
+ onDismiss: () => void
+ orgName: string
+ projectName: string
+ vpcName: string
+}
+
+export function CreateVpcSubnetModal({
+ isOpen,
+ onDismiss,
+ orgName,
+ projectName,
+ vpcName,
+}: CreateProps) {
+ const parentIds = { orgName, projectName, vpcName }
+ const queryClient = useApiQueryClient()
+
+ function dismiss() {
+ createSubnet.reset()
+ onDismiss()
+ }
+
+ const createSubnet = useApiMutation('vpcSubnetsPost', {
+ onSuccess() {
+ queryClient.invalidateQueries('vpcSubnetsGet', parentIds)
+ dismiss()
+ },
+ })
+
+ const formId = 'create-vpc-subnet-form'
+
+ return (
+
+ {
+ createSubnet.mutate({
+ ...parentIds,
+ // XXX body is optional. useApiMutation should be smarter and require body when it's required
+ body: {
+ name,
+ description,
+ // TODO: validate these client-side using the patterns. sadly non-trivial
+ ipv4Block: ipv4Block || null,
+ ipv6Block: ipv6Block || null,
+ },
+ })
+ }}
+ >
+
+
+
+
+
+
+
+ )
+}
+
+type EditProps = {
+ onDismiss: () => void
+ orgName: string
+ projectName: string
+ vpcName: string
+ originalSubnet: VpcSubnet | null
+}
+
+export function EditVpcSubnetModal({
+ onDismiss,
+ orgName,
+ projectName,
+ vpcName,
+ originalSubnet,
+}: EditProps) {
+ const parentIds = { orgName, projectName, vpcName }
+ const queryClient = useApiQueryClient()
+
+ function dismiss() {
+ updateSubnet.reset()
+ onDismiss()
+ }
+
+ const updateSubnet = useApiMutation('vpcSubnetsPutSubnet', {
+ onSuccess() {
+ queryClient.invalidateQueries('vpcSubnetsGet', parentIds)
+ dismiss()
+ },
+ })
+
+ if (!originalSubnet) return null
+
+ const formId = 'edit-vpc-subnet-form'
+ return (
+
+ {
+ updateSubnet.mutate({
+ ...parentIds,
+ subnetName: originalSubnet.identity.name,
+ body: {
+ name,
+ description,
+ // TODO: validate these client-side using the patterns. sadly non-trivial
+ ipv4Block: ipv4Block || null,
+ ipv6Block: ipv6Block || null,
+ },
+ })
+ }}
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
index 63ecb6039..e2eca6c21 100644
--- a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
+++ b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
@@ -1,22 +1,57 @@
-import React from 'react'
+import React, { useState } from 'react'
import { useParams } from '../../../../../hooks'
+import { Button } from '@oxide/ui'
+import type { MenuAction } from '@oxide/table'
import { useQueryTable, DateCell, LabelCell } from '@oxide/table'
+import type { VpcRouter } from '@oxide/api'
+import { CreateVpcRouterModal, EditVpcRouterModal } from '../modals/vpc-routers'
export const VpcRoutersTab = () => {
const vpcParams = useParams('orgName', 'projectName', 'vpcName')
const { Table, Column } = useQueryTable('vpcRoutersGet', vpcParams)
+ const [createModalOpen, setCreateModalOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+
+ const actions = (router: VpcRouter): MenuAction[] => [
+ {
+ label: 'Edit',
+ onActivate: () => setEditing(router),
+ },
+ ]
+
return (
-
+ <>
+
+
+ setCreateModalOpen(false)}
+ />
+ setEditing(null)}
+ />
+
+
+ >
)
}
diff --git a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
index de5395889..45fb2ea7f 100644
--- a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
+++ b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
@@ -1,22 +1,57 @@
-import React from 'react'
+import React, { useState } from 'react'
import { useParams } from '../../../../../hooks'
+import type { MenuAction } from '@oxide/table'
import { useQueryTable, TwoLineCell, DateCell } from '@oxide/table'
+import { Button } from '@oxide/ui'
+import { CreateVpcSubnetModal, EditVpcSubnetModal } from '../modals/vpc-subnets'
+import type { VpcSubnet } from '@oxide/api'
export const VpcSubnetsTab = () => {
const vpcParams = useParams('orgName', 'projectName', 'vpcName')
const { Table, Column } = useQueryTable('vpcSubnetsGet', vpcParams)
+ const [createModalOpen, setCreateModalOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+
+ const actions = (subnet: VpcSubnet): MenuAction[] => [
+ {
+ label: 'Edit',
+ onActivate: () => setEditing(subnet),
+ },
+ ]
+
return (
-
-
- [vpc.ipv4_block, vpc.ipv6_block]}
- cell={TwoLineCell}
- />
-
-
+ <>
+
+
+ setCreateModalOpen(false)}
+ />
+ setEditing(null)}
+ />
+
+
+
+ [vpc.ipv4_block, vpc.ipv6_block]}
+ cell={TwoLineCell}
+ />
+
+
+ >
)
}
diff --git a/app/test-utils.tsx b/app/test-utils.tsx
index a19fa573b..2da931a4f 100644
--- a/app/test-utils.tsx
+++ b/app/test-utils.tsx
@@ -38,4 +38,5 @@ export const lastPostBody = (mock: FetchMockStatic): any =>
JSON.parse(mock.lastOptions(undefined, 'POST')?.body as unknown as string)
export * from '@testing-library/react'
+export { default as userEvent } from '@testing-library/user-event'
export { customRender as render }
diff --git a/libs/api-mocks/index.ts b/libs/api-mocks/index.ts
index 3a64ad4f9..2bfdd793c 100644
--- a/libs/api-mocks/index.ts
+++ b/libs/api-mocks/index.ts
@@ -2,3 +2,4 @@ export * from './instance'
export * from './org'
export * from './project'
export * from './users'
+export * from './vpc'
diff --git a/libs/api-mocks/vpc.ts b/libs/api-mocks/vpc.ts
new file mode 100644
index 000000000..5321c6891
--- /dev/null
+++ b/libs/api-mocks/vpc.ts
@@ -0,0 +1,41 @@
+import { project } from './project'
+import type { Vpc, VpcSubnet, VpcSubnetResultsPage } from '@oxide/api'
+
+export const vpc: Vpc = {
+ id: 'vpc-id',
+ name: 'mock-vpc',
+ description: 'a fake vpc',
+ dnsName: 'mock-vpc',
+ timeCreated: new Date(2021, 0, 1).toISOString(),
+ timeModified: new Date(2021, 0, 2).toISOString(),
+ projectId: project.id,
+ systemRouterId: 'router-id', // ???
+}
+
+export const vpcSubnet: VpcSubnet = {
+ // this is supposed to be flattened into the top level. will fix in API
+ identity: {
+ id: 'vpc-subnet-id',
+ name: 'mock-subnet',
+ description: 'a fake subnet',
+ timeCreated: new Date(2021, 0, 1).toISOString(),
+ timeModified: new Date(2021, 0, 2).toISOString(),
+ },
+ // supposed to be camelcase, will fix in API
+ vpc_id: vpc.id,
+ ipv4_block: '1.1.1.1/24',
+}
+
+export const vpcSubnet2: VpcSubnet = {
+ identity: {
+ ...vpcSubnet.identity,
+ id: 'vpc-subnet-id-2',
+ name: 'mock-subnet-2',
+ },
+ vpc_id: vpc.id,
+ ipv4_block: '1.1.1.2/24',
+}
+
+export const vpcSubnets: VpcSubnetResultsPage = {
+ items: [vpcSubnet],
+}
diff --git a/libs/api/index.ts b/libs/api/index.ts
index a08f96337..814521256 100644
--- a/libs/api/index.ts
+++ b/libs/api/index.ts
@@ -11,7 +11,14 @@ export type { ErrorResponse, Params, Result, ResultItem } from './hooks'
const basePath = process.env.API_URL ?? '/api'
-const api = new Api({ baseUrl: basePath })
+const api = new Api({
+ baseUrl: basePath,
+ baseApiParams: {
+ // default format so that the client parses JSON error responses for endpoints
+ // where the success response is 204 No Content
+ format: 'json',
+ },
+})
type A = typeof api.methods
diff --git a/libs/ui/index.ts b/libs/ui/index.ts
index 60f78a635..23a847929 100644
--- a/libs/ui/index.ts
+++ b/libs/ui/index.ts
@@ -22,5 +22,3 @@ export * from './lib/tabs/Tabs'
export * from './lib/table/Table'
export * from './lib/tooltip/Tooltip'
export * from './lib/toast/Toast'
-
-export * from './util/classed'
diff --git a/libs/ui/lib/PageHeader.tsx b/libs/ui/lib/PageHeader.tsx
index 12615226f..32ac158a2 100644
--- a/libs/ui/lib/PageHeader.tsx
+++ b/libs/ui/lib/PageHeader.tsx
@@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import { cloneElement } from 'react'
import React from 'react'
-import { classed } from '../util/classed'
+import { classed } from '@oxide/util'
export const PageHeader = classed.header`flex items-center justify-between mb-16 mt-4`
diff --git a/libs/ui/lib/checkbox/Checkbox.tsx b/libs/ui/lib/checkbox/Checkbox.tsx
index 902bf9ffd..df492f2f1 100644
--- a/libs/ui/lib/checkbox/Checkbox.tsx
+++ b/libs/ui/lib/checkbox/Checkbox.tsx
@@ -1,7 +1,7 @@
import { Checkmark12Icon } from '@oxide/ui'
import React from 'react'
-import { classed } from '../../util/classed'
+import { classed } from '@oxide/util'
const Check = () => (
diff --git a/libs/ui/lib/radio-group/RadioGroup.tsx b/libs/ui/lib/radio-group/RadioGroup.tsx
index fbdddce48..53273e6df 100644
--- a/libs/ui/lib/radio-group/RadioGroup.tsx
+++ b/libs/ui/lib/radio-group/RadioGroup.tsx
@@ -43,7 +43,7 @@
import React from 'react'
import cn from 'classnames'
-import { classed } from '../../util/classed'
+import { classed } from '@oxide/util'
export const RadioGroupHint = classed.p`text-base text-gray-100 font-sans font-light max-w-3xl`
diff --git a/libs/ui/lib/side-modal/SideModal.tsx b/libs/ui/lib/side-modal/SideModal.tsx
index 8a3d7a793..04e25e32f 100644
--- a/libs/ui/lib/side-modal/SideModal.tsx
+++ b/libs/ui/lib/side-modal/SideModal.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import type { DialogProps } from '@reach/dialog'
import Dialog from '@reach/dialog'
import { Button } from '../button/Button'
-import { pluckFirstOfType } from '@oxide/util'
+import { classed, pluckFirstOfType } from '@oxide/util'
import type { ChildrenProp } from '@oxide/util'
import { Close12Icon } from '../icons'
@@ -27,7 +27,7 @@ export function SideModal({
id={id}
onDismiss={onDismiss}
{...dialogProps}
- className="absolute right-0 top-0 bottom-0 w-[32rem] p-0 m-0 flex flex-col justify-between bg-gray-500 border-l border-gray-400"
+ className="absolute right-0 top-0 bottom-0 w-[32rem] p-0 m-0 flex flex-col justify-between bg-black border-l border-gray-400"
aria-labelledby={titleId}
>
(
-
{children}
-)
+SideModal.Section = classed.div`p-8 space-y-6 border-gray-400`
SideModal.Docs = ({ children }: ChildrenProp) => (
diff --git a/libs/ui/util/classed.ts b/libs/util/classed.ts
similarity index 100%
rename from libs/ui/util/classed.ts
rename to libs/util/classed.ts
diff --git a/libs/util/index.ts b/libs/util/index.ts
index 985449f48..a937e615f 100644
--- a/libs/util/index.ts
+++ b/libs/util/index.ts
@@ -1,3 +1,4 @@
+export * from './classed'
export * from './str'
export * from './invariant'
export * from './object'
diff --git a/package.json b/package.json
index 4959c38f5..359a69e2f 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"build": "vite build",
"build-for-nexus": "yarn install && API_URL='' vite build",
"ci": "yarn tsc && yarn lint && yarn test",
- "test": "jest",
+ "test": "DEBUG_PRINT_LIMIT=0 jest",
"lint": "eslint --ext .js,.ts,.tsx,.json .",
"fmt": "prettier --write",
"gen": "plop",
@@ -70,9 +70,11 @@
"@storybook/manager-webpack5": "^6.4.0-rc.1",
"@storybook/react": "^6.4.0-rc.1",
"@storybook/theming": "^6.4.0-rc.1",
+ "@testing-library/dom": "^8.11.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/react-hooks": "^7.0.1",
+ "@testing-library/user-event": "^13.5.0",
"@types/jest": "^26.0.23",
"@types/jscodeshift": "^0.11.2",
"@types/mousetrap": "^1.6.8",
diff --git a/yarn.lock b/yarn.lock
index ef4d5719c..b98894fdb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3201,6 +3201,20 @@
lz-string "^1.4.4"
pretty-format "^27.0.2"
+"@testing-library/dom@^8.11.1":
+ version "8.11.1"
+ resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753"
+ integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==
+ dependencies:
+ "@babel/code-frame" "^7.10.4"
+ "@babel/runtime" "^7.12.5"
+ "@types/aria-query" "^4.2.0"
+ aria-query "^5.0.0"
+ chalk "^4.1.0"
+ dom-accessibility-api "^0.5.9"
+ lz-string "^1.4.4"
+ pretty-format "^27.0.2"
+
"@testing-library/jest-dom@^5.14.1":
version "5.14.1"
resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.14.1.tgz"
@@ -3235,6 +3249,13 @@
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0"
+"@testing-library/user-event@^13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295"
+ integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz"
@@ -4360,6 +4381,11 @@ aria-query@^4.2.2:
"@babel/runtime" "^7.10.2"
"@babel/runtime-corejs3" "^7.10.2"
+aria-query@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c"
+ integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==
+
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz"
@@ -6384,6 +6410,11 @@ dom-accessibility-api@^0.5.6:
resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz"
integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==
+dom-accessibility-api@^0.5.9:
+ version "0.5.10"
+ resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c"
+ integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==
+
dom-converter@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz"