mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +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
|
<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 ||
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
|
@ -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}`}
|
||||||
|
@ -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
|
||||||
|
@ -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.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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(
|
||||||
|
@ -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}
|
||||||
|
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;
|
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();
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user