mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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:
committed by
GitHub
parent
f9f94b5d01
commit
05eac64be4
@ -337,7 +337,9 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
{...getFieldHelpers("description", {
|
||||
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
rows={5}
|
||||
multiline
|
||||
@ -363,10 +365,11 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
<FormFields>
|
||||
<Stack direction="row" css={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_hours",
|
||||
<DefaultTTLHelperText ttl={form.values.default_ttl_hours} />,
|
||||
)}
|
||||
{...getFieldHelpers("default_ttl_hours", {
|
||||
helperText: (
|
||||
<DefaultTTLHelperText ttl={form.values.default_ttl_hours} />
|
||||
),
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
@ -377,12 +380,13 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
|
||||
<Stack direction="row" css={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"autostop_requirement_days_of_week",
|
||||
<AutostopRequirementDaysHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("autostop_requirement_days_of_week", {
|
||||
helperText: (
|
||||
<AutostopRequirementDaysHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.values.use_max_ttl ||
|
||||
@ -408,13 +412,14 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"autostop_requirement_weeks",
|
||||
<AutostopRequirementWeeksHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
weeks={form.values.autostop_requirement_weeks}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("autostop_requirement_weeks", {
|
||||
helperText: (
|
||||
<AutostopRequirementWeeksHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
weeks={form.values.autostop_requirement_weeks}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.values.use_max_ttl ||
|
||||
@ -453,9 +458,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"max_ttl_hours",
|
||||
allowAdvancedScheduling ? (
|
||||
{...getFieldHelpers("max_ttl_hours", {
|
||||
helperText: allowAdvancedScheduling ? (
|
||||
<MaxTTLHelperText ttl={form.values.max_ttl_hours} />
|
||||
) : (
|
||||
<>
|
||||
@ -463,7 +467,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
<Link href={docs("/enterprise")}>Learn more</Link>.
|
||||
</>
|
||||
),
|
||||
)}
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!form.values.use_max_ttl ||
|
||||
|
@ -132,10 +132,9 @@ export const CreateUserForm: FC<
|
||||
label={Language.emailLabel}
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"login_type",
|
||||
"Authentication method for this user",
|
||||
)}
|
||||
{...getFieldHelpers("login_type", {
|
||||
helperText: "Authentication method for this user",
|
||||
})}
|
||||
select
|
||||
id="login_type"
|
||||
data-testid="login-type-input"
|
||||
@ -180,12 +179,11 @@ export const CreateUserForm: FC<
|
||||
})}
|
||||
</TextField>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"password",
|
||||
form.values.login_type === "password"
|
||||
? ""
|
||||
: "No password required for this login type",
|
||||
)}
|
||||
{...getFieldHelpers("password", {
|
||||
helperText:
|
||||
form.values.login_type !== "password" &&
|
||||
"No password required for this login type",
|
||||
})}
|
||||
autoComplete="current-password"
|
||||
fullWidth
|
||||
id="password"
|
||||
|
@ -214,10 +214,10 @@ export const AppearanceSettingsPageView: FC<
|
||||
/>
|
||||
<Stack spacing={0}>
|
||||
<TextField
|
||||
{...serviceBannerFieldHelpers(
|
||||
"message",
|
||||
"Markdown bold, italics, and links are supported.",
|
||||
)}
|
||||
{...serviceBannerFieldHelpers("message", {
|
||||
helperText:
|
||||
"Markdown bold, italics, and links are supported.",
|
||||
})}
|
||||
fullWidth
|
||||
label="Message"
|
||||
multiline
|
||||
|
@ -52,10 +52,9 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
label="Name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"display_name",
|
||||
"Optional: keep empty to default to the name.",
|
||||
)}
|
||||
{...getFieldHelpers("display_name", {
|
||||
helperText: "Optional: keep empty to default to the name.",
|
||||
})}
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
/>
|
||||
|
@ -73,10 +73,9 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"display_name",
|
||||
"Optional: keep empty to default to the name.",
|
||||
)}
|
||||
{...getFieldHelpers("display_name", {
|
||||
helperText: "Optional: keep empty to default to the name.",
|
||||
})}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="display_name"
|
||||
autoFocus
|
||||
@ -94,11 +93,10 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
|
||||
</>
|
||||
)}
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"quota_allowance",
|
||||
`This group gives ${form.values.quota_allowance} quota credits to each
|
||||
{...getFieldHelpers("quota_allowance", {
|
||||
helperText: `This group gives ${form.values.quota_allowance} quota credits to each
|
||||
of its members.`,
|
||||
)}
|
||||
})}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
|
@ -4,13 +4,12 @@ import GeneralIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import SecurityIcon from "@mui/icons-material/LockOutlined";
|
||||
import { type FC } from "react";
|
||||
import type { Template } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { ExternalAvatar } from "components/Avatar/Avatar";
|
||||
import {
|
||||
Sidebar as BaseSidebar,
|
||||
SidebarHeader,
|
||||
SidebarNavItem,
|
||||
} from "components/Sidebar/Sidebar";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
|
||||
interface SidebarProps {
|
||||
template: Template;
|
||||
@ -21,9 +20,7 @@ export const Sidebar: FC<SidebarProps> = ({ template }) => {
|
||||
<BaseSidebar>
|
||||
<SidebarHeader
|
||||
avatar={
|
||||
<Avatar variant="square" fitImage>
|
||||
<ExternalImage src={template.icon} css={{ width: "100%" }} />
|
||||
</Avatar>
|
||||
<ExternalAvatar src={template.icon} variant="square" fitImage />
|
||||
}
|
||||
title={template.display_name || template.name}
|
||||
linkTo={`/templates/${template.name}`}
|
||||
|
@ -29,6 +29,8 @@ import {
|
||||
import { EnterpriseBadge } from "components/Badges/Badges";
|
||||
|
||||
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 =>
|
||||
Yup.object({
|
||||
@ -36,7 +38,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||
display_name: templateDisplayNameValidator("Display name"),
|
||||
description: Yup.string().max(
|
||||
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(),
|
||||
icon: iconValidator,
|
||||
@ -119,7 +121,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
{...getFieldHelpers("description", {
|
||||
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
})}
|
||||
multiline
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
|
@ -82,7 +82,7 @@ const fillAndSubmitForm = async ({
|
||||
await userEvent.type(iconField, icon);
|
||||
|
||||
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
|
||||
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",
|
||||
};
|
||||
const validate = () => getValidationSchema().validateSync(values);
|
||||
expect(validate).toThrowError(
|
||||
"Please enter a description that is less than or equal to 128 characters.",
|
||||
);
|
||||
expect(validate).toThrowError();
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { mockApiError, MockTemplate } from "testHelpers/entities";
|
||||
import { TemplateSettingsPageView } from "./TemplateSettingsPageView";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof TemplateSettingsPageView> = {
|
||||
title: "pages/TemplateSettingsPage",
|
||||
|
@ -354,10 +354,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
>
|
||||
<Stack direction="row" css={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_ms",
|
||||
<DefaultTTLHelperText ttl={form.values.default_ttl_ms} />,
|
||||
)}
|
||||
{...getFieldHelpers("default_ttl_ms", {
|
||||
helperText: (
|
||||
<DefaultTTLHelperText ttl={form.values.default_ttl_ms} />
|
||||
),
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
@ -373,12 +374,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
>
|
||||
<Stack direction="row" css={styles.ttlFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"autostop_requirement_days_of_week",
|
||||
<AutostopRequirementDaysHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("autostop_requirement_days_of_week", {
|
||||
helperText: (
|
||||
<AutostopRequirementDaysHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={isSubmitting || form.values.use_max_ttl}
|
||||
fullWidth
|
||||
select
|
||||
@ -400,13 +402,14 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"autostop_requirement_weeks",
|
||||
<AutostopRequirementWeeksHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
weeks={form.values.autostop_requirement_weeks}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("autostop_requirement_weeks", {
|
||||
helperText: (
|
||||
<AutostopRequirementWeeksHelperText
|
||||
days={form.values.autostop_requirement_days_of_week}
|
||||
weeks={form.values.autostop_requirement_weeks}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.values.use_max_ttl ||
|
||||
@ -461,9 +464,8 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"max_ttl_ms",
|
||||
allowAdvancedScheduling ? (
|
||||
{...getFieldHelpers("max_ttl_ms", {
|
||||
helperText: allowAdvancedScheduling ? (
|
||||
<MaxTTLHelperText ttl={form.values.max_ttl_ms} />
|
||||
) : (
|
||||
<>
|
||||
@ -471,7 +473,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
<Link href={docs("/enterprise")}>Learn more</Link>.
|
||||
</>
|
||||
),
|
||||
)}
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!form.values.use_max_ttl ||
|
||||
@ -579,10 +581,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
label="Enable Failure Cleanup"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"failure_ttl_ms",
|
||||
<FailureTTLHelperText ttl={form.values.failure_ttl_ms} />,
|
||||
)}
|
||||
{...getFieldHelpers("failure_ttl_ms", {
|
||||
helperText: (
|
||||
<FailureTTLHelperText ttl={form.values.failure_ttl_ms} />
|
||||
),
|
||||
})}
|
||||
disabled={isSubmitting || !form.values.failure_cleanup_enabled}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: "any" }}
|
||||
@ -608,12 +611,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
label="Enable Dormancy Threshold"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"time_til_dormant_ms",
|
||||
<DormancyTTLHelperText
|
||||
ttl={form.values.time_til_dormant_ms}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("time_til_dormant_ms", {
|
||||
helperText: (
|
||||
<DormancyTTLHelperText
|
||||
ttl={form.values.time_til_dormant_ms}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting || !form.values.inactivity_cleanup_enabled
|
||||
}
|
||||
@ -641,12 +645,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
||||
label="Enable Dormancy Auto-Deletion"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"time_til_dormant_autodelete_ms",
|
||||
<DormancyAutoDeletionTTLHelperText
|
||||
ttl={form.values.time_til_dormant_autodelete_ms}
|
||||
/>,
|
||||
)}
|
||||
{...getFieldHelpers("time_til_dormant_autodelete_ms", {
|
||||
helperText: (
|
||||
<DormancyAutoDeletionTTLHelperText
|
||||
ttl={form.values.time_til_dormant_autodelete_ms}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!form.values.dormant_autodeletion_cleanup_enabled
|
||||
|
@ -56,13 +56,7 @@ export const TemplateSettingsLayout: FC = () => {
|
||||
</Helmet>
|
||||
|
||||
<Margins>
|
||||
<Stack
|
||||
css={{
|
||||
padding: "48px 0",
|
||||
}}
|
||||
direction="row"
|
||||
spacing={10}
|
||||
>
|
||||
<Stack css={{ padding: "48px 0" }} direction="row" spacing={10}>
|
||||
{templateQuery.isError || permissionsQuery.isError ? (
|
||||
<ErrorAlert error={templateQuery.error} />
|
||||
) : (
|
||||
@ -74,11 +68,7 @@ export const TemplateSettingsLayout: FC = () => {
|
||||
>
|
||||
<Sidebar template={templateQuery.data} />
|
||||
<Suspense fallback={<Loader />}>
|
||||
<main
|
||||
css={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<main css={{ width: "100%" }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</Suspense>
|
||||
|
@ -74,7 +74,7 @@ export const TemplateVariablesForm: FC<TemplateVariablesForm> = ({
|
||||
if (templateVariable.sensitive) {
|
||||
fieldHelpers = getFieldHelpers(
|
||||
"user_variable_values[" + index + "].value",
|
||||
<SensitiveVariableHelperText />,
|
||||
{ helperText: <SensitiveVariableHelperText /> },
|
||||
);
|
||||
} else {
|
||||
fieldHelpers = getFieldHelpers(
|
||||
|
@ -399,7 +399,10 @@ export const WorkspaceScheduleForm: FC<
|
||||
label={Language.stopSwitch}
|
||||
/>
|
||||
<TextField
|
||||
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
|
||||
{...formHelpers("ttl", {
|
||||
helperText: ttlShutdownAt(form.values.ttl),
|
||||
backendFieldName: "ttl_ms",
|
||||
})}
|
||||
disabled={isLoading || !form.values.autostopEnabled}
|
||||
inputProps={{ min: 0, step: "any" }}
|
||||
label={Language.ttlLabel}
|
||||
|
69
site/src/utils/formUtils.stories.tsx
Normal file
69
site/src/utils/formUtils.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
@ -7,6 +7,9 @@ interface TestType {
|
||||
untouchedBadField: string;
|
||||
touchedGoodField: string;
|
||||
touchedBadField: string;
|
||||
maxLengthOk: string;
|
||||
maxLengthClose: string;
|
||||
maxLengthOver: string;
|
||||
}
|
||||
|
||||
const mockHandleChange = jest.fn();
|
||||
@ -17,21 +20,36 @@ const form = {
|
||||
untouchedBadField: "oops!",
|
||||
touchedGoodField: undefined,
|
||||
touchedBadField: "oops!",
|
||||
maxLengthOk: undefined,
|
||||
maxLengthClose: undefined,
|
||||
maxLengthOver: undefined,
|
||||
},
|
||||
touched: {
|
||||
untouchedGoodField: false,
|
||||
untouchedBadField: false,
|
||||
touchedGoodField: 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,
|
||||
handleBlur: jest.fn(),
|
||||
getFieldProps: (name: string) => {
|
||||
getFieldProps: (name: keyof TestType) => {
|
||||
return {
|
||||
name,
|
||||
onBlur: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
value: "",
|
||||
value: form.values[name] ?? "",
|
||||
};
|
||||
},
|
||||
} as unknown as FormikContextType<TestType>;
|
||||
@ -46,6 +64,15 @@ describe("form util functions", () => {
|
||||
const untouchedBadResult = getFieldHelpers("untouchedBadField");
|
||||
const touchedGoodResult = getFieldHelpers("touchedGoodField");
|
||||
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'", () => {
|
||||
expect(untouchedGoodResult.name).toEqual("untouchedGoodField");
|
||||
expect(untouchedGoodResult.onBlur).toBeDefined();
|
||||
@ -56,17 +83,29 @@ describe("form util functions", () => {
|
||||
expect(untouchedGoodResult.id).toEqual("untouchedGoodField");
|
||||
});
|
||||
it("sets error to true if touched and invalid", () => {
|
||||
expect(untouchedGoodResult.error).toBeFalsy;
|
||||
expect(untouchedBadResult.error).toBeFalsy;
|
||||
expect(touchedGoodResult.error).toBeFalsy;
|
||||
expect(touchedBadResult.error).toBeTruthy;
|
||||
expect(untouchedGoodResult.error).toBeFalsy();
|
||||
expect(untouchedBadResult.error).toBeFalsy();
|
||||
expect(touchedGoodResult.error).toBeFalsy();
|
||||
expect(touchedBadResult.error).toBeTruthy();
|
||||
});
|
||||
it("sets helperText to the error message if touched and invalid", () => {
|
||||
expect(untouchedGoodResult.helperText).toBeUndefined;
|
||||
expect(untouchedBadResult.helperText).toBeUndefined;
|
||||
expect(touchedGoodResult.helperText).toBeUndefined;
|
||||
expect(untouchedGoodResult.helperText).toBeUndefined();
|
||||
expect(untouchedBadResult.helperText).toBeUndefined();
|
||||
expect(touchedGoodResult.helperText).toBeUndefined();
|
||||
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", () => {
|
||||
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", () => {
|
||||
const input = Array(32).fill("a").join("");
|
||||
const input = "a".repeat(32);
|
||||
const validate = () => nameSchema.validateSync(input);
|
||||
expect(validate).not.toThrow();
|
||||
});
|
||||
@ -145,7 +184,7 @@ describe("form util functions", () => {
|
||||
});
|
||||
|
||||
it("disallows a 33-letter name", () => {
|
||||
const input = Array(33).fill("a").join("");
|
||||
const input = "a".repeat(33);
|
||||
const validate = () => nameSchema.validateSync(input);
|
||||
expect(validate).toThrow();
|
||||
});
|
||||
|
@ -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 {
|
||||
name: string;
|
||||
onBlur: FocusEventHandler;
|
||||
onChange: ChangeEventHandler;
|
||||
onBlur: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||
id: string;
|
||||
value?: string | number;
|
||||
error: boolean;
|
||||
@ -37,10 +52,14 @@ export const getFormHelpers =
|
||||
<TFormValues>(form: FormikContextType<TFormValues>, error?: unknown) =>
|
||||
(
|
||||
fieldName: keyof TFormValues | string,
|
||||
helperText?: ReactNode,
|
||||
// backendFieldName is used when the value in the form is named different from the backend
|
||||
backendFieldName?: string,
|
||||
options: GetFormHelperOptions = {},
|
||||
): FormHelpers => {
|
||||
const {
|
||||
backendFieldName,
|
||||
helperText: defaultHelperText,
|
||||
maxLength,
|
||||
} = options;
|
||||
let helperText = defaultHelperText;
|
||||
const apiValidationErrors = isApiValidationError(error)
|
||||
? (mapApiErrorToFieldErrors(
|
||||
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
|
||||
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
|
||||
// Since the field in the form can be different 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;
|
||||
|
||||
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 {
|
||||
...form.getFieldProps(fieldName),
|
||||
...fieldProps,
|
||||
id: fieldName.toString(),
|
||||
error: touched && Boolean(errorToDisplay),
|
||||
helperText: touched ? errorToDisplay ?? helperText : helperText,
|
||||
error: Boolean(errorToDisplay),
|
||||
helperText: errorToDisplay || helperText,
|
||||
};
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user