fix: error messages from workspaceScheduleXService (#3255)

* Update color palette

* Edit dialog error colors

* Format

* Lighten links

* Lighten link just in ErrorSummary

* Format

* Fix errors in schedule xservice

* Add error summary to form for generic message

* Format

* Extend getFormHelpers to remap field name

* Add mock error and use in storybook

* Format
This commit is contained in:
Presley Pizzo
2022-07-28 13:18:51 -04:00
committed by GitHub
parent 1a8cce27ae
commit 73f145e45f
7 changed files with 76 additions and 42 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export const Language = {
},
}
interface FieldError {
export interface FieldError {
field: string
detail: string
}
@@ -1,9 +1,9 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { makeMockApiError } from "testHelpers/entities"
import {
defaultWorkspaceSchedule,
WorkspaceScheduleForm,
@@ -17,6 +17,14 @@ dayjs.extend(timezone)
export default {
title: "components/WorkspaceScheduleForm",
component: WorkspaceScheduleForm,
argTypes: {
onCancel: {
action: "onCancel",
},
onSubmit: {
action: "onSubmit",
},
},
}
const Template: Story<WorkspaceScheduleFormProps> = (args) => <WorkspaceScheduleForm {...args} />
@@ -27,8 +35,6 @@ WorkspaceWillNotShutDown.args = {
...defaultWorkspaceSchedule(5),
ttl: 0,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownInAnHour = Template.bind({})
@@ -37,8 +43,6 @@ WorkspaceWillShutdownInAnHour.args = {
...defaultWorkspaceSchedule(5),
ttl: 1,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownInTwoHours = Template.bind({})
@@ -47,8 +51,6 @@ WorkspaceWillShutdownInTwoHours.args = {
...defaultWorkspaceSchedule(2),
ttl: 2,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownInADay = Template.bind({})
@@ -57,8 +59,6 @@ WorkspaceWillShutdownInADay.args = {
...defaultWorkspaceSchedule(2),
ttl: 24,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownInTwoDays = Template.bind({})
@@ -67,6 +67,13 @@ WorkspaceWillShutdownInTwoDays.args = {
...defaultWorkspaceSchedule(2),
ttl: 48,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WithError = Template.bind({})
WithError.args = {
initialTouched: { ttl: true },
submitScheduleError: makeMockApiError({
message: "Something went wrong.",
validations: [{ field: "ttl_ms", detail: "Invalid time until shutdown." }],
}),
}
@@ -7,17 +7,17 @@ import FormLabel from "@material-ui/core/FormLabel"
import MenuItem from "@material-ui/core/MenuItem"
import makeStyles from "@material-ui/core/styles/makeStyles"
import TextField from "@material-ui/core/TextField"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { useFormik } from "formik"
import { FormikTouched, useFormik } from "formik"
import { FC } from "react"
import * as Yup from "yup"
import { FieldErrors } from "../../api/errors"
import { getFormHelpers } from "../../util/formUtils"
import { getFormHelpersWithError } from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter"
import { FullPageForm } from "../FullPageForm/FullPageForm"
import { Stack } from "../Stack/Stack"
@@ -54,11 +54,13 @@ export const Language = {
}
export interface WorkspaceScheduleFormProps {
fieldErrors?: FieldErrors
submitScheduleError?: Error | unknown
initialValues?: WorkspaceScheduleFormValues
isLoading: boolean
onCancel: () => void
onSubmit: (values: WorkspaceScheduleFormValues) => void
// for storybook
initialTouched?: FormikTouched<WorkspaceScheduleFormValues>
}
export interface WorkspaceScheduleFormValues {
@@ -178,11 +180,12 @@ export const defaultWorkspaceSchedule = (
})
export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
fieldErrors,
submitScheduleError,
initialValues = defaultWorkspaceSchedule(),
isLoading,
onCancel,
onSubmit,
initialTouched,
}) => {
const styles = useStyles()
@@ -190,8 +193,12 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
initialValues,
onSubmit,
validationSchema,
initialTouched,
})
const formHelpers = getFormHelpers<WorkspaceScheduleFormValues>(form, fieldErrors)
const formHelpers = getFormHelpersWithError<WorkspaceScheduleFormValues>(
form,
submitScheduleError,
)
const checkboxes: Array<{ value: boolean; name: string; label: string }> = [
{ value: form.values.sunday, name: "sunday", label: Language.daySundayLabel },
@@ -207,6 +214,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
<FullPageForm onCancel={onCancel} title="Workspace schedule">
<form onSubmit={form.handleSubmit} className={styles.form}>
<Stack>
{submitScheduleError && <ErrorSummary error={submitScheduleError} />}
<TextField
{...formHelpers("startTime", Language.startTimeHelperText)}
disabled={isLoading}
@@ -262,7 +270,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
</FormControl>
<TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl))}
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
disabled={isLoading}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
@@ -28,6 +28,8 @@ dayjs.extend(timezone)
const Language = {
forbiddenError: "You don't have permissions to update the schedule for this workspace.",
getWorkspaceError: "Failed to fetch workspace.",
checkPermissionsError: "Failed to fetch permissions.",
}
export const formValuesToAutoStartRequest = (
@@ -156,7 +158,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
userId: me?.id,
},
})
const { checkPermissionsError, formErrors, getWorkspaceError, permissions, workspace } =
const { checkPermissionsError, submitScheduleError, getWorkspaceError, permissions, workspace } =
scheduleState.context
// Get workspace on mount and whenever the args for getting a workspace change.
@@ -183,6 +185,9 @@ export const WorkspaceSchedulePage: React.FC = () => {
return (
<ErrorSummary
error={getWorkspaceError || checkPermissionsError}
defaultMessage={
getWorkspaceError ? Language.getWorkspaceError : Language.checkPermissionsError
}
retry={() => scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })}
/>
)
@@ -195,7 +200,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
return (
<WorkspaceScheduleForm
fieldErrors={formErrors}
submitScheduleError={submitScheduleError}
initialValues={workspaceToInitialValues(workspace, dayjs.tz.guess())}
isLoading={scheduleState.tags.has("loading")}
onCancel={() => {
+21
View File
@@ -1,3 +1,4 @@
import { FieldError } from "api/errors"
import * as Types from "../api/types"
import * as TypesGen from "../api/typesGenerated"
@@ -584,3 +585,23 @@ export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [
export const MockCancellationMessage = {
message: "Job successfully canceled",
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const makeMockApiError = ({
message,
detail,
validations,
}: {
message?: string
detail?: string
validations?: FieldError[]
}) => ({
response: {
data: {
message: message ?? "Something went wrong.",
detail: detail ?? undefined,
validations: validations ?? undefined,
},
},
isAxiosError: true,
})
+8 -6
View File
@@ -25,19 +25,21 @@ interface FormHelpers {
helperText?: ReactNode
}
// backendErrorName can be used if the backend names a field differently than the frontend does
export const getFormHelpers =
<T>(form: FormikContextType<T>, formErrors?: FormikErrors<T>) =>
(name: keyof T, HelperText: ReactNode = ""): FormHelpers => {
<T>(form: FormikContextType<T>, apiValidationErrors?: FormikErrors<T>) =>
(name: keyof T, HelperText: ReactNode = "", backendErrorName?: string): FormHelpers => {
if (typeof name !== "string") {
throw new Error(`name must be type of string, instead received '${typeof name}'`)
}
const apiErrorName = backendErrorName ?? name
// getIn is a util function from Formik that gets at any depth of nesting
// and is necessary for the types to work
const touched = getIn(form.touched, name)
const apiError = getIn(formErrors, name)
const validationError = getIn(form.errors, name)
const error = apiError ?? validationError
const apiError = getIn(apiValidationErrors, apiErrorName)
const frontendError = getIn(form.errors, name)
const error = apiError ?? frontendError
return {
...form.getFieldProps(name),
id: name,
@@ -49,7 +51,7 @@ export const getFormHelpers =
export const getFormHelpersWithError = <T>(
form: FormikContextType<T>,
error?: Error | unknown,
): ((name: keyof T, HelperText?: ReactNode) => FormHelpers) => {
): ((name: keyof T, HelperText?: ReactNode, errorName?: string) => FormHelpers) => {
const apiValidationErrors =
isApiError(error) && hasApiFieldErrors(error)
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
@@ -4,20 +4,16 @@
*/
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors"
import * as TypesGen from "../../api/typesGenerated"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
errorSubmissionFailed: "Failed to update schedule",
errorWorkspaceFetch: "Failed to fetch workspace",
successMessage: "Successfully updated workspace schedule.",
}
type Permissions = Record<keyof ReturnType<typeof permissionsToCheck>, boolean>
export interface WorkspaceScheduleContext {
formErrors?: FieldErrors
getWorkspaceError?: Error | unknown
/**
* Each workspace has their own schedule (start and ttl). For this reason, we
@@ -29,6 +25,7 @@ export interface WorkspaceScheduleContext {
userId?: string
permissions?: Permissions
checkPermissionsError?: Error | unknown
submitScheduleError?: Error | unknown
}
export const checks = {
@@ -86,7 +83,7 @@ export const workspaceSchedule = createMachine(
},
onError: {
target: "error",
actions: ["assignGetWorkspaceError", "displayWorkspaceError"],
actions: ["assignGetWorkspaceError"],
},
},
tags: "loading",
@@ -125,7 +122,7 @@ export const workspaceSchedule = createMachine(
},
onError: {
target: "presentForm",
actions: ["assignSubmissionError", "displaySubmissionError"],
actions: ["assignSubmissionError"],
},
},
tags: "loading",
@@ -145,7 +142,7 @@ export const workspaceSchedule = createMachine(
{
actions: {
assignSubmissionError: assign({
formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
submitScheduleError: (_, event) => event.data,
}),
assignWorkspace: assign({
workspace: (_, event) => event.data,
@@ -170,12 +167,6 @@ export const workspaceSchedule = createMachine(
clearGetWorkspaceError: (context) => {
assign({ ...context, getWorkspaceError: undefined })
},
displayWorkspaceError: () => {
displayError(Language.errorWorkspaceFetch)
},
displaySubmissionError: () => {
displayError(Language.errorSubmissionFailed)
},
displaySuccess: () => {
displaySuccess(Language.successMessage)
},
@@ -197,7 +188,7 @@ export const workspaceSchedule = createMachine(
submitSchedule: async (context, event) => {
if (!context.workspace?.id) {
// This state is theoretically impossible, but helps TS
throw new Error("failed to load workspace")
throw new Error("Failed to load workspace.")
}
// REMARK: These calls are purposefully synchronous because if one