diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 189ea12f43c4b..56f385b19959d 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -6,7 +6,7 @@ export const Language = { }, } -interface FieldError { +export interface FieldError { field: string detail: string } diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx index 8a9c1bcdecbf5..cb24e1316dc5e 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx @@ -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 = (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." }], + }), } diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 7b9905cced32d..6eb500550ff38 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -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 } export interface WorkspaceScheduleFormValues { @@ -178,11 +180,12 @@ export const defaultWorkspaceSchedule = ( }) export const WorkspaceScheduleForm: FC = ({ - fieldErrors, + submitScheduleError, initialValues = defaultWorkspaceSchedule(), isLoading, onCancel, onSubmit, + initialTouched, }) => { const styles = useStyles() @@ -190,8 +193,12 @@ export const WorkspaceScheduleForm: FC = ({ initialValues, onSubmit, validationSchema, + initialTouched, }) - const formHelpers = getFormHelpers(form, fieldErrors) + const formHelpers = getFormHelpersWithError( + 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 = ({
+ {submitScheduleError && } = ({ { 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 ( scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })} /> ) @@ -195,7 +200,7 @@ export const WorkspaceSchedulePage: React.FC = () => { if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) { return ( { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d847cdca8fadd..efe26681ca63b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, +}) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index 3dc137bf4d969..af0f32723aa6e 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -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 = - (form: FormikContextType, formErrors?: FormikErrors) => - (name: keyof T, HelperText: ReactNode = ""): FormHelpers => { + (form: FormikContextType, apiValidationErrors?: FormikErrors) => + (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 = ( form: FormikContextType, 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) diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts index fec8356f80b96..9fef3b34cd0f4 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts @@ -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, 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