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
{...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 ||

View File

@ -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"

View File

@ -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

View File

@ -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"
/>

View File

@ -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

View File

@ -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}`}

View File

@ -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

View File

@ -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();
});
});

View File

@ -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",

View File

@ -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

View File

@ -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>

View File

@ -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(

View File

@ -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}

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;
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();
});

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 {
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,
};
};