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
1 change: 1 addition & 0 deletions apps/web-console/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useBreadcrumbs } from './use-breadcrumbs'
export { useAsync } from './use-async'
55 changes: 55 additions & 0 deletions apps/web-console/src/hooks/use-async.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { useAsync } from './use-async'

describe('useAsync', () => {
it('starts with null data, null error, and pending false', () => {
const { result } = renderHook(() => useAsync(() => Promise.resolve(1)))

expect(result.current.data).toBeNull()
expect(result.current.error).toBeNull()
expect(result.current.pending).toBe(false)
})

it('with promise in flight, has pending true, null error, and null data', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(() => Promise.resolve(1))
)
act(() => {
result.current.run()
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

FYI because I fought with this quite a bit:

In order to catch the promise in-flight, this needs the braces, i.e., it cannot be

act(() => result.current.execute())

or

act(result.current.execute)

Because then it knows we're doing something async and warns that we have to put an await in front like this:

await act(result.current.execute)

But that ruins this test, because if we wait for the promise to complete, it is no longer pending.


expect(result.current.pending).toBe(true)
expect(result.current.data).toBeNull()
expect(result.current.error).toBeNull()

await waitForNextUpdate()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This await on the last line is unfortunately necessary to prevent warnings about updates happening outside of act. Basically without this, the promise resolves after the test is over, causing a render, and it doesn't like that.

})

it('after promise resolves, has data, null error, and pending false', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(() => Promise.resolve(1))
)
act(() => {
result.current.run()
})
await waitForNextUpdate()

expect(result.current.data).toBe(1)
expect(result.current.error).toBeNull()
expect(result.current.pending).toBe(false)
})

it('after promise rejects, has error, null data, and pending false', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(() => Promise.reject(1))
)
act(() => {
result.current.run()
})
await waitForNextUpdate()

expect(result.current.error).toBe(1)
expect(result.current.data).toBeNull()
expect(result.current.pending).toBe(false)
})
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Unlike the pending one, this one could actually be done like this:

const { result } = renderHook(() => useAsync(() => Promise.reject(1)))
await act(result.current.execute)

However, understanding the difference requires a pretty deep understanding of act born of struggle and suffering, so I prefer keeping a parallel structure and letting the different position of await waitForNextUpdate() tell the story.

44 changes: 44 additions & 0 deletions apps/web-console/src/hooks/use-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useState } from 'react'

// TODO: this is what the API currently response with for 400s, but it may not
// cover all API errors, and it definitely doesn't cover places where we might
// use useAsync for something other than a POST to our own API
interface ResponseError {
request_id: string
message: string
error_code: number | null
}

interface AsyncResult<R> {
run: () => Promise<void>
pending: boolean
data: R | null
error: ResponseError | null
}

export function useAsync<R>(asyncFunction: () => Promise<R>): AsyncResult<R> {
const [pending, setPending] = useState(false)
const [data, setData] = useState<R | null>(null)
const [error, setError] = useState<ResponseError | null>(null)

const run = async () => {
setPending(true)
setData(null)
setError(null)

try {
const response = await asyncFunction()
setData(response)
} catch (errorResponse) {
const error =
typeof errorResponse.json === 'function'
? await errorResponse.json()
: errorResponse
setError(error)
} finally {
setPending(false)
}
}

return { run, pending, data, error }
}
87 changes: 83 additions & 4 deletions apps/web-console/src/pages/instance/InstanceCreatePage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import React from 'react'
import type { ChangeEvent } from 'react'
import { useEffect } from 'react'
import React, { useState } from 'react'
import styled from 'styled-components'
import { useParams, useHistory } from 'react-router-dom'

import { Breadcrumbs, Icon, PageHeader, TextWithIcon } from '@oxide/ui'
import { useBreadcrumbs } from '../../hooks'
import {
Breadcrumbs,
Button,
Icon,
PageHeader,
NumberField,
TextField,
TextWithIcon,
} from '@oxide/ui'
import { useBreadcrumbs, useAsync } from '../../hooks'
import { api } from '@oxide/api'

const Title = styled(TextWithIcon).attrs({
text: { variant: 'title', as: 'h1' },
Expand All @@ -14,15 +26,82 @@ const Title = styled(TextWithIcon).attrs({
}
`

const Box = styled.div`
border: 1px solid white;
padding: 1rem;
`

const FormContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(4)};
${({ theme }) => theme.spaceBetweenY(4)}
`

type Params = {
projectName: string
}

const InstancesPage = () => {
const breadcrumbs = useBreadcrumbs()

const history = useHistory()
const { projectName } = useParams<Params>()

// form state
const [instanceName, setInstanceName] = useState('')
const [ncpus, setNcpus] = useState(1)

const createInstance = useAsync(() =>
api.apiProjectInstancesPost({
projectName,
apiInstanceCreateParams: {
bootDiskSize: 1,
description: `An instance in project: ${projectName}`,
hostname: 'oxide.com',
memory: 10,
name: instanceName,
ncpus,
},
})
)
Comment on lines +53 to +65
Copy link
Contributor

Choose a reason for hiding this comment

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

a little worried we might run into a closure async issue with this pattern, as the values in state may have been updated and this closure not re-calculated before being sent off ...

I would prefer to see the object currently being passed to apiProjectInstancesPost be passed to execute instead ... need to dig into this more however.

Copy link
Collaborator Author

@david-crespo david-crespo Apr 15, 2021

Choose a reason for hiding this comment

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

I think the version of this I wrote at my last job worked that way. Looks like the react-async version lets you do it either way, which is interesting.

Of the top of my head, I don't think it's a problem because execute itself is not actually being stored in state inside useAsync. So the version of execute you have on hand to call is always up to date with whatever the current values are.

I considered it doing it the other way, but what's nice about this way is it keeps the interface of useAsync very simple because it doesn't have to know anything about args.


const onCreateClick = async () => {
// TODO: validate client-side before attempting post
await createInstance.run()
}

// redirect on successful post
useEffect(() => {
if (createInstance.data) {
history.push(`/projects/${projectName}/instances`)
}
}, [createInstance.data, history, projectName])

return (
<>
<Breadcrumbs data={breadcrumbs} />
<PageHeader>
<Title>Create Instance</Title>
</PageHeader>
<p style={{ marginTop: '2rem' }}>There is nothing here, sorry</p>
<FormContainer>
<Box>Post error: {JSON.stringify(createInstance.error)}</Box>
<TextField
value={instanceName}
required
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setInstanceName(e.target.value)
}
placeholder="db1"
>
Instance name
</TextField>
<NumberField value={ncpus} handleChange={setNcpus}>
Number of CPUs
</NumberField>

<Button onClick={onCreateClick} disabled={createInstance.pending}>
Create instance
</Button>
</FormContainer>
</>
)
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web-console/src/pages/instance/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ const InstancesPage = () => {
))}
{data.items.length === 0 && <p>No instances!</p>}
</ul>
<Link
style={{ marginTop: '1rem' }}
to={`/projects/${projectName}/instances/new`}
>
Create instance
</Link>
</>
)
}
Expand Down
2 changes: 1 addition & 1 deletion libs/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const basePath =
process.env.NODE_ENV === 'production' ? process.env.API_URL : '/api'

const config = new Configuration({ basePath })
const api = new DefaultApi(config)
export const api = new DefaultApi(config)

export const useApi = getUseApi(api)

Expand Down
1 change: 1 addition & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './lib/layout/global-nav/GlobalNav'
export * from './lib/layout/sidebar-navigation/operation-list/OperationList'
export * from './lib/layout/sidebar-navigation/project-list/ProjectList'
export * from './lib/modal/Modal'
export * from './lib/number-field/NumberField'
export * from './lib/PageHeader'
export * from './lib/table/Table'
export * from './lib/tabs/Tabs'
Expand Down
2 changes: 1 addition & 1 deletion libs/ui/src/lib/text-field/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type TextFieldProps = StyledComponentProps<
* Additional text to associate with this specific field
*/
hint?: string | React.ReactNode
onChange?: ReactEventHandler
onChange?: ReactEventHandler<HTMLInputElement>
value?: string | number
},
never
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@storybook/react": "^6.2.5",
"@svgr/webpack": "^5.4.0",
"@testing-library/react": "11.1.2",
"@testing-library/react-hooks": "^5.1.1",
"@types/jest": "26.0.8",
"@types/node": "12.12.38",
"@types/reach__router": "^1.3.7",
Expand Down
40 changes: 39 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2893,6 +2893,18 @@
lz-string "^1.4.4"
pretty-format "^26.6.2"

"@testing-library/react-hooks@^5.1.1":
version "5.1.1"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-5.1.1.tgz#1fbaae8a4e8a4a7f97b176c23e1e890c41bbbfa5"
integrity sha512-52D2XnpelFDefnWpy/V6z2qGNj8JLIvW5DjYtelMvFXdEyWiykSaI7IXHwFy4ICoqXJDmmwHAiFRiFboub/U5g==
dependencies:
"@babel/runtime" "^7.12.5"
"@types/react" ">=16.9.0"
"@types/react-dom" ">=16.9.0"
"@types/react-test-renderer" ">=16.9.0"
filter-console "^0.1.1"
react-error-boundary "^3.1.0"

"@testing-library/react@11.1.2":
version "11.1.2"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.1.2.tgz#089b06d3828e76fc1ff0092dd69c7b59c454c998"
Expand Down Expand Up @@ -3281,6 +3293,13 @@
dependencies:
"@types/react" "*"

"@types/react-dom@>=16.9.0":
version "17.0.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.3.tgz#7fdf37b8af9d6d40127137865bb3fff8871d7ee1"
integrity sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==
dependencies:
"@types/react" "*"

"@types/react-is@16.7.1":
version "16.7.1"
resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.7.1.tgz#d3f1c68c358c00ce116b55ef5410cf486dd08539"
Expand Down Expand Up @@ -3319,6 +3338,13 @@
dependencies:
"@types/react" "*"

"@types/react-test-renderer@>=16.9.0":
version "17.0.1"
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b"
integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==
dependencies:
"@types/react" "*"

"@types/react-virtualized-auto-sizer@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272"
Expand All @@ -3333,7 +3359,7 @@
dependencies:
"@types/react" "*"

"@types/react@*":
"@types/react@*", "@types/react@>=16.9.0":
version "17.0.3"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79"
integrity sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg==
Expand Down Expand Up @@ -7259,6 +7285,11 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"

filter-console@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/filter-console/-/filter-console-0.1.1.tgz#6242be28982bba7415bcc6db74a79f4a294fa67c"
integrity sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==

finalhandler@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
Expand Down Expand Up @@ -11931,6 +11962,13 @@ react-element-to-jsx-string@^14.3.2:
"@base2/pretty-print-object" "1.0.0"
is-plain-object "3.0.1"

react-error-boundary@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.1.tgz#932c5ca5cbab8ec4fe37fd7b415aa5c3a47597e7"
integrity sha512-W3xCd9zXnanqrTUeViceufD3mIW8Ut29BUD+S2f0eO2XCOU8b6UrJfY46RDGe5lxCJzfe4j0yvIfh0RbTZhKJw==
dependencies:
"@babel/runtime" "^7.12.5"

react-error-overlay@^6.0.9:
version "6.0.9"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
Expand Down