Skip to content

Commit 73f145e

Browse files
authored
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
1 parent 1a8cce2 commit 73f145e

File tree

7 files changed

+76
-42
lines changed

7 files changed

+76
-42
lines changed

site/src/api/errors.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const Language = {
66
},
77
}
88

9-
interface FieldError {
9+
export interface FieldError {
1010
field: string
1111
detail: string
1212
}

site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx

+18-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { action } from "@storybook/addon-actions"
21
import { Story } from "@storybook/react"
32
import dayjs from "dayjs"
43
import advancedFormat from "dayjs/plugin/advancedFormat"
54
import timezone from "dayjs/plugin/timezone"
65
import utc from "dayjs/plugin/utc"
6+
import { makeMockApiError } from "testHelpers/entities"
77
import {
88
defaultWorkspaceSchedule,
99
WorkspaceScheduleForm,
@@ -17,6 +17,14 @@ dayjs.extend(timezone)
1717
export default {
1818
title: "components/WorkspaceScheduleForm",
1919
component: WorkspaceScheduleForm,
20+
argTypes: {
21+
onCancel: {
22+
action: "onCancel",
23+
},
24+
onSubmit: {
25+
action: "onSubmit",
26+
},
27+
},
2028
}
2129

2230
const Template: Story<WorkspaceScheduleFormProps> = (args) => <WorkspaceScheduleForm {...args} />
@@ -27,8 +35,6 @@ WorkspaceWillNotShutDown.args = {
2735
...defaultWorkspaceSchedule(5),
2836
ttl: 0,
2937
},
30-
onCancel: () => action("onCancel"),
31-
onSubmit: () => action("onSubmit"),
3238
}
3339

3440
export const WorkspaceWillShutdownInAnHour = Template.bind({})
@@ -37,8 +43,6 @@ WorkspaceWillShutdownInAnHour.args = {
3743
...defaultWorkspaceSchedule(5),
3844
ttl: 1,
3945
},
40-
onCancel: () => action("onCancel"),
41-
onSubmit: () => action("onSubmit"),
4246
}
4347

4448
export const WorkspaceWillShutdownInTwoHours = Template.bind({})
@@ -47,8 +51,6 @@ WorkspaceWillShutdownInTwoHours.args = {
4751
...defaultWorkspaceSchedule(2),
4852
ttl: 2,
4953
},
50-
onCancel: () => action("onCancel"),
51-
onSubmit: () => action("onSubmit"),
5254
}
5355

5456
export const WorkspaceWillShutdownInADay = Template.bind({})
@@ -57,8 +59,6 @@ WorkspaceWillShutdownInADay.args = {
5759
...defaultWorkspaceSchedule(2),
5860
ttl: 24,
5961
},
60-
onCancel: () => action("onCancel"),
61-
onSubmit: () => action("onSubmit"),
6262
}
6363

6464
export const WorkspaceWillShutdownInTwoDays = Template.bind({})
@@ -67,6 +67,13 @@ WorkspaceWillShutdownInTwoDays.args = {
6767
...defaultWorkspaceSchedule(2),
6868
ttl: 48,
6969
},
70-
onCancel: () => action("onCancel"),
71-
onSubmit: () => action("onSubmit"),
70+
}
71+
72+
export const WithError = Template.bind({})
73+
WithError.args = {
74+
initialTouched: { ttl: true },
75+
submitScheduleError: makeMockApiError({
76+
message: "Something went wrong.",
77+
validations: [{ field: "ttl_ms", detail: "Invalid time until shutdown." }],
78+
}),
7279
}

site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx

+15-7
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ import FormLabel from "@material-ui/core/FormLabel"
77
import MenuItem from "@material-ui/core/MenuItem"
88
import makeStyles from "@material-ui/core/styles/makeStyles"
99
import TextField from "@material-ui/core/TextField"
10+
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
1011
import dayjs from "dayjs"
1112
import advancedFormat from "dayjs/plugin/advancedFormat"
1213
import duration from "dayjs/plugin/duration"
1314
import relativeTime from "dayjs/plugin/relativeTime"
1415
import timezone from "dayjs/plugin/timezone"
1516
import utc from "dayjs/plugin/utc"
16-
import { useFormik } from "formik"
17+
import { FormikTouched, useFormik } from "formik"
1718
import { FC } from "react"
1819
import * as Yup from "yup"
19-
import { FieldErrors } from "../../api/errors"
20-
import { getFormHelpers } from "../../util/formUtils"
20+
import { getFormHelpersWithError } from "../../util/formUtils"
2121
import { FormFooter } from "../FormFooter/FormFooter"
2222
import { FullPageForm } from "../FullPageForm/FullPageForm"
2323
import { Stack } from "../Stack/Stack"
@@ -54,11 +54,13 @@ export const Language = {
5454
}
5555

5656
export interface WorkspaceScheduleFormProps {
57-
fieldErrors?: FieldErrors
57+
submitScheduleError?: Error | unknown
5858
initialValues?: WorkspaceScheduleFormValues
5959
isLoading: boolean
6060
onCancel: () => void
6161
onSubmit: (values: WorkspaceScheduleFormValues) => void
62+
// for storybook
63+
initialTouched?: FormikTouched<WorkspaceScheduleFormValues>
6264
}
6365

6466
export interface WorkspaceScheduleFormValues {
@@ -178,20 +180,25 @@ export const defaultWorkspaceSchedule = (
178180
})
179181

180182
export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
181-
fieldErrors,
183+
submitScheduleError,
182184
initialValues = defaultWorkspaceSchedule(),
183185
isLoading,
184186
onCancel,
185187
onSubmit,
188+
initialTouched,
186189
}) => {
187190
const styles = useStyles()
188191

189192
const form = useFormik<WorkspaceScheduleFormValues>({
190193
initialValues,
191194
onSubmit,
192195
validationSchema,
196+
initialTouched,
193197
})
194-
const formHelpers = getFormHelpers<WorkspaceScheduleFormValues>(form, fieldErrors)
198+
const formHelpers = getFormHelpersWithError<WorkspaceScheduleFormValues>(
199+
form,
200+
submitScheduleError,
201+
)
195202

196203
const checkboxes: Array<{ value: boolean; name: string; label: string }> = [
197204
{ value: form.values.sunday, name: "sunday", label: Language.daySundayLabel },
@@ -207,6 +214,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
207214
<FullPageForm onCancel={onCancel} title="Workspace schedule">
208215
<form onSubmit={form.handleSubmit} className={styles.form}>
209216
<Stack>
217+
{submitScheduleError && <ErrorSummary error={submitScheduleError} />}
210218
<TextField
211219
{...formHelpers("startTime", Language.startTimeHelperText)}
212220
disabled={isLoading}
@@ -262,7 +270,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
262270
</FormControl>
263271

264272
<TextField
265-
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl))}
273+
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
266274
disabled={isLoading}
267275
inputProps={{ min: 0, step: 1 }}
268276
label={Language.ttlLabel}

site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ dayjs.extend(timezone)
2828

2929
const Language = {
3030
forbiddenError: "You don't have permissions to update the schedule for this workspace.",
31+
getWorkspaceError: "Failed to fetch workspace.",
32+
checkPermissionsError: "Failed to fetch permissions.",
3133
}
3234

3335
export const formValuesToAutoStartRequest = (
@@ -156,7 +158,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
156158
userId: me?.id,
157159
},
158160
})
159-
const { checkPermissionsError, formErrors, getWorkspaceError, permissions, workspace } =
161+
const { checkPermissionsError, submitScheduleError, getWorkspaceError, permissions, workspace } =
160162
scheduleState.context
161163

162164
// Get workspace on mount and whenever the args for getting a workspace change.
@@ -183,6 +185,9 @@ export const WorkspaceSchedulePage: React.FC = () => {
183185
return (
184186
<ErrorSummary
185187
error={getWorkspaceError || checkPermissionsError}
188+
defaultMessage={
189+
getWorkspaceError ? Language.getWorkspaceError : Language.checkPermissionsError
190+
}
186191
retry={() => scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })}
187192
/>
188193
)
@@ -195,7 +200,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
195200
if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
196201
return (
197202
<WorkspaceScheduleForm
198-
fieldErrors={formErrors}
203+
submitScheduleError={submitScheduleError}
199204
initialValues={workspaceToInitialValues(workspace, dayjs.tz.guess())}
200205
isLoading={scheduleState.tags.has("loading")}
201206
onCancel={() => {

site/src/testHelpers/entities.ts

+21
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FieldError } from "api/errors"
12
import * as Types from "../api/types"
23
import * as TypesGen from "../api/typesGenerated"
34

@@ -584,3 +585,23 @@ export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [
584585
export const MockCancellationMessage = {
585586
message: "Job successfully canceled",
586587
}
588+
589+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
590+
export const makeMockApiError = ({
591+
message,
592+
detail,
593+
validations,
594+
}: {
595+
message?: string
596+
detail?: string
597+
validations?: FieldError[]
598+
}) => ({
599+
response: {
600+
data: {
601+
message: message ?? "Something went wrong.",
602+
detail: detail ?? undefined,
603+
validations: validations ?? undefined,
604+
},
605+
},
606+
isAxiosError: true,
607+
})

site/src/util/formUtils.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@ interface FormHelpers {
2525
helperText?: ReactNode
2626
}
2727

28+
// backendErrorName can be used if the backend names a field differently than the frontend does
2829
export const getFormHelpers =
29-
<T>(form: FormikContextType<T>, formErrors?: FormikErrors<T>) =>
30-
(name: keyof T, HelperText: ReactNode = ""): FormHelpers => {
30+
<T>(form: FormikContextType<T>, apiValidationErrors?: FormikErrors<T>) =>
31+
(name: keyof T, HelperText: ReactNode = "", backendErrorName?: string): FormHelpers => {
3132
if (typeof name !== "string") {
3233
throw new Error(`name must be type of string, instead received '${typeof name}'`)
3334
}
35+
const apiErrorName = backendErrorName ?? name
3436

3537
// getIn is a util function from Formik that gets at any depth of nesting
3638
// and is necessary for the types to work
3739
const touched = getIn(form.touched, name)
38-
const apiError = getIn(formErrors, name)
39-
const validationError = getIn(form.errors, name)
40-
const error = apiError ?? validationError
40+
const apiError = getIn(apiValidationErrors, apiErrorName)
41+
const frontendError = getIn(form.errors, name)
42+
const error = apiError ?? frontendError
4143
return {
4244
...form.getFieldProps(name),
4345
id: name,
@@ -49,7 +51,7 @@ export const getFormHelpers =
4951
export const getFormHelpersWithError = <T>(
5052
form: FormikContextType<T>,
5153
error?: Error | unknown,
52-
): ((name: keyof T, HelperText?: ReactNode) => FormHelpers) => {
54+
): ((name: keyof T, HelperText?: ReactNode, errorName?: string) => FormHelpers) => {
5355
const apiValidationErrors =
5456
isApiError(error) && hasApiFieldErrors(error)
5557
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)

site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts

+6-15
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,16 @@
44
*/
55
import { assign, createMachine } from "xstate"
66
import * as API from "../../api/api"
7-
import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors"
87
import * as TypesGen from "../../api/typesGenerated"
9-
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
8+
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
109

1110
export const Language = {
12-
errorSubmissionFailed: "Failed to update schedule",
13-
errorWorkspaceFetch: "Failed to fetch workspace",
1411
successMessage: "Successfully updated workspace schedule.",
1512
}
1613

1714
type Permissions = Record<keyof ReturnType<typeof permissionsToCheck>, boolean>
1815

1916
export interface WorkspaceScheduleContext {
20-
formErrors?: FieldErrors
2117
getWorkspaceError?: Error | unknown
2218
/**
2319
* Each workspace has their own schedule (start and ttl). For this reason, we
@@ -29,6 +25,7 @@ export interface WorkspaceScheduleContext {
2925
userId?: string
3026
permissions?: Permissions
3127
checkPermissionsError?: Error | unknown
28+
submitScheduleError?: Error | unknown
3229
}
3330

3431
export const checks = {
@@ -86,7 +83,7 @@ export const workspaceSchedule = createMachine(
8683
},
8784
onError: {
8885
target: "error",
89-
actions: ["assignGetWorkspaceError", "displayWorkspaceError"],
86+
actions: ["assignGetWorkspaceError"],
9087
},
9188
},
9289
tags: "loading",
@@ -125,7 +122,7 @@ export const workspaceSchedule = createMachine(
125122
},
126123
onError: {
127124
target: "presentForm",
128-
actions: ["assignSubmissionError", "displaySubmissionError"],
125+
actions: ["assignSubmissionError"],
129126
},
130127
},
131128
tags: "loading",
@@ -145,7 +142,7 @@ export const workspaceSchedule = createMachine(
145142
{
146143
actions: {
147144
assignSubmissionError: assign({
148-
formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
145+
submitScheduleError: (_, event) => event.data,
149146
}),
150147
assignWorkspace: assign({
151148
workspace: (_, event) => event.data,
@@ -170,12 +167,6 @@ export const workspaceSchedule = createMachine(
170167
clearGetWorkspaceError: (context) => {
171168
assign({ ...context, getWorkspaceError: undefined })
172169
},
173-
displayWorkspaceError: () => {
174-
displayError(Language.errorWorkspaceFetch)
175-
},
176-
displaySubmissionError: () => {
177-
displayError(Language.errorSubmissionFailed)
178-
},
179170
displaySuccess: () => {
180171
displaySuccess(Language.successMessage)
181172
},
@@ -197,7 +188,7 @@ export const workspaceSchedule = createMachine(
197188
submitSchedule: async (context, event) => {
198189
if (!context.workspace?.id) {
199190
// This state is theoretically impossible, but helps TS
200-
throw new Error("failed to load workspace")
191+
throw new Error("Failed to load workspace.")
201192
}
202193

203194
// REMARK: These calls are purposefully synchronous because if one

0 commit comments

Comments
 (0)