diff --git a/site/src/components/CreateUserForm/CreateUserForm.tsx b/site/src/components/CreateUserForm/CreateUserForm.tsx index cc7e8fcdeab76..710d41d6b6576 100644 --- a/site/src/components/CreateUserForm/CreateUserForm.tsx +++ b/site/src/components/CreateUserForm/CreateUserForm.tsx @@ -4,7 +4,7 @@ import { FormikContextType, FormikErrors, useFormik } from "formik" import React from "react" import * as Yup from "yup" import * as TypesGen from "../../api/typesGenerated" -import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils" import { FormFooter } from "../FormFooter/FormFooter" import { FullPageForm } from "../FullPageForm/FullPageForm" @@ -15,7 +15,6 @@ export const Language = { emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", passwordRequired: "Please enter a password.", - usernameRequired: "Please enter a username.", createUser: "Create", cancel: "Cancel", } @@ -32,7 +31,7 @@ export interface CreateUserFormProps { const validationSchema = Yup.object({ email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired), password: Yup.string().required(Language.passwordRequired), - username: Yup.string().required(Language.usernameRequired), + username: nameValidator(Language.usernameLabel), }) export const CreateUserForm: React.FC = ({ diff --git a/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx b/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx index 0e14a0d018653..392671cee5e46 100644 --- a/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx +++ b/site/src/components/SettingsAccountForm/SettingsAccountForm.tsx @@ -3,7 +3,7 @@ import TextField from "@material-ui/core/TextField" import { FormikContextType, FormikErrors, useFormik } from "formik" import React from "react" import * as Yup from "yup" -import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils" import { LoadingButton } from "../LoadingButton/LoadingButton" import { Stack } from "../Stack/Stack" @@ -22,7 +22,7 @@ export const Language = { const validationSchema = Yup.object({ email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired), - username: Yup.string().trim(), + username: nameValidator(Language.usernameLabel), }) export type AccountFormErrors = FormikErrors diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index a0f11bd6e5f1a..008505671bb26 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -1,13 +1,13 @@ import { screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import React from "react" -import { reach, StringSchema } from "yup" import * as API from "../../api/api" import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter" import { MockTemplate, MockWorkspace } from "../../testHelpers/entities" import { renderWithAuth } from "../../testHelpers/renderHelpers" +import { Language as FormLanguage } from "../../util/formUtils" import CreateWorkspacePage from "./CreateWorkspacePage" -import { Language, validationSchema } from "./CreateWorkspacePageView" +import { Language } from "./CreateWorkspacePageView" const renderCreateWorkspacePage = () => { return renderWithAuth(, { @@ -23,8 +23,6 @@ const fillForm = async ({ name = "example" }: { name?: string }) => { await userEvent.click(submitButton) } -const nameSchema = reach(validationSchema, "name") as StringSchema - describe("CreateWorkspacePage", () => { it("renders", async () => { renderCreateWorkspacePage() @@ -35,7 +33,7 @@ describe("CreateWorkspacePage", () => { it("shows validation error message", async () => { renderCreateWorkspacePage() await fillForm({ name: "$$$" }) - const errorMessage = await screen.findByText(Language.nameMatches) + const errorMessage = await screen.findByText(FormLanguage.nameInvalidChars(Language.nameLabel)) expect(errorMessage).toBeDefined() }) @@ -47,38 +45,4 @@ describe("CreateWorkspacePage", () => { // Check if the request was made await waitFor(() => expect(API.createWorkspace).toBeCalledTimes(1)) }) - - describe("validationSchema", () => { - it("allows a 1-letter name", () => { - const validate = () => nameSchema.validateSync("t") - expect(validate).not.toThrow() - }) - - it("allows a 32-letter name", () => { - const input = Array(32).fill("a").join("") - const validate = () => nameSchema.validateSync(input) - expect(validate).not.toThrow() - }) - - it("allows 'test-3' to be used as name", () => { - const validate = () => nameSchema.validateSync("test-3") - expect(validate).not.toThrow() - }) - - it("allows '3-test' to be used as a name", () => { - const validate = () => nameSchema.validateSync("3-test") - expect(validate).not.toThrow() - }) - - it("disallows a 33-letter name", () => { - const input = Array(33).fill("a").join("") - const validate = () => nameSchema.validateSync(input) - expect(validate).toThrow() - }) - - it("disallows a space", () => { - const validate = () => nameSchema.validateSync("test 3") - expect(validate).toThrow() - }) - }) }) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 3370c3bef2340..07c7dbe680fce 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -10,22 +10,13 @@ import { Loader } from "../../components/Loader/Loader" import { Margins } from "../../components/Margins/Margins" import { ParameterInput } from "../../components/ParameterInput/ParameterInput" import { Stack } from "../../components/Stack/Stack" -import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils" export const Language = { templateLabel: "Template", nameLabel: "Name", - nameRequired: "Please enter a name.", - nameMatches: "Name must start with a-Z or 0-9 and can contain a-Z, 0-9 or -", - nameMax: "Name cannot be longer than 32 characters", } -// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L40 -const maxLenName = 32 - -// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L18 -const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/ - export interface CreateWorkspacePageViewProps { loadingTemplates: boolean loadingTemplateSchema: boolean @@ -39,10 +30,7 @@ export interface CreateWorkspacePageViewProps { } export const validationSchema = Yup.object({ - name: Yup.string() - .required(Language.nameRequired) - .matches(usernameRE, Language.nameMatches) - .max(maxLenName, Language.nameMax), + name: nameValidator(Language.nameLabel), }) export const CreateWorkspacePageView: React.FC = (props) => { diff --git a/site/src/util/formUtils.test.ts b/site/src/util/formUtils.test.ts index 7d3a53ad51c3e..41d4005e71a6e 100644 --- a/site/src/util/formUtils.test.ts +++ b/site/src/util/formUtils.test.ts @@ -1,5 +1,5 @@ import { FormikContextType } from "formik/dist/types" -import { getFormHelpers, onChangeTrimmed } from "./formUtils" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "./formUtils" interface TestType { untouchedGoodField: string @@ -35,6 +35,8 @@ const form = { }, } as unknown as FormikContextType +const nameSchema = nameValidator("name") + describe("form util functions", () => { describe("getFormHelpers", () => { describe("without API errors", () => { @@ -94,4 +96,38 @@ describe("form util functions", () => { expect(mockHandleChange).toHaveBeenCalledWith({ target: { value: "hello" } }) }) }) + + describe("nameValidator", () => { + it("allows a 1-letter name", () => { + const validate = () => nameSchema.validateSync("a") + expect(validate).not.toThrow() + }) + + it("allows a 32-letter name", () => { + const input = Array(32).fill("a").join("") + const validate = () => nameSchema.validateSync(input) + expect(validate).not.toThrow() + }) + + it("allows 'test-3' to be used as name", () => { + const validate = () => nameSchema.validateSync("test-3") + expect(validate).not.toThrow() + }) + + it("allows '3-test' to be used as a name", () => { + const validate = () => nameSchema.validateSync("3-test") + expect(validate).not.toThrow() + }) + + it("disallows a 33-letter name", () => { + const input = Array(33).fill("a").join("") + const validate = () => nameSchema.validateSync(input) + expect(validate).toThrow() + }) + + it("disallows a space", () => { + const validate = () => nameSchema.validateSync("test 3") + expect(validate).toThrow() + }) + }) }) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index 108ffdbedf261..12d4939693d4d 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -1,5 +1,18 @@ import { FormikContextType, FormikErrors, getIn } from "formik" import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode } from "react" +import * as Yup from "yup" + +export const Language = { + nameRequired: (name: string): string => { + return `Please enter a ${name.toLowerCase()}.` + }, + nameInvalidChars: (name: string): string => { + return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -` + }, + nameTooLong: (name: string): string => { + return `${name} cannot be longer than 32 characters` + }, +} interface FormHelpers { name: string @@ -38,3 +51,16 @@ export const onChangeTrimmed = event.target.value = event.target.value.trim() form.handleChange(event) } + +// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L40 +const maxLenName = 32 + +// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L18 +const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/ + +// REMARK: see #1756 for name/username semantics +export const nameValidator = (name: string): Yup.StringSchema => + Yup.string() + .required(Language.nameRequired(name)) + .matches(usernameRE, Language.nameInvalidChars(name)) + .max(maxLenName, Language.nameTooLong(name))