feat: add a character counter for fields with length limits (#11558)

- refactors`getFormHelpers` to accept an options object
- adds a `maxLength` option which will display a message and character counter for fields with length limits
- set `maxLength` option for template description fields
This commit is contained in:
Kayla Washburn-Love
2024-01-11 12:15:43 -07:00
committed by GitHub
parent f9f94b5d01
commit 05eac64be4
16 changed files with 277 additions and 132 deletions

View File

@ -337,7 +337,9 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
/> />
<TextField <TextField
{...getFieldHelpers("description")} {...getFieldHelpers("description", {
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
})}
disabled={isSubmitting} disabled={isSubmitting}
rows={5} rows={5}
multiline multiline
@ -363,10 +365,11 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
<FormFields> <FormFields>
<Stack direction="row" css={styles.ttlFields}> <Stack direction="row" css={styles.ttlFields}>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("default_ttl_hours", {
"default_ttl_hours", helperText: (
<DefaultTTLHelperText ttl={form.values.default_ttl_hours} />, <DefaultTTLHelperText ttl={form.values.default_ttl_hours} />
)} ),
})}
disabled={isSubmitting} disabled={isSubmitting}
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
fullWidth fullWidth
@ -377,12 +380,13 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
<Stack direction="row" css={styles.ttlFields}> <Stack direction="row" css={styles.ttlFields}>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("autostop_requirement_days_of_week", {
"autostop_requirement_days_of_week", helperText: (
<AutostopRequirementDaysHelperText <AutostopRequirementDaysHelperText
days={form.values.autostop_requirement_days_of_week} days={form.values.autostop_requirement_days_of_week}
/>, />
)} ),
})}
disabled={ disabled={
isSubmitting || isSubmitting ||
form.values.use_max_ttl || form.values.use_max_ttl ||
@ -408,13 +412,14 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</TextField> </TextField>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("autostop_requirement_weeks", {
"autostop_requirement_weeks", helperText: (
<AutostopRequirementWeeksHelperText <AutostopRequirementWeeksHelperText
days={form.values.autostop_requirement_days_of_week} days={form.values.autostop_requirement_days_of_week}
weeks={form.values.autostop_requirement_weeks} weeks={form.values.autostop_requirement_weeks}
/>, />
)} ),
})}
disabled={ disabled={
isSubmitting || isSubmitting ||
form.values.use_max_ttl || form.values.use_max_ttl ||
@ -453,9 +458,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</Stack> </Stack>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("max_ttl_hours", {
"max_ttl_hours", helperText: allowAdvancedScheduling ? (
allowAdvancedScheduling ? (
<MaxTTLHelperText ttl={form.values.max_ttl_hours} /> <MaxTTLHelperText ttl={form.values.max_ttl_hours} />
) : ( ) : (
<> <>
@ -463,7 +467,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
<Link href={docs("/enterprise")}>Learn more</Link>. <Link href={docs("/enterprise")}>Learn more</Link>.
</> </>
), ),
)} })}
disabled={ disabled={
isSubmitting || isSubmitting ||
!form.values.use_max_ttl || !form.values.use_max_ttl ||

View File

@ -132,10 +132,9 @@ export const CreateUserForm: FC<
label={Language.emailLabel} label={Language.emailLabel}
/> />
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("login_type", {
"login_type", helperText: "Authentication method for this user",
"Authentication method for this user", })}
)}
select select
id="login_type" id="login_type"
data-testid="login-type-input" data-testid="login-type-input"
@ -180,12 +179,11 @@ export const CreateUserForm: FC<
})} })}
</TextField> </TextField>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("password", {
"password", helperText:
form.values.login_type === "password" form.values.login_type !== "password" &&
? "" "No password required for this login type",
: "No password required for this login type", })}
)}
autoComplete="current-password" autoComplete="current-password"
fullWidth fullWidth
id="password" id="password"

View File

@ -214,10 +214,10 @@ export const AppearanceSettingsPageView: FC<
/> />
<Stack spacing={0}> <Stack spacing={0}>
<TextField <TextField
{...serviceBannerFieldHelpers( {...serviceBannerFieldHelpers("message", {
"message", helperText:
"Markdown bold, italics, and links are supported.", "Markdown bold, italics, and links are supported.",
)} })}
fullWidth fullWidth
label="Message" label="Message"
multiline multiline

View File

@ -52,10 +52,9 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
label="Name" label="Name"
/> />
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("display_name", {
"display_name", helperText: "Optional: keep empty to default to the name.",
"Optional: keep empty to default to the name.", })}
)}
fullWidth fullWidth
label="Display Name" label="Display Name"
/> />

View File

@ -73,10 +73,9 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
) : ( ) : (
<> <>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("display_name", {
"display_name", helperText: "Optional: keep empty to default to the name.",
"Optional: keep empty to default to the name.", })}
)}
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
autoComplete="display_name" autoComplete="display_name"
autoFocus autoFocus
@ -94,11 +93,10 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
</> </>
)} )}
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("quota_allowance", {
"quota_allowance", helperText: `This group gives ${form.values.quota_allowance} quota credits to each
`This group gives ${form.values.quota_allowance} quota credits to each
of its members.`, of its members.`,
)} })}
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
autoFocus autoFocus
fullWidth fullWidth

View File

@ -4,13 +4,12 @@ import GeneralIcon from "@mui/icons-material/SettingsOutlined";
import SecurityIcon from "@mui/icons-material/LockOutlined"; import SecurityIcon from "@mui/icons-material/LockOutlined";
import { type FC } from "react"; import { type FC } from "react";
import type { Template } from "api/typesGenerated"; import type { Template } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar"; import { ExternalAvatar } from "components/Avatar/Avatar";
import { import {
Sidebar as BaseSidebar, Sidebar as BaseSidebar,
SidebarHeader, SidebarHeader,
SidebarNavItem, SidebarNavItem,
} from "components/Sidebar/Sidebar"; } from "components/Sidebar/Sidebar";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
interface SidebarProps { interface SidebarProps {
template: Template; template: Template;
@ -21,9 +20,7 @@ export const Sidebar: FC<SidebarProps> = ({ template }) => {
<BaseSidebar> <BaseSidebar>
<SidebarHeader <SidebarHeader
avatar={ avatar={
<Avatar variant="square" fitImage> <ExternalAvatar src={template.icon} variant="square" fitImage />
<ExternalImage src={template.icon} css={{ width: "100%" }} />
</Avatar>
} }
title={template.display_name || template.name} title={template.display_name || template.name}
linkTo={`/templates/${template.name}`} linkTo={`/templates/${template.name}`}

View File

@ -29,6 +29,8 @@ import {
import { EnterpriseBadge } from "components/Badges/Badges"; import { EnterpriseBadge } from "components/Badges/Badges";
const MAX_DESCRIPTION_CHAR_LIMIT = 128; const MAX_DESCRIPTION_CHAR_LIMIT = 128;
const MAX_DESCRIPTION_MESSAGE =
"Please enter a description that is no longer than 128 characters.";
export const getValidationSchema = (): Yup.AnyObjectSchema => export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({ Yup.object({
@ -36,7 +38,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
display_name: templateDisplayNameValidator("Display name"), display_name: templateDisplayNameValidator("Display name"),
description: Yup.string().max( description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT, MAX_DESCRIPTION_CHAR_LIMIT,
"Please enter a description that is less than or equal to 128 characters.", MAX_DESCRIPTION_MESSAGE,
), ),
allow_user_cancel_workspace_jobs: Yup.boolean(), allow_user_cancel_workspace_jobs: Yup.boolean(),
icon: iconValidator, icon: iconValidator,
@ -119,7 +121,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
/> />
<TextField <TextField
{...getFieldHelpers("description")} {...getFieldHelpers("description", {
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
})}
multiline multiline
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth

View File

@ -82,7 +82,7 @@ const fillAndSubmitForm = async ({
await userEvent.type(iconField, icon); await userEvent.type(iconField, icon);
const allowCancelJobsField = screen.getByRole("checkbox", { const allowCancelJobsField = screen.getByRole("checkbox", {
name: "Allow users to cancel in-progress workspace jobs. Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.", name: /allow users to cancel in-progress workspace jobs/i,
}); });
// checkbox is checked by default, so it must be clicked to get unchecked // checkbox is checked by default, so it must be clicked to get unchecked
if (!allow_user_cancel_workspace_jobs) { if (!allow_user_cancel_workspace_jobs) {
@ -123,8 +123,6 @@ describe("TemplateSettingsPage", () => {
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a", "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a",
}; };
const validate = () => getValidationSchema().validateSync(values); const validate = () => getValidationSchema().validateSync(values);
expect(validate).toThrowError( expect(validate).toThrowError();
"Please enter a description that is less than or equal to 128 characters.",
);
}); });
}); });

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError, MockTemplate } from "testHelpers/entities"; import { mockApiError, MockTemplate } from "testHelpers/entities";
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof TemplateSettingsPageView> = { const meta: Meta<typeof TemplateSettingsPageView> = {
title: "pages/TemplateSettingsPage", title: "pages/TemplateSettingsPage",

View File

@ -354,10 +354,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
> >
<Stack direction="row" css={styles.ttlFields}> <Stack direction="row" css={styles.ttlFields}>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("default_ttl_ms", {
"default_ttl_ms", helperText: (
<DefaultTTLHelperText ttl={form.values.default_ttl_ms} />, <DefaultTTLHelperText ttl={form.values.default_ttl_ms} />
)} ),
})}
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth
inputProps={{ min: 0, step: 1 }} inputProps={{ min: 0, step: 1 }}
@ -373,12 +374,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
> >
<Stack direction="row" css={styles.ttlFields}> <Stack direction="row" css={styles.ttlFields}>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("autostop_requirement_days_of_week", {
"autostop_requirement_days_of_week", helperText: (
<AutostopRequirementDaysHelperText <AutostopRequirementDaysHelperText
days={form.values.autostop_requirement_days_of_week} days={form.values.autostop_requirement_days_of_week}
/>, />
)} ),
})}
disabled={isSubmitting || form.values.use_max_ttl} disabled={isSubmitting || form.values.use_max_ttl}
fullWidth fullWidth
select select
@ -400,13 +402,14 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</TextField> </TextField>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("autostop_requirement_weeks", {
"autostop_requirement_weeks", helperText: (
<AutostopRequirementWeeksHelperText <AutostopRequirementWeeksHelperText
days={form.values.autostop_requirement_days_of_week} days={form.values.autostop_requirement_days_of_week}
weeks={form.values.autostop_requirement_weeks} weeks={form.values.autostop_requirement_weeks}
/>, />
)} ),
})}
disabled={ disabled={
isSubmitting || isSubmitting ||
form.values.use_max_ttl || form.values.use_max_ttl ||
@ -461,9 +464,8 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</Stack> </Stack>
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("max_ttl_ms", {
"max_ttl_ms", helperText: allowAdvancedScheduling ? (
allowAdvancedScheduling ? (
<MaxTTLHelperText ttl={form.values.max_ttl_ms} /> <MaxTTLHelperText ttl={form.values.max_ttl_ms} />
) : ( ) : (
<> <>
@ -471,7 +473,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
<Link href={docs("/enterprise")}>Learn more</Link>. <Link href={docs("/enterprise")}>Learn more</Link>.
</> </>
), ),
)} })}
disabled={ disabled={
isSubmitting || isSubmitting ||
!form.values.use_max_ttl || !form.values.use_max_ttl ||
@ -579,10 +581,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
label="Enable Failure Cleanup" label="Enable Failure Cleanup"
/> />
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("failure_ttl_ms", {
"failure_ttl_ms", helperText: (
<FailureTTLHelperText ttl={form.values.failure_ttl_ms} />, <FailureTTLHelperText ttl={form.values.failure_ttl_ms} />
)} ),
})}
disabled={isSubmitting || !form.values.failure_cleanup_enabled} disabled={isSubmitting || !form.values.failure_cleanup_enabled}
fullWidth fullWidth
inputProps={{ min: 0, step: "any" }} inputProps={{ min: 0, step: "any" }}
@ -608,12 +611,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
label="Enable Dormancy Threshold" label="Enable Dormancy Threshold"
/> />
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("time_til_dormant_ms", {
"time_til_dormant_ms", helperText: (
<DormancyTTLHelperText <DormancyTTLHelperText
ttl={form.values.time_til_dormant_ms} ttl={form.values.time_til_dormant_ms}
/>, />
)} ),
})}
disabled={ disabled={
isSubmitting || !form.values.inactivity_cleanup_enabled isSubmitting || !form.values.inactivity_cleanup_enabled
} }
@ -641,12 +645,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
label="Enable Dormancy Auto-Deletion" label="Enable Dormancy Auto-Deletion"
/> />
<TextField <TextField
{...getFieldHelpers( {...getFieldHelpers("time_til_dormant_autodelete_ms", {
"time_til_dormant_autodelete_ms", helperText: (
<DormancyAutoDeletionTTLHelperText <DormancyAutoDeletionTTLHelperText
ttl={form.values.time_til_dormant_autodelete_ms} ttl={form.values.time_til_dormant_autodelete_ms}
/>, />
)} ),
})}
disabled={ disabled={
isSubmitting || isSubmitting ||
!form.values.dormant_autodeletion_cleanup_enabled !form.values.dormant_autodeletion_cleanup_enabled

View File

@ -56,13 +56,7 @@ export const TemplateSettingsLayout: FC = () => {
</Helmet> </Helmet>
<Margins> <Margins>
<Stack <Stack css={{ padding: "48px 0" }} direction="row" spacing={10}>
css={{
padding: "48px 0",
}}
direction="row"
spacing={10}
>
{templateQuery.isError || permissionsQuery.isError ? ( {templateQuery.isError || permissionsQuery.isError ? (
<ErrorAlert error={templateQuery.error} /> <ErrorAlert error={templateQuery.error} />
) : ( ) : (
@ -74,11 +68,7 @@ export const TemplateSettingsLayout: FC = () => {
> >
<Sidebar template={templateQuery.data} /> <Sidebar template={templateQuery.data} />
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<main <main css={{ width: "100%" }}>
css={{
width: "100%",
}}
>
<Outlet /> <Outlet />
</main> </main>
</Suspense> </Suspense>

View File

@ -74,7 +74,7 @@ export const TemplateVariablesForm: FC<TemplateVariablesForm> = ({
if (templateVariable.sensitive) { if (templateVariable.sensitive) {
fieldHelpers = getFieldHelpers( fieldHelpers = getFieldHelpers(
"user_variable_values[" + index + "].value", "user_variable_values[" + index + "].value",
<SensitiveVariableHelperText />, { helperText: <SensitiveVariableHelperText /> },
); );
} else { } else {
fieldHelpers = getFieldHelpers( fieldHelpers = getFieldHelpers(

View File

@ -399,7 +399,10 @@ export const WorkspaceScheduleForm: FC<
label={Language.stopSwitch} label={Language.stopSwitch}
/> />
<TextField <TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")} {...formHelpers("ttl", {
helperText: ttlShutdownAt(form.values.ttl),
backendFieldName: "ttl_ms",
})}
disabled={isLoading || !form.values.autostopEnabled} disabled={isLoading || !form.values.autostopEnabled}
inputProps={{ min: 0, step: "any" }} inputProps={{ min: 0, step: "any" }}
label={Language.ttlLabel} label={Language.ttlLabel}

View File

@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react";
import { type FC } from "react";
import TextField from "@mui/material/TextField";
import { Form } from "components/Form/Form";
import { getFormHelpers } from "./formUtils";
import { useFormik } from "formik";
import { action } from "@storybook/addon-actions";
interface ExampleFormProps {
value?: string;
maxLength?: number;
}
const ExampleForm: FC<ExampleFormProps> = ({ value, maxLength }) => {
const form = useFormik({
initialValues: {
value,
},
onSubmit: action("submit"),
});
const getFieldHelpers = getFormHelpers(form, null);
return (
<Form>
<TextField
label="Value"
rows={2}
{...getFieldHelpers("value", { maxLength })}
/>
</Form>
);
};
const meta: Meta<typeof ExampleForm> = {
title: "utilities/getFormHelpers",
component: ExampleForm,
};
export default meta;
type Story = StoryObj<typeof Form>;
export const UnderMaxLength: Story = {
args: {
value: "a".repeat(98),
maxLength: 128,
},
};
export const CloseToMaxLength: Story = {
args: {
value: "a".repeat(99),
maxLength: 128,
},
};
export const AtMaxLength: Story = {
args: {
value: "a".repeat(128),
maxLength: 128,
},
};
export const OverMaxLength: Story = {
args: {
value: "a".repeat(129),
maxLength: 128,
},
};

View File

@ -7,6 +7,9 @@ interface TestType {
untouchedBadField: string; untouchedBadField: string;
touchedGoodField: string; touchedGoodField: string;
touchedBadField: string; touchedBadField: string;
maxLengthOk: string;
maxLengthClose: string;
maxLengthOver: string;
} }
const mockHandleChange = jest.fn(); const mockHandleChange = jest.fn();
@ -17,21 +20,36 @@ const form = {
untouchedBadField: "oops!", untouchedBadField: "oops!",
touchedGoodField: undefined, touchedGoodField: undefined,
touchedBadField: "oops!", touchedBadField: "oops!",
maxLengthOk: undefined,
maxLengthClose: undefined,
maxLengthOver: undefined,
}, },
touched: { touched: {
untouchedGoodField: false, untouchedGoodField: false,
untouchedBadField: false, untouchedBadField: false,
touchedGoodField: true, touchedGoodField: true,
touchedBadField: true, touchedBadField: true,
maxLengthOk: false,
maxLengthClose: false,
maxLengthOver: false,
},
values: {
untouchedGoodField: "",
untouchedBadField: "",
touchedGoodField: "",
touchedBadField: "",
maxLengthOk: "",
maxLengthClose: "a".repeat(32),
maxLengthOver: "a".repeat(33),
}, },
handleChange: mockHandleChange, handleChange: mockHandleChange,
handleBlur: jest.fn(), handleBlur: jest.fn(),
getFieldProps: (name: string) => { getFieldProps: (name: keyof TestType) => {
return { return {
name, name,
onBlur: jest.fn(), onBlur: jest.fn(),
onChange: jest.fn(), onChange: jest.fn(),
value: "", value: form.values[name] ?? "",
}; };
}, },
} as unknown as FormikContextType<TestType>; } as unknown as FormikContextType<TestType>;
@ -46,6 +64,15 @@ describe("form util functions", () => {
const untouchedBadResult = getFieldHelpers("untouchedBadField"); const untouchedBadResult = getFieldHelpers("untouchedBadField");
const touchedGoodResult = getFieldHelpers("touchedGoodField"); const touchedGoodResult = getFieldHelpers("touchedGoodField");
const touchedBadResult = getFieldHelpers("touchedBadField"); const touchedBadResult = getFieldHelpers("touchedBadField");
const maxLengthOk = getFieldHelpers("maxLengthOk", {
maxLength: 32,
});
const maxLengthClose = getFieldHelpers("maxLengthClose", {
maxLength: 32,
});
const maxLengthOver = getFieldHelpers("maxLengthOver", {
maxLength: 32,
});
it("populates the 'field props'", () => { it("populates the 'field props'", () => {
expect(untouchedGoodResult.name).toEqual("untouchedGoodField"); expect(untouchedGoodResult.name).toEqual("untouchedGoodField");
expect(untouchedGoodResult.onBlur).toBeDefined(); expect(untouchedGoodResult.onBlur).toBeDefined();
@ -56,17 +83,29 @@ describe("form util functions", () => {
expect(untouchedGoodResult.id).toEqual("untouchedGoodField"); expect(untouchedGoodResult.id).toEqual("untouchedGoodField");
}); });
it("sets error to true if touched and invalid", () => { it("sets error to true if touched and invalid", () => {
expect(untouchedGoodResult.error).toBeFalsy; expect(untouchedGoodResult.error).toBeFalsy();
expect(untouchedBadResult.error).toBeFalsy; expect(untouchedBadResult.error).toBeFalsy();
expect(touchedGoodResult.error).toBeFalsy; expect(touchedGoodResult.error).toBeFalsy();
expect(touchedBadResult.error).toBeTruthy; expect(touchedBadResult.error).toBeTruthy();
}); });
it("sets helperText to the error message if touched and invalid", () => { it("sets helperText to the error message if touched and invalid", () => {
expect(untouchedGoodResult.helperText).toBeUndefined; expect(untouchedGoodResult.helperText).toBeUndefined();
expect(untouchedBadResult.helperText).toBeUndefined; expect(untouchedBadResult.helperText).toBeUndefined();
expect(touchedGoodResult.helperText).toBeUndefined; expect(touchedGoodResult.helperText).toBeUndefined();
expect(touchedBadResult.helperText).toEqual("oops!"); expect(touchedBadResult.helperText).toEqual("oops!");
}); });
it("allows short entries", () => {
expect(maxLengthOk.error).toBe(false);
expect(maxLengthOk.helperText).toBeUndefined();
});
it("warns on entries close to the limit", () => {
expect(maxLengthClose.error).toBe(false);
expect(maxLengthClose.helperText).toBeDefined();
});
it("reports an error for entries that are too long", () => {
expect(maxLengthOver.error).toBe(true);
expect(maxLengthOver.helperText).toBeDefined();
});
}); });
describe("with API errors", () => { describe("with API errors", () => {
it("shows an error if there is only an API error", () => { it("shows an error if there is only an API error", () => {
@ -129,7 +168,7 @@ describe("form util functions", () => {
}); });
it("allows a 32-letter name", () => { it("allows a 32-letter name", () => {
const input = Array(32).fill("a").join(""); const input = "a".repeat(32);
const validate = () => nameSchema.validateSync(input); const validate = () => nameSchema.validateSync(input);
expect(validate).not.toThrow(); expect(validate).not.toThrow();
}); });
@ -145,7 +184,7 @@ describe("form util functions", () => {
}); });
it("disallows a 33-letter name", () => { it("disallows a 33-letter name", () => {
const input = Array(33).fill("a").join(""); const input = "a".repeat(33);
const validate = () => nameSchema.validateSync(input); const validate = () => nameSchema.validateSync(input);
expect(validate).toThrow(); expect(validate).toThrow();
}); });

View File

@ -23,10 +23,25 @@ const Language = {
}, },
}; };
interface GetFormHelperOptions {
helperText?: ReactNode;
/**
* backendFieldName remaps the name in the form, for when it doesn't match the
* name used by the backend
*/
backendFieldName?: string;
/**
* maxLength is used for showing helper text on fields that have a limited length,
* which will let the user know how much space they have left, or how much they are
* over the limit. Zero and negative values will be ignored.
*/
maxLength?: number;
}
interface FormHelpers { interface FormHelpers {
name: string; name: string;
onBlur: FocusEventHandler; onBlur: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onChange: ChangeEventHandler; onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
id: string; id: string;
value?: string | number; value?: string | number;
error: boolean; error: boolean;
@ -37,10 +52,14 @@ export const getFormHelpers =
<TFormValues>(form: FormikContextType<TFormValues>, error?: unknown) => <TFormValues>(form: FormikContextType<TFormValues>, error?: unknown) =>
( (
fieldName: keyof TFormValues | string, fieldName: keyof TFormValues | string,
helperText?: ReactNode, options: GetFormHelperOptions = {},
// backendFieldName is used when the value in the form is named different from the backend
backendFieldName?: string,
): FormHelpers => { ): FormHelpers => {
const {
backendFieldName,
helperText: defaultHelperText,
maxLength,
} = options;
let helperText = defaultHelperText;
const apiValidationErrors = isApiValidationError(error) const apiValidationErrors = isApiValidationError(error)
? (mapApiErrorToFieldErrors( ? (mapApiErrorToFieldErrors(
error.response.data, error.response.data,
@ -49,17 +68,39 @@ export const getFormHelpers =
// Since the fieldName can be a path string like parameters[0].value we need to use getIn // 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 touched = Boolean(getIn(form.touched, fieldName.toString()));
const formError = getIn(form.errors, fieldName.toString()); const formError = getIn(form.errors, fieldName.toString());
// Since the field in the form can be diff from the backend, we need to // Since the field in the form can be different from the backend, we need to
// check for both when getting the error // check for both when getting the error
const apiField = backendFieldName ?? fieldName; const apiField = backendFieldName ?? fieldName;
const apiError = apiValidationErrors?.[apiField.toString()]; const apiError = apiValidationErrors?.[apiField.toString()];
const errorToDisplay = apiError ?? formError;
const fieldProps = form.getFieldProps(fieldName);
const value = fieldProps.value;
let lengthError: ReactNode = null;
// Show a message if the input is approaching or over the maximum length.
if (
maxLength &&
maxLength > 0 &&
typeof value === "string" &&
value.length > maxLength - 30
) {
helperText = `This cannot be longer than ${maxLength} characters. (${value.length}/${maxLength})`;
// Show it as an error, rather than a hint
if (value.length > maxLength) {
lengthError = helperText;
}
}
// API and regular validation errors should wait to be shown, but length errors should
// be more responsive.
const errorToDisplay =
(touched && apiError) || lengthError || (touched && formError);
return { return {
...form.getFieldProps(fieldName), ...fieldProps,
id: fieldName.toString(), id: fieldName.toString(),
error: touched && Boolean(errorToDisplay), error: Boolean(errorToDisplay),
helperText: touched ? errorToDisplay ?? helperText : helperText, helperText: errorToDisplay || helperText,
}; };
}; };