diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 6c5bc538310d1..466d4baa1d6eb 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -337,7 +337,9 @@ export const CreateTemplateForm: FC = (props) => { /> = (props) => { , - )} + {...getFieldHelpers("default_ttl_hours", { + helperText: ( + + ), + })} disabled={isSubmitting} onChange={onChangeTrimmed(form)} fullWidth @@ -377,12 +380,13 @@ export const CreateTemplateForm: FC = (props) => { , - )} + {...getFieldHelpers("autostop_requirement_days_of_week", { + helperText: ( + + ), + })} disabled={ isSubmitting || form.values.use_max_ttl || @@ -408,13 +412,14 @@ export const CreateTemplateForm: FC = (props) => { , - )} + {...getFieldHelpers("autostop_requirement_weeks", { + helperText: ( + + ), + })} disabled={ isSubmitting || form.values.use_max_ttl || @@ -453,9 +458,8 @@ export const CreateTemplateForm: FC = (props) => { ) : ( <> @@ -463,7 +467,7 @@ export const CreateTemplateForm: FC = (props) => { Learn more. ), - )} + })} disabled={ isSubmitting || !form.values.use_max_ttl || diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index c5afa5ffe025a..5a355d031ddb6 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -132,10 +132,9 @@ export const CreateUserForm: FC< label={Language.emailLabel} /> = ({ label="Name" /> diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx index 34ddab4841470..33410e87b8bc1 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx @@ -73,10 +73,9 @@ const UpdateGroupForm: FC = ({ ) : ( <> = ({ )} = ({ template }) => { - - + } title={template.display_name || template.name} linkTo={`/templates/${template.name}`} diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 39f722f59bdb1..5845538f61ee7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -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 = ({ /> { "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(); }); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx index 613b90154467a..d63dca49e26fa 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -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 = { title: "pages/TemplateSettingsPage", diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 89f26cc5d451e..1d96fde22015d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -356,10 +356,11 @@ export const TemplateScheduleForm: FC = ({ > , - )} + {...getFieldHelpers("default_ttl_ms", { + helperText: ( + + ), + })} disabled={isSubmitting} fullWidth inputProps={{ min: 0, step: 1 }} @@ -375,12 +376,13 @@ export const TemplateScheduleForm: FC = ({ > , - )} + {...getFieldHelpers("autostop_requirement_days_of_week", { + helperText: ( + + ), + })} disabled={isSubmitting || form.values.use_max_ttl} fullWidth select @@ -402,13 +404,14 @@ export const TemplateScheduleForm: FC = ({ , - )} + {...getFieldHelpers("autostop_requirement_weeks", { + helperText: ( + + ), + })} disabled={ isSubmitting || form.values.use_max_ttl || @@ -463,9 +466,8 @@ export const TemplateScheduleForm: FC = ({ ) : ( <> @@ -473,7 +475,7 @@ export const TemplateScheduleForm: FC = ({ Learn more. ), - )} + })} disabled={ isSubmitting || !form.values.use_max_ttl || @@ -581,10 +583,11 @@ export const TemplateScheduleForm: FC = ({ label="Enable Failure Cleanup" /> , - )} + {...getFieldHelpers("failure_ttl_ms", { + helperText: ( + + ), + })} disabled={isSubmitting || !form.values.failure_cleanup_enabled} fullWidth inputProps={{ min: 0, step: "any" }} @@ -610,12 +613,13 @@ export const TemplateScheduleForm: FC = ({ label="Enable Dormancy Threshold" /> , - )} + {...getFieldHelpers("time_til_dormant_ms", { + helperText: ( + + ), + })} disabled={ isSubmitting || !form.values.inactivity_cleanup_enabled } @@ -643,12 +647,13 @@ export const TemplateScheduleForm: FC = ({ label="Enable Dormancy Auto-Deletion" /> , - )} + {...getFieldHelpers("time_til_dormant_autodelete_ms", { + helperText: ( + + ), + })} disabled={ isSubmitting || !form.values.dormant_autodeletion_cleanup_enabled diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx index 876539f6109b3..75e20440ed72d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx @@ -56,13 +56,7 @@ export const TemplateSettingsLayout: FC = () => { - + {templateQuery.isError || permissionsQuery.isError ? ( ) : ( @@ -74,11 +68,7 @@ export const TemplateSettingsLayout: FC = () => { > }> -
+
diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx index 8746559169599..a455b3dae4618 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx @@ -74,7 +74,7 @@ export const TemplateVariablesForm: FC = ({ if (templateVariable.sensitive) { fieldHelpers = getFieldHelpers( "user_variable_values[" + index + "].value", - , + { helperText: }, ); } else { fieldHelpers = getFieldHelpers( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx index 85b261521b9cc..fc72ffb3089e1 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx @@ -399,7 +399,10 @@ export const WorkspaceScheduleForm: FC< label={Language.stopSwitch} /> = ({ value, maxLength }) => { + const form = useFormik({ + initialValues: { + value, + }, + onSubmit: action("submit"), + }); + + const getFieldHelpers = getFormHelpers(form, null); + + return ( +
+ + + ); +}; + +const meta: Meta = { + title: "utilities/getFormHelpers", + component: ExampleForm, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/utils/formUtils.test.ts b/site/src/utils/formUtils.test.ts index fab07cb7d6c2f..2460512947fb9 100644 --- a/site/src/utils/formUtils.test.ts +++ b/site/src/utils/formUtils.test.ts @@ -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; @@ -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(); }); diff --git a/site/src/utils/formUtils.ts b/site/src/utils/formUtils.ts index 1de43b825c14b..12eb5ea341f43 100644 --- a/site/src/utils/formUtils.ts +++ b/site/src/utils/formUtils.ts @@ -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; + onChange: ChangeEventHandler; id: string; value?: string | number; error: boolean; @@ -37,10 +52,14 @@ export const getFormHelpers = (form: FormikContextType, 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, }; };