mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user