-
Notifications
You must be signed in to change notification settings - Fork 15
useAsync hook for create instance POST #245
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
Changes from all commits
3d14949
1b9c19f
7c27130
68a81be
3e0f55f
cb9457e
2f09cd2
0843b10
4393989
a24b8fa
50c57c4
157c38e
606df6a
dc9c232
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export { useBreadcrumbs } from './use-breadcrumbs' | ||
| export { useAsync } from './use-async' |
| 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() | ||
| }) | ||
|
|
||
| expect(result.current.pending).toBe(true) | ||
| expect(result.current.data).toBeNull() | ||
| expect(result.current.error).toBeNull() | ||
|
|
||
| await waitForNextUpdate() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
| }) | ||
|
|
||
| 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) | ||
| }) | ||
| }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 } | ||
| } |
| 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' }, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I considered it doing it the other way, but what's nice about this way is it keeps the interface of |
||
|
|
||
| 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> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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
or
Because then it knows we're doing something async and warns that we have to put an
awaitin front like this:But that ruins this test, because if we wait for the promise to complete, it is no longer pending.