diff --git a/apps/web-console/src/hooks/index.ts b/apps/web-console/src/hooks/index.ts index af5c533915..5bfa1dcee1 100644 --- a/apps/web-console/src/hooks/index.ts +++ b/apps/web-console/src/hooks/index.ts @@ -1 +1,2 @@ export { useBreadcrumbs } from './use-breadcrumbs' +export { useAsync } from './use-async' diff --git a/apps/web-console/src/hooks/use-async.spec.ts b/apps/web-console/src/hooks/use-async.spec.ts new file mode 100644 index 0000000000..4985cd2270 --- /dev/null +++ b/apps/web-console/src/hooks/use-async.spec.ts @@ -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() + }) + + 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) + }) +}) diff --git a/apps/web-console/src/hooks/use-async.ts b/apps/web-console/src/hooks/use-async.ts new file mode 100644 index 0000000000..8402764e47 --- /dev/null +++ b/apps/web-console/src/hooks/use-async.ts @@ -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 { + run: () => Promise + pending: boolean + data: R | null + error: ResponseError | null +} + +export function useAsync(asyncFunction: () => Promise): AsyncResult { + const [pending, setPending] = useState(false) + const [data, setData] = useState(null) + const [error, setError] = useState(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 } +} diff --git a/apps/web-console/src/pages/instance/InstanceCreatePage.tsx b/apps/web-console/src/pages/instance/InstanceCreatePage.tsx index c1445fa674..8eb29fcb22 100644 --- a/apps/web-console/src/pages/instance/InstanceCreatePage.tsx +++ b/apps/web-console/src/pages/instance/InstanceCreatePage.tsx @@ -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() + + // 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, + }, + }) + ) + + 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 ( <> Create Instance -

There is nothing here, sorry

+ + Post error: {JSON.stringify(createInstance.error)} + ) => + setInstanceName(e.target.value) + } + placeholder="db1" + > + Instance name + + + Number of CPUs + + + + ) } diff --git a/apps/web-console/src/pages/instance/InstancesPage.tsx b/apps/web-console/src/pages/instance/InstancesPage.tsx index de3df2efc1..abcd3a3905 100644 --- a/apps/web-console/src/pages/instance/InstancesPage.tsx +++ b/apps/web-console/src/pages/instance/InstancesPage.tsx @@ -40,6 +40,12 @@ const InstancesPage = () => { ))} {data.items.length === 0 &&

No instances!

} + + Create instance + ) } diff --git a/libs/api/index.ts b/libs/api/index.ts index c360cdb02d..0974ebf5d3 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -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) diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 4370ebddec..a3bde5d1cf 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -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' diff --git a/libs/ui/src/lib/text-field/TextField.tsx b/libs/ui/src/lib/text-field/TextField.tsx index e06553a82c..720748e685 100644 --- a/libs/ui/src/lib/text-field/TextField.tsx +++ b/libs/ui/src/lib/text-field/TextField.tsx @@ -29,7 +29,7 @@ export type TextFieldProps = StyledComponentProps< * Additional text to associate with this specific field */ hint?: string | React.ReactNode - onChange?: ReactEventHandler + onChange?: ReactEventHandler value?: string | number }, never diff --git a/package.json b/package.json index 1f74b0e98a..b927c29927 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index f5beb24cac..312d137ab9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" @@ -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== @@ -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" @@ -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"