fix(site): Fix template icon field validation (#7394)

This commit is contained in:
Bruno Quaresma 2023-05-04 14:30:48 -03:00 committed by GitHub
parent 614bdfbf3c
commit 8909110f58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 258 additions and 457 deletions

View File

@ -1,3 +1,4 @@
import { mockApiError } from "testHelpers/entities"
import {
getValidationErrorMessage,
isApiError,
@ -7,17 +8,14 @@ import {
describe("isApiError", () => {
it("returns true when the object is an API Error", () => {
expect(
isApiError({
isAxiosError: true,
response: {
data: {
message: "Invalid entry",
errors: [
{ detail: "Username is already in use", field: "username" },
],
},
},
}),
isApiError(
mockApiError({
message: "Invalid entry",
validations: [
{ detail: "Username is already in use", field: "username" },
],
}),
),
).toBe(true)
})
@ -48,24 +46,21 @@ describe("mapApiErrorToFieldErrors", () => {
describe("getValidationErrorMessage", () => {
it("returns multiple validation messages", () => {
expect(
getValidationErrorMessage({
response: {
data: {
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
{
field: "q",
detail: `Query element "role:a:e" can only contain 1 ':'`,
},
],
},
},
isAxiosError: true,
}),
getValidationErrorMessage(
mockApiError({
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
{
field: "q",
detail: `Query element "role:a:e" can only contain 1 ':'`,
},
],
}),
),
).toEqual(
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
)
@ -73,28 +68,18 @@ describe("getValidationErrorMessage", () => {
it("non-API error returns empty validation message", () => {
expect(
getValidationErrorMessage({
response: {
data: {
error: "Invalid user search query.",
},
},
isAxiosError: true,
}),
getValidationErrorMessage(new Error("Invalid user search query.")),
).toEqual("")
})
it("no validations field returns empty validation message", () => {
expect(
getValidationErrorMessage({
response: {
data: {
message: "Invalid user search query.",
detail: `Query element "role:a:e" can only contain 1 ':'`,
},
},
isAxiosError: true,
}),
getValidationErrorMessage(
mockApiError({
message: "Invalid user search query.",
detail: `Query element "role:a:e" can only contain 1 ':'`,
}),
),
).toEqual("")
})
})

View File

@ -24,30 +24,16 @@ export type ApiError = AxiosError<ApiErrorResponse> & {
}
export const isApiError = (err: unknown): err is ApiError => {
if (axios.isAxiosError(err)) {
const response = err.response?.data
if (!response) {
return false
}
return (
typeof response.message === "string" &&
(typeof response.errors === "undefined" || Array.isArray(response.errors))
)
}
return false
return axios.isAxiosError(err) && err.response !== undefined
}
/**
* ApiErrors contain useful error messages in their response body. They contain an overall message
* and may also contain errors for specific form fields.
* @param error ApiError
* @returns true if the ApiError contains error messages for specific form fields.
*/
export const hasApiFieldErrors = (error: ApiError): boolean =>
Array.isArray(error.response.data.validations)
export const isApiValidationError = (error: unknown): error is ApiError => {
return isApiError(error) && hasApiFieldErrors(error)
}
export const mapApiErrorToFieldErrors = (
apiErrorResponse: ApiErrorResponse,
): FieldErrors => {
@ -63,10 +49,6 @@ export const mapApiErrorToFieldErrors = (
return result
}
export const isApiValidationError = (error: unknown): error is ApiError => {
return isApiError(error) && hasApiFieldErrors(error)
}
/**
*
* @param error

View File

@ -1,7 +1,7 @@
import { Story } from "@storybook/react"
import { AlertBanner } from "./AlertBanner"
import Button from "@material-ui/core/Button"
import { makeMockApiError } from "testHelpers/entities"
import { mockApiError } from "testHelpers/entities"
import { AlertBannerProps } from "./alertTypes"
import Link from "@material-ui/core/Link"
@ -16,7 +16,7 @@ const ExampleAction = (
</Button>
)
const mockError = makeMockApiError({
const mockError = mockApiError({
message: "Email or password was invalid",
detail: "Password is invalid",
})

View File

@ -1,6 +1,7 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { CreateUserForm, CreateUserFormProps } from "./CreateUserForm"
import { mockApiError } from "testHelpers/entities"
export default {
title: "components/CreateUserForm",
@ -18,22 +19,14 @@ Ready.args = {
isLoading: false,
}
export const UnknownError = Template.bind({})
UnknownError.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
error: "Something went wrong",
}
export const FormError = Template.bind({})
FormError.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
formErrors: {
username: "Username taken",
},
error: mockApiError({
validations: [{ field: "username", detail: "Username taken" }],
}),
}
export const Loading = Template.bind({})

View File

@ -1,6 +1,5 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import { FormikContextType, useFormik } from "formik"
import { FC } from "react"
import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated"
@ -27,9 +26,8 @@ export const Language = {
export interface CreateUserFormProps {
onSubmit: (user: TypesGen.CreateUserRequest) => void
onCancel: () => void
formErrors?: FormikErrors<TypesGen.CreateUserRequest>
error?: unknown
isLoading: boolean
error?: string
myOrgId: string
}
@ -44,7 +42,7 @@ const validationSchema = Yup.object({
export const CreateUserForm: FC<
React.PropsWithChildren<CreateUserFormProps>
> = ({ onSubmit, onCancel, formErrors, isLoading, error, myOrgId }) => {
> = ({ onSubmit, onCancel, error, isLoading, myOrgId }) => {
const form: FormikContextType<TypesGen.CreateUserRequest> =
useFormik<TypesGen.CreateUserRequest>({
initialValues: {
@ -58,7 +56,7 @@ export const CreateUserForm: FC<
})
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequest>(
form,
formErrors,
error,
)
return (
@ -92,7 +90,6 @@ export const CreateUserForm: FC<
variant="outlined"
/>
</Stack>
{error && <FormHelperText error>{error}</FormHelperText>}
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>

View File

@ -4,6 +4,7 @@ import {
SearchBarWithFilter,
SearchBarWithFilterProps,
} from "./SearchBarWithFilter"
import { mockApiError } from "testHelpers/entities"
export default {
title: "components/SearchBarWithFilter",
@ -34,18 +35,13 @@ WithError.args = {
{ query: userFilterQuery.active, name: "Active users" },
{ query: "random query", name: "Random query" },
],
error: {
response: {
data: {
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
],
error: mockApiError({
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
},
isAxiosError: true,
},
],
}),
}

View File

@ -1,5 +1,6 @@
import { Story } from "@storybook/react"
import { AccountForm, AccountFormProps } from "./SettingsAccountForm"
import { mockApiError } from "testHelpers/entities"
export default {
title: "components/SettingsAccountForm",
@ -35,20 +36,15 @@ Loading.args = {
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateProfileError: {
response: {
data: {
message: "Username is invalid",
validations: [
{
field: "username",
detail: "Username is too long.",
},
],
updateProfileError: mockApiError({
message: "Username is invalid",
validations: [
{
field: "username",
detail: "Username is too long.",
},
},
isAxiosError: true,
},
],
}),
initialTouched: {
username: true,
},

View File

@ -1,5 +1,6 @@
import { Story } from "@storybook/react"
import { SecurityForm, SecurityFormProps } from "./SettingsSecurityForm"
import { mockApiError } from "testHelpers/entities"
export default {
title: "components/SettingsSecurityForm",
@ -36,20 +37,15 @@ Loading.args = {
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateSecurityError: {
response: {
data: {
message: "Old password is incorrect",
validations: [
{
field: "old_password",
detail: "Old password is incorrect.",
},
],
updateSecurityError: mockApiError({
message: "Old password is incorrect",
validations: [
{
field: "old_password",
detail: "Old password is incorrect.",
},
},
isAxiosError: true,
},
],
}),
initialTouched: {
old_password: true,
},

View File

@ -1,5 +1,5 @@
import { Story } from "@storybook/react"
import { makeMockApiError } from "testHelpers/entities"
import { mockApiError } from "testHelpers/entities"
import { SignInForm, SignInFormProps } from "./SignInForm"
export default {
@ -37,7 +37,7 @@ SigningIn.args = {
export const WithError = Template.bind({})
WithError.args = {
...SignedOut.args,
error: makeMockApiError({
error: mockApiError({
message: "Email or password was invalid",
validations: [
{

View File

@ -95,7 +95,7 @@ Failed.args = {
...Running.args,
workspace: Mocks.MockFailedWorkspace,
workspaceErrors: {
[WorkspaceErrors.BUILD_ERROR]: Mocks.makeMockApiError({
[WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({
message: "A workspace build is already active.",
}),
},
@ -152,7 +152,7 @@ export const GetBuildsError = Template.bind({})
GetBuildsError.args = {
...Running.args,
workspaceErrors: {
[WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.makeMockApiError({
[WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({
message: "There is a problem fetching builds.",
}),
},
@ -162,7 +162,7 @@ export const CancellationError = Template.bind({})
CancellationError.args = {
...Failed.args,
workspaceErrors: {
[WorkspaceErrors.CANCELLATION_ERROR]: Mocks.makeMockApiError({
[WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({
message: "Job could not be canceled.",
}),
},

View File

@ -8,7 +8,7 @@ import {
emptySchedule,
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { makeMockApiError } from "testHelpers/entities"
import { mockApiError } from "testHelpers/entities"
import {
WorkspaceScheduleForm,
WorkspaceScheduleFormProps,
@ -81,7 +81,7 @@ export const WithError = Template.bind({})
WithError.args = {
initialValues: { ...defaultInitialValues, ttl: 100 },
initialTouched: { ttl: true },
submitScheduleError: makeMockApiError({
submitScheduleError: mockApiError({
message: "Something went wrong.",
validations: [{ field: "ttl_ms", detail: "Invalid time until shutdown." }],
}),

View File

@ -1,6 +1,6 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
mockParameterSchema,
MockParameterSchemas,
MockTemplate,
@ -85,7 +85,7 @@ export const GetTemplatesError = Template.bind({})
GetTemplatesError.args = {
...Parameters.args,
createWorkspaceErrors: {
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: makeMockApiError({
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: mockApiError({
message: "Failed to fetch templates.",
detail: "You do not have permission to access this resource.",
}),
@ -97,7 +97,7 @@ export const GetTemplateSchemaError = Template.bind({})
GetTemplateSchemaError.args = {
...Parameters.args,
createWorkspaceErrors: {
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: makeMockApiError({
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: mockApiError({
message: 'Failed to fetch template schema for "docker-amd64".',
detail: "You do not have permission to access this resource.",
}),
@ -109,7 +109,7 @@ export const CreateWorkspaceError = Template.bind({})
CreateWorkspaceError.args = {
...Parameters.args,
createWorkspaceErrors: {
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: makeMockApiError({
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: mockApiError({
message:
'Workspace "test" already exists in the "docker-amd64" template.',
validations: [

View File

@ -1,8 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
makeMockApiError,
MockDeploymentDAUResponse,
} from "testHelpers/entities"
import { mockApiError, MockDeploymentDAUResponse } from "testHelpers/entities"
import {
GeneralSettingsPageView,
GeneralSettingsPageViewProps,
@ -43,5 +40,7 @@ NoDAUs.args = {
export const DAUError = Template.bind({})
DAUError.args = {
deploymentDAUs: undefined,
getDeploymentDAUsError: makeMockApiError({ message: "Error fetching DAUs." }),
getDeploymentDAUsError: mockApiError({
message: "Error fetching DAUs.",
}),
}

View File

@ -20,7 +20,7 @@ export const CreateGroupPage: FC = () => {
},
},
})
const { createGroupFormErrors } = createState.context
const { error } = createState.context
return (
<>
@ -34,7 +34,7 @@ export const CreateGroupPage: FC = () => {
data,
})
}}
formErrors={createGroupFormErrors}
formErrors={error}
isLoading={createState.matches("creatingGroup")}
/>
</>

View File

@ -26,7 +26,7 @@ export const SettingsGroupPage: FC = () => {
onUpdate: navigateToGroup,
},
})
const { updateGroupFormErrors, group } = editState.context
const { error, group } = editState.context
return (
<>
@ -40,7 +40,7 @@ export const SettingsGroupPage: FC = () => {
sendEditEvent({ type: "UPDATE", data })
}}
group={group}
formErrors={updateGroupFormErrors}
formErrors={error}
isLoading={editState.matches("loading")}
isUpdating={editState.matches("updating")}
/>

View File

@ -1,12 +1,11 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { rest } from "msw"
import { history, MockUser, render } from "testHelpers/renderHelpers"
import { history, render } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
import { Language as SetupLanguage } from "xServices/setup/setupXService"
import { SetupPage } from "./SetupPage"
import { Language as PageViewLanguage } from "./SetupPageView"
import { MockUser } from "testHelpers/entities"
const fillForm = async ({
username = "someuser",
@ -47,18 +46,6 @@ describe("Setup Page", () => {
expect(errorMessage).toBeDefined()
})
it("shows generic error message", async () => {
jest.spyOn(API, "createFirstUser").mockRejectedValueOnce({
data: "unknown error",
})
render(<SetupPage />)
await fillForm()
const errorMessage = await screen.findByText(
SetupLanguage.createFirstUserError,
)
expect(errorMessage).toBeDefined()
})
it("shows API error message", async () => {
const fieldErrorMessage = "invalid username"
server.use(

View File

@ -22,8 +22,7 @@ export const SetupPage: FC = () => {
},
},
})
const { createFirstUserFormErrors, createFirstUserErrorMessage } =
setupState.context
const { error } = setupState.context
useEffect(() => {
if (authState.matches("signedIn")) {
@ -38,8 +37,7 @@ export const SetupPage: FC = () => {
</Helmet>
<SetupPageView
isLoading={setupState.hasTag("loading")}
formErrors={createFirstUserFormErrors}
genericError={createFirstUserErrorMessage}
error={error}
onSubmit={(firstUser) => {
setupSend({ type: "CREATE_FIRST_USER", firstUser })
}}

View File

@ -1,6 +1,7 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { SetupPageView, SetupPageViewProps } from "./SetupPageView"
import { mockApiError } from "testHelpers/entities"
export default {
title: "pages/SetupPageView",
@ -14,27 +15,18 @@ const Template: Story<SetupPageViewProps> = (args: SetupPageViewProps) => (
export const Ready = Template.bind({})
Ready.args = {
onSubmit: action("submit"),
isCreating: false,
}
export const UnknownError = Template.bind({})
UnknownError.args = {
onSubmit: action("submit"),
isCreating: false,
genericError: "Something went wrong",
}
export const FormError = Template.bind({})
FormError.args = {
onSubmit: action("submit"),
isCreating: false,
formErrors: {
username: "Username taken",
},
error: mockApiError({
validations: [{ field: "username", detail: "Username taken" }],
}),
}
export const Loading = Template.bind({})
Loading.args = {
onSubmit: action("submit"),
isCreating: true,
isLoading: true,
}

View File

@ -1,6 +1,5 @@
import Box from "@material-ui/core/Box"
import Checkbox from "@material-ui/core/Checkbox"
import FormHelperText from "@material-ui/core/FormHelperText"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Typography from "@material-ui/core/Typography"
@ -8,7 +7,7 @@ import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Stack } from "components/Stack/Stack"
import { Welcome } from "components/Welcome/Welcome"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import { FormikContextType, useFormik } from "formik"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "utils/formUtils"
import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated"
@ -35,15 +34,13 @@ const validationSchema = Yup.object({
export interface SetupPageViewProps {
onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void
formErrors?: FormikErrors<TypesGen.CreateFirstUserRequest>
genericError?: string
error?: unknown
isLoading?: boolean
}
export const SetupPageView: React.FC<SetupPageViewProps> = ({
onSubmit,
formErrors,
genericError,
error,
isLoading,
}) => {
const form: FormikContextType<TypesGen.CreateFirstUserRequest> =
@ -59,7 +56,7 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
})
const getFieldHelpers = getFormHelpers<TypesGen.CreateFirstUserRequest>(
form,
formErrors,
error,
)
const styles = useStyles()
@ -93,9 +90,6 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
type="password"
variant="outlined"
/>
{genericError && (
<FormHelperText error>{genericError}</FormHelperText>
)}
<div className={styles.callout}>
<Box display="flex">
<div>

View File

@ -1,6 +1,6 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
MockOrganization,
MockTemplateExample,
} from "testHelpers/entities"
@ -33,7 +33,7 @@ Error.args = {
context: {
exampleId: MockTemplateExample.id,
organizationId: MockOrganization.id,
error: makeMockApiError({
error: mockApiError({
message: `Example ${MockTemplateExample.id} not found.`,
}),
starterTemplate: undefined,

View File

@ -1,6 +1,6 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
MockOrganization,
MockTemplateExample,
MockTemplateExample2,
@ -36,7 +36,7 @@ export const Error = Template.bind({})
Error.args = {
context: {
organizationId: MockOrganization.id,
error: makeMockApiError({
error: mockApiError({
message: "Error on loading the template examples",
}),
starterTemplatesByTag: undefined,

View File

@ -7,6 +7,7 @@ import {
nameValidator,
templateDisplayNameValidator,
onChangeTrimmed,
iconValidator,
} from "utils/formUtils"
import * as Yup from "yup"
import i18next from "i18next"
@ -37,8 +38,8 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
MAX_DESCRIPTION_CHAR_LIMIT,
i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }),
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
icon: iconValidator,
})
export interface TemplateSettingsForm {
@ -74,7 +75,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
const getFieldHelpers = getFormHelpers(form, error)
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()

View File

@ -1,6 +1,6 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { makeMockApiError, MockTemplate } from "testHelpers/entities"
import { mockApiError, MockTemplate } from "testHelpers/entities"
import {
TemplateSettingsPageView,
TemplateSettingsPageViewProps,
@ -25,7 +25,7 @@ Example.args = {}
export const SaveTemplateSettingsError = Template.bind({})
SaveTemplateSettingsError.args = {
submitError: makeMockApiError({
submitError: mockApiError({
message: 'Template "test" already exists.',
validations: [
{

View File

@ -1,7 +1,7 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
MockTemplateVersion,
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
@ -69,7 +69,7 @@ WithUpdateTemplateError.args = {
MockTemplateVersionVariable4,
],
errors: {
updateTemplateError: makeMockApiError({
updateTemplateError: mockApiError({
message: "Something went wrong.",
}),
},

View File

@ -2,7 +2,7 @@ import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { UseTabResult } from "hooks/useTab"
import {
makeMockApiError,
mockApiError,
MockOrganization,
MockTemplate,
MockTemplateVersion,
@ -64,7 +64,7 @@ Error.args = {
...defaultArgs.context,
currentVersion: undefined,
currentFiles: undefined,
error: makeMockApiError({
error: mockApiError({
message: "Error on loading the template version",
}),
},

View File

@ -1,6 +1,6 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
MockOrganization,
MockPermissions,
MockTemplate,
@ -89,7 +89,7 @@ Error.args = {
...MockPermissions,
createTemplates: false,
},
error: makeMockApiError({
error: mockApiError({
message: "Something went wrong fetching templates.",
}),
templates: undefined,

View File

@ -5,6 +5,7 @@ import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import * as AuthXService from "../../../xServices/auth/authXService"
import { AccountPage } from "./AccountPage"
import i18next from "i18next"
import { mockApiError } from "testHelpers/entities"
const { t } = i18next
@ -54,17 +55,14 @@ describe("AccountPage", () => {
describe("when the username is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: {
message: "Invalid profile",
validations: [
{ detail: "Username is already in use", field: "username" },
],
},
},
})
jest.spyOn(API, "updateProfile").mockRejectedValueOnce(
mockApiError({
message: "Invalid profile",
validations: [
{ detail: "Username is already in use", field: "username" },
],
}),
)
const { user } = renderPage()
await fillAndSubmitForm()

View File

@ -1,5 +1,5 @@
import { Story } from "@storybook/react"
import { makeMockApiError } from "testHelpers/entities"
import { mockApiError } from "testHelpers/entities"
import { SSHKeysPageView, SSHKeysPageViewProps } from "./SSHKeysPageView"
export default {
@ -39,7 +39,7 @@ export const WithGetSSHKeyError = Template.bind({})
WithGetSSHKeyError.args = {
...Example.args,
hasLoaded: false,
getSSHKeyError: makeMockApiError({
getSSHKeyError: mockApiError({
message: "Failed to get SSH key",
}),
}
@ -47,7 +47,7 @@ WithGetSSHKeyError.args = {
export const WithRegenerateSSHKeyError = Template.bind({})
WithRegenerateSSHKeyError.args = {
...Example.args,
regenerateSSHKeyError: makeMockApiError({
regenerateSSHKeyError: mockApiError({
message: "Failed to regenerate SSH key",
}),
}

View File

@ -4,6 +4,7 @@ import * as SecurityForm from "../../../components/SettingsSecurityForm/Settings
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import { SecurityPage } from "./SecurityPage"
import i18next from "i18next"
import { mockApiError } from "testHelpers/entities"
const { t } = i18next
@ -54,17 +55,14 @@ describe("SecurityPage", () => {
describe("when the old_password is incorrect", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: {
message: "Incorrect password.",
validations: [
{ detail: "Incorrect password.", field: "old_password" },
],
},
},
})
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [
{ detail: "Incorrect password.", field: "old_password" },
],
}),
)
const { user } = renderPage()
await fillAndSubmitForm()
@ -79,15 +77,12 @@ describe("SecurityPage", () => {
describe("when the password is invalid", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: {
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
},
},
})
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
)
const { user } = renderPage()
await fillAndSubmitForm()

View File

@ -1,5 +1,5 @@
import { Story } from "@storybook/react"
import { makeMockApiError, MockTokens } from "testHelpers/entities"
import { mockApiError, MockTokens } from "testHelpers/entities"
import { TokensPageView, TokensPageViewProps } from "./TokensPageView"
export default {
@ -41,7 +41,7 @@ export const WithGetTokensError = Template.bind({})
WithGetTokensError.args = {
...Example.args,
hasLoaded: false,
getTokensError: makeMockApiError({
getTokensError: mockApiError({
message: "Failed to get tokens.",
}),
}
@ -50,7 +50,7 @@ export const WithDeleteTokenError = Template.bind({})
WithDeleteTokenError.args = {
...Example.args,
hasLoaded: false,
deleteTokenError: makeMockApiError({
deleteTokenError: mockApiError({
message: "Failed to delete token.",
}),
}

View File

@ -1,6 +1,6 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
mockApiError,
MockWorkspaceProxies,
MockPrimaryWorkspaceProxy,
MockHealthyWildWorkspaceProxy,
@ -61,7 +61,7 @@ export const WithProxiesError = Template.bind({})
WithProxiesError.args = {
...Example.args,
hasLoaded: false,
getWorkspaceProxiesError: makeMockApiError({
getWorkspaceProxiesError: mockApiError({
message: "Failed to get proxies.",
}),
}
@ -70,7 +70,7 @@ export const WithSelectProxyError = Template.bind({})
WithSelectProxyError.args = {
...Example.args,
hasLoaded: false,
selectProxyError: makeMockApiError({
selectProxyError: mockApiError({
message: "Failed to select proxy.",
}),
}

View File

@ -1,7 +1,6 @@
import { fireEvent, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { rest } from "msw"
import * as API from "../../../api/api"
import { Language as FormLanguage } from "../../../components/CreateUserForm/CreateUserForm"
import { Language as FooterLanguage } from "../../../components/FormFooter/FormFooter"
import {
@ -14,7 +13,9 @@ import { Language as CreateUserLanguage } from "../../../xServices/users/createU
import { CreateUserPage } from "./CreateUserPage"
const renderCreateUserPage = async () => {
renderWithAuth(<CreateUserPage />)
renderWithAuth(<CreateUserPage />, {
extraRoutes: [{ path: "/users", element: <div>Users Page</div> }],
})
await waitForLoaderToBeRemoved()
}
@ -51,18 +52,6 @@ describe("Create User Page", () => {
expect(errorMessage).toBeDefined()
})
it("shows generic error message", async () => {
jest.spyOn(API, "createUser").mockRejectedValueOnce({
data: "unknown error",
})
await renderCreateUserPage()
await fillForm({})
const errorMessage = await screen.findByText(
CreateUserLanguage.createUserError,
)
expect(errorMessage).toBeDefined()
})
it("shows API error message", async () => {
const fieldErrorMessage = "username already in use"
server.use(

View File

@ -23,13 +23,7 @@ export const CreateUserPage: FC = () => {
},
},
})
const { createUserErrorMessage, createUserFormErrors } =
createUserState.context
// There is no field for organization id in Community Edition, so handle its field error like a generic error
const genericError =
createUserErrorMessage ||
createUserFormErrors?.organization_id ||
(!myOrgId ? Language.unknownError : undefined)
const { error } = createUserState.context
return (
<Margins>
@ -37,7 +31,7 @@ export const CreateUserPage: FC = () => {
<title>{pageTitle("Create User")}</title>
</Helmet>
<CreateUserForm
formErrors={createUserFormErrors}
error={error}
onSubmit={(user: TypesGen.CreateUserRequest) =>
createUserSend({ type: "CREATE", user })
}
@ -46,7 +40,6 @@ export const CreateUserPage: FC = () => {
navigate("/users")
}}
isLoading={createUserState.hasTag("loading")}
error={genericError}
myOrgId={myOrgId}
/>
</Margins>

View File

@ -4,6 +4,7 @@ import {
MockUser,
MockUser2,
MockAssignableSiteRoles,
mockApiError,
} from "testHelpers/entities"
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
@ -42,18 +43,13 @@ EmptyPage.args = { users: [], isNonInitialPage: true }
export const Error = Template.bind({})
Error.args = {
users: undefined,
error: {
response: {
data: {
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
],
error: mockApiError({
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
},
isAxiosError: true,
},
],
}),
}

View File

@ -341,6 +341,8 @@ export const MockTemplate: TypesGen.Template = {
created_by_name: "test_creator",
icon: "/icon/code.svg",
allow_user_cancel_workspace_jobs: true,
allow_user_autostart: false,
allow_user_autostop: false,
}
export const MockTemplateVersionFiles: TemplateVersionFiles = {
@ -1251,6 +1253,7 @@ type MockAPIInput = {
}
type MockAPIOutput = {
isAxiosError: true
response: {
data: {
message: string
@ -1258,16 +1261,15 @@ type MockAPIOutput = {
validations: FieldError[] | undefined
}
}
isAxiosError: boolean
}
type MakeMockApiErrorFunction = (input: MockAPIInput) => MockAPIOutput
export const makeMockApiError: MakeMockApiErrorFunction = ({
export const mockApiError = ({
message,
detail,
validations,
}) => ({
}: MockAPIInput): MockAPIOutput => ({
// This is how axios can check if it is an axios error when calling isAxiosError
isAxiosError: true,
response: {
data: {
message: message ?? "Something went wrong.",
@ -1275,7 +1277,6 @@ export const makeMockApiError: MakeMockApiErrorFunction = ({
validations: validations ?? undefined,
},
},
isAxiosError: true,
})
export const MockEntitlements: TypesGen.Entitlements = {

View File

@ -1,5 +1,6 @@
import { FormikContextType } from "formik/dist/types"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "./formUtils"
import { mockApiError } from "testHelpers/entities"
interface TestType {
untouchedGoodField: string
@ -69,9 +70,17 @@ describe("form util functions", () => {
})
describe("with API errors", () => {
it("shows an error if there is only an API error", () => {
const getFieldHelpers = getFormHelpers<TestType>(form, {
touchedGoodField: "API error!",
})
const getFieldHelpers = getFormHelpers<TestType>(
form,
mockApiError({
validations: [
{
field: "touchedGoodField",
detail: "API error!",
},
],
}),
)
const result = getFieldHelpers("touchedGoodField")
expect(result.error).toBeTruthy()
expect(result.helperText).toEqual("API error!")
@ -83,9 +92,17 @@ describe("form util functions", () => {
expect(result.helperText).toEqual("oops!")
})
it("shows the API error if both are present", () => {
const getFieldHelpers = getFormHelpers<TestType>(form, {
touchedBadField: "API error!",
})
const getFieldHelpers = getFormHelpers<TestType>(
form,
mockApiError({
validations: [
{
field: "touchedBadField",
detail: "API error!",
},
],
}),
)
const result = getFieldHelpers("touchedBadField")
expect(result.error).toBeTruthy()
expect(result.helperText).toEqual("API error!")

View File

@ -34,37 +34,32 @@ interface FormHelpers {
}
export const getFormHelpers =
<T>(form: FormikContextType<T>, error?: Error | unknown) =>
<TFormValues>(form: FormikContextType<TFormValues>, error?: unknown) =>
(
name: string,
HelperText: ReactNode = "",
backendErrorName?: string,
fieldName: keyof TFormValues | string,
helperText?: ReactNode,
// backendFieldName is used when the value in the form is named different from the backend
backendFieldName?: string,
): FormHelpers => {
const apiValidationErrors = isApiValidationError(error)
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
: // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests
error
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(apiValidationErrors, apiErrorName)
const frontendError = getIn(form.errors, name)
const returnError = apiError ?? frontendError
? (mapApiErrorToFieldErrors(
error.response.data,
) as FormikErrors<TFormValues> & { [key: string]: string })
: undefined
// Since the fieldName can be a path string like parameters[0].value we need to use getIn
const touched = Boolean(getIn(form.touched, fieldName.toString()))
const formError = getIn(form.errors, fieldName.toString())
// Since the field in the form can be diff from the backend, we need to
// check for both when getting the error
const apiField = backendFieldName ?? fieldName
const apiError = apiValidationErrors?.[apiField.toString()]
const errorToDisplay = apiError ?? formError
return {
...form.getFieldProps(name),
id: name,
error: touched && Boolean(returnError),
helperText: touched ? returnError || HelperText : HelperText,
...form.getFieldProps(fieldName),
id: fieldName.toString(),
error: touched && Boolean(errorToDisplay),
helperText: touched ? errorToDisplay ?? helperText : helperText,
}
}
@ -102,3 +97,5 @@ export const templateDisplayNameValidator = (
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
)
.optional()
export const iconValidator = Yup.string().label("Icon").max(256)

View File

@ -1,14 +1,6 @@
import { createGroup } from "api/api"
import {
ApiError,
getErrorMessage,
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "api/errors"
import { CreateGroupRequest, Group } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { createMachine } from "xstate"
import { createMachine, assign } from "xstate"
export const createGroupMachine = createMachine(
{
@ -16,7 +8,7 @@ export const createGroupMachine = createMachine(
schema: {
context: {} as {
organizationId: string
createGroupFormErrors?: unknown
error?: unknown
},
services: {} as {
createGroup: {
@ -45,37 +37,23 @@ export const createGroupMachine = createMachine(
target: "idle",
actions: ["onCreate"],
},
onError: [
{
target: "idle",
cond: "hasFieldErrors",
actions: ["assignCreateGroupFormErrors"],
},
{
target: "idle",
actions: ["displayCreateGroupError"],
},
],
onError: {
target: "idle",
actions: ["assignError"],
},
},
},
},
},
{
guards: {
hasFieldErrors: (_, event) =>
isApiError(event.data) && hasApiFieldErrors(event.data),
},
services: {
createGroup: ({ organizationId }, { data }) =>
createGroup(organizationId, data),
},
actions: {
displayCreateGroupError: (_, { data }) => {
const message = getErrorMessage(data, "Error on creating the group.")
displayError(message)
},
assignCreateGroupFormErrors: (_, event) =>
mapApiErrorToFieldErrors((event.data as ApiError).response.data),
assignError: assign({
error: (_, event) => event.data,
}),
},
},
)

View File

@ -1,11 +1,5 @@
import { getGroup, patchGroup } from "api/api"
import {
ApiError,
getErrorMessage,
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "api/errors"
import { getErrorMessage } from "api/errors"
import { Group } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
@ -17,7 +11,7 @@ export const editGroupMachine = createMachine(
context: {} as {
groupId: string
group?: Group
updateGroupFormErrors?: unknown
error?: unknown
},
services: {} as {
loadGroup: {
@ -61,26 +55,15 @@ export const editGroupMachine = createMachine(
onDone: {
actions: ["onUpdate"],
},
onError: [
{
target: "idle",
cond: "hasFieldErrors",
actions: ["assignUpdateGroupFormErrors"],
},
{
target: "idle",
actions: ["displayUpdateGroupError"],
},
],
onError: {
target: "idle",
actions: ["assignError"],
},
},
},
},
},
{
guards: {
hasFieldErrors: (_, event) =>
isApiError(event.data) && hasApiFieldErrors(event.data),
},
services: {
loadGroup: ({ groupId }) => getGroup(groupId),
@ -104,12 +87,9 @@ export const editGroupMachine = createMachine(
const message = getErrorMessage(data, "Failed to the group.")
displayError(message)
},
displayUpdateGroupError: (_, { data }) => {
const message = getErrorMessage(data, "Failed to update the group.")
displayError(message)
},
assignUpdateGroupFormErrors: (_, event) =>
mapApiErrorToFieldErrors((event.data as ApiError).response.data),
assignError: assign({
error: (_, event) => event.data,
}),
},
},
)

View File

@ -1,12 +1,4 @@
import * as API from "api/api"
import {
ApiError,
FieldErrors,
getErrorMessage,
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "api/errors"
import * as TypesGen from "api/typesGenerated"
import { assign, createMachine } from "xstate"
@ -15,8 +7,7 @@ export const Language = {
}
export interface SetupContext {
createFirstUserErrorMessage?: string
createFirstUserFormErrors?: FieldErrors
error?: unknown
firstUser?: TypesGen.CreateFirstUserRequest
}
@ -52,7 +43,7 @@ export const setupMachine =
},
},
creatingFirstUser: {
entry: "clearCreateFirstUserError",
entry: "clearError",
invoke: {
src: "createFirstUser",
id: "createFirstUser",
@ -62,17 +53,10 @@ export const setupMachine =
target: "firstUserCreated",
},
],
onError: [
{
actions: "assignCreateFirstUserFormErrors",
cond: "hasFieldErrors",
target: "idle",
},
{
actions: "assignCreateFirstUserError",
target: "idle",
},
],
onError: {
actions: "assignError",
target: "idle",
},
},
tags: "loading",
},
@ -86,28 +70,16 @@ export const setupMachine =
services: {
createFirstUser: (_, event) => API.createFirstUser(event.firstUser),
},
guards: {
hasFieldErrors: (_, event) =>
isApiError(event.data) && hasApiFieldErrors(event.data),
},
actions: {
assignFirstUserData: assign({
firstUser: (_, event) => event.firstUser,
}),
assignCreateFirstUserError: assign({
createFirstUserErrorMessage: (_, event) =>
getErrorMessage(event.data, Language.createFirstUserError),
assignError: assign({
error: (_, event) => event.data,
}),
assignCreateFirstUserFormErrors: assign({
// the guard ensures it is ApiError
createFirstUserFormErrors: (_, event) =>
mapApiErrorToFieldErrors((event.data as ApiError).response.data),
clearError: assign({
error: (_) => undefined,
}),
clearCreateFirstUserError: assign((context: SetupContext) => ({
...context,
createFirstUserErrorMessage: undefined,
createFirstUserFormErrors: undefined,
})),
},
},
)

View File

@ -1,24 +1,14 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import {
ApiError,
FieldErrors,
getErrorMessage,
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "../../api/errors"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
createUserSuccess: "Successfully created user.",
createUserError: "Error on creating the user.",
}
export interface CreateUserContext {
createUserErrorMessage?: string
createUserFormErrors?: FieldErrors
error?: unknown
}
export type CreateUserEvent =
@ -44,11 +34,11 @@ export const createUserMachine = createMachine(
idle: {
on: {
CREATE: "creatingUser",
CANCEL_CREATE_USER: { actions: ["clearCreateUserError"] },
CANCEL_CREATE_USER: { actions: ["clearError"] },
},
},
creatingUser: {
entry: "clearCreateUserError",
entry: "clearError",
invoke: {
src: "createUser",
id: "createUser",
@ -56,17 +46,10 @@ export const createUserMachine = createMachine(
target: "idle",
actions: ["displayCreateUserSuccess", "redirectToUsersPage"],
},
onError: [
{
target: "idle",
cond: "hasFieldErrors",
actions: ["assignCreateUserFormErrors"],
},
{
target: "idle",
actions: ["assignCreateUserError"],
},
],
onError: {
target: "idle",
actions: ["assignError"],
},
},
tags: "loading",
},
@ -76,25 +59,11 @@ export const createUserMachine = createMachine(
services: {
createUser: (_, event) => API.createUser(event.user),
},
guards: {
hasFieldErrors: (_, event) =>
isApiError(event.data) && hasApiFieldErrors(event.data),
},
actions: {
assignCreateUserError: assign({
createUserErrorMessage: (_, event) =>
getErrorMessage(event.data, Language.createUserError),
assignError: assign({
error: (_, event) => event.data,
}),
assignCreateUserFormErrors: assign({
// the guard ensures it is ApiError
createUserFormErrors: (_, event) =>
mapApiErrorToFieldErrors((event.data as ApiError).response.data),
}),
clearCreateUserError: assign((context: CreateUserContext) => ({
...context,
createUserErrorMessage: undefined,
createUserFormErrors: undefined,
})),
clearError: assign({ error: (_) => undefined }),
displayCreateUserSuccess: () => {
displaySuccess(Language.createUserSuccess)
},