Skip to content
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

Adds server error messages to form actions bar #788

Merged
merged 14 commits into from
Apr 21, 2022
19 changes: 17 additions & 2 deletions app/components/form/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ButtonProps } from '@oxide/ui'
import { Error12Icon } from '@oxide/ui'
import { Button } from '@oxide/ui'
import { SideModal } from '@oxide/ui'
import { useIsInSideModal } from '@oxide/ui'
Expand All @@ -18,7 +19,7 @@ import React, { cloneElement } from 'react'
import invariant from 'tiny-invariant'
import './form.css'
import cn from 'classnames'
import type { ErrorResponse } from '@oxide/api'
import type { Error, ErrorResponse } from '@oxide/api'

const PageActionsTunnel = tunnel('form-page-actions')
const SideModalActionsTunnel = tunnel('form-sidebar-actions')
Expand Down Expand Up @@ -90,6 +91,7 @@ export function Form<Values>({
formId: id,
submitDisabled:
!props.dirty || !props.isValid || mutation.status === 'loading',
error: mutation.error?.error,
onDismiss,
})}
</SideModalActionsTunnel.In>
Expand All @@ -100,6 +102,7 @@ export function Form<Values>({
formId: id,
submitDisabled:
!props.dirty || !props.isValid || mutation.status === 'loading',
error: mutation.error?.error,
})}
</PageActionsContainer>
</PageActionsTunnel.In>
Expand All @@ -122,6 +125,7 @@ interface FormActionsProps {
children: React.ReactNode
submitDisabled?: boolean
onDismiss?: () => void
error?: Error | null
}

/**
Expand All @@ -135,6 +139,7 @@ Form.Actions = ({
formId,
submitDisabled = true,
onDismiss,
error,
}: FormActionsProps) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const isSideModal = useIsInSideModal()
Expand All @@ -157,10 +162,20 @@ Form.Actions = ({
invariant(submit, 'Form.Actions must contain a Form.Submit component')

return (
<div className={cn('flex gap-[0.625rem]', { 'flex-row-reverse': isSideModal })}>
<div
className={cn('flex w-full items-center gap-[0.625rem] children:shrink-0', {
'flex-row-reverse': isSideModal,
})}
>
{cloneElement(submit, { form: formId, disabled: submitDisabled })}
{isSideModal && cancel && cloneElement(cancel, { onClick: onDismiss })}
{childArray}
{error && (
<div className="flex !shrink grow items-start justify-end text-mono-sm text-error">
<Error12Icon className="mx-2 mt-0.5 shrink-0" />
<span>{error.message}</span>
</div>
)}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
useApiQueryClient,
firewallRuleGetToPut,
} from '@oxide/api'
import { getServerError } from 'app/util/errors'

type FormProps = {
error: ErrorResponse | null
Expand Down Expand Up @@ -352,7 +351,7 @@ const CommonForm = ({ id, error }: FormProps) => {
</fieldset>
</SideModal.Section>
<SideModal.Section>
<div className="text-destructive">{getServerError(error)}</div>
<div className="text-destructive">{error?.error.message}</div>
Comment on lines -355 to +354
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This'll go away once we convert this form

</SideModal.Section>
</Form>
)
Expand Down
3 changes: 1 addition & 2 deletions app/pages/project/networking/VpcPage/modals/vpc-routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Formik, Form } from 'formik'
import { Button, FieldLabel, SideModal_old as SideModal, TextField } from '@oxide/ui'
import type { VpcRouter, ErrorResponse } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { getServerError } from 'app/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
Expand Down Expand Up @@ -41,7 +40,7 @@ const CommonForm = ({ error, id }: FormProps) => (
</div>
</SideModal.Section>
<SideModal.Section>
<div className="text-destructive">{getServerError(error)}</div>
<div className="text-destructive">{error?.error.message}</div>
</SideModal.Section>
</Form>
)
Expand Down
65 changes: 0 additions & 65 deletions app/util/errors.spec.ts

This file was deleted.

38 changes: 0 additions & 38 deletions app/util/errors.ts

This file was deleted.

8 changes: 4 additions & 4 deletions libs/api/__tests__/hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('useApiQuery', () => {
const { result } = renderGetOrg503()

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ServiceUnavailable',
})
)
Expand Down Expand Up @@ -82,7 +82,7 @@ describe('useApiQuery', () => {
await waitFor(() => {
const error = result.error as ErrorResponse | undefined
expect(error?.status).toEqual(404)
expect(error?.error).toEqual({ errorCode: 'ObjectNotFound' })
expect(error?.error).toMatchObject({ errorCode: 'ObjectNotFound' })
})
})

Expand All @@ -98,7 +98,7 @@ describe('useApiQuery', () => {
)

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ObjectNotFound',
})
)
Expand Down Expand Up @@ -155,7 +155,7 @@ describe('useApiMutation', () => {
act(() => result.current.mutate(projectPost404Params))

await waitFor(() =>
expect(result.current.error?.error).toEqual({
expect(result.current.error?.error).toMatchObject({
errorCode: 'ObjectNotFound',
})
)
Expand Down
114 changes: 114 additions & 0 deletions libs/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { navToLogin } from './nav-to-login'
import type { ApiMethods, ErrorResponse, Error } from '.'
import { camelCaseToWords, capitalize } from '@oxide/util'

const errorCodeFormatter =
(method: keyof ApiMethods) =>
(errorCode: string, _: Error): string | undefined => {
switch (errorCode) {
case 'Forbidden':
return 'Action not authorized'

// TODO: This is a temporary fix for the API; better messages should be provided from there
case 'ObjectAlreadyExists':
if (method.endsWith('Post')) {
let resource = camelCaseToWords(method).slice(-2)[0]
resource = resource.endsWith('s') ? resource.slice(0, -1) : resource
return `${resource} name already exists`
}
return undefined
default:
return undefined
}
}

export const handleErrors = (method: keyof ApiMethods) => (resp: ErrorResponse) => {
// TODO is this a valid failure condition?
if (!resp) throw 'unknown server error'

// if logged out, hit /login to trigger login redirect
if (resp.status === 401) {
// TODO-usability: for background requests, a redirect to login without
// warning could come as a surprise to the user, especially because
// sometimes background requests are not directly triggered by a user
// action, e.g., polling or refetching when window regains focus
navToLogin({ includeCurrent: true })
}
// we need to rethrow because that's how react-query knows it's an error
throw formatServerError(resp, errorCodeFormatter(method))
}

function formatServerError(
resp: ErrorResponse,
msgFromCode: (errorCode: string, error: Error) => string | undefined
): ErrorResponse {
const code = resp.error.errorCode
const codeMsg = code && msgFromCode(code, resp.error)
const serverMsg = resp.error.message

resp.error.message =
codeMsg || getParseError(serverMsg) || serverMsg || 'Unknown server error'

return resp
}

function getParseError(message: string | undefined): string | undefined {
if (!message) return undefined
const inner = /^unable to parse body: (.+) at line \d+ column \d+$/.exec(message)?.[1]
return inner && capitalize(inner)
}

// -- TESTS ----------------

if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
const parseError = {
error: {
requestId: '1',
errorCode: null,
message: 'unable to parse body: hello there, you have an error at line 129 column 4',
},
} as ErrorResponse

const alreadyExists = {
error: {
requestId: '2',
errorCode: 'ObjectAlreadyExists',
message: 'whatever',
},
} as ErrorResponse

describe('getParseError', () => {
it('extracts nice part of error message', () => {
expect(getParseError(parseError.error.message)).toEqual(
'Hello there, you have an error'
)
})

it('returns undefined if error does not match pattern', () => {
expect(getParseError('some nonsense')).toBeUndefined()
})
})

describe('getServerError', () => {
it('extracts message from parse errors', () => {
expect(formatServerError(parseError, () => undefined).error.message).toEqual(
'Hello there, you have an error'
)
})

it('uses message from code map if error code matches', () => {
expect(
formatServerError(alreadyExists, (code) =>
code === 'ObjectAlreadyExists' ? 'that already exists' : undefined
)
).toEqual('that already exists')
})

it('falls back to server error message if code not found', () => {
expect(formatServerError(alreadyExists, () => undefined).error.message).toEqual(
'whatever'
)
})
})
}
Loading