fix: UX issues in template settings form's default auto-stop field (#5330)

* Fix helper text

- handles 0 ttl
- uses helper text typography
- pluralizes
- still doesn't override error (once considered touched)

* Show user friendly field name in error text

* Format

* Override label through Yup instead

* Switch to i18n - wip

* Fix i18n by thunking schema

* Fix template settings tests

* Replace third arg to getFieldHelpers -is used after all
This commit is contained in:
Presley Pizzo
2022-12-07 11:32:39 -05:00
committed by GitHub
parent ee605b34b6
commit 8ea09235f9
8 changed files with 111 additions and 97 deletions

View File

@ -8,6 +8,7 @@ import agent from "./agent.json"
import buildPage from "./buildPage.json" import buildPage from "./buildPage.json"
import workspacesPage from "./workspacesPage.json" import workspacesPage from "./workspacesPage.json"
import usersPage from "./usersPage.json" import usersPage from "./usersPage.json"
import templateSettingsPage from "./templateSettingsPage.json"
import templateVersionPage from "./templateVersionPage.json" import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json" import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
@ -24,6 +25,7 @@ export const en = {
buildPage, buildPage,
workspacesPage, workspacesPage,
usersPage, usersPage,
templateSettingsPage,
templateVersionPage, templateVersionPage,
loginPage, loginPage,
workspaceChangeVersionPage, workspaceChangeVersionPage,

View File

@ -1,16 +1,4 @@
{ {
"deleteSuccess": "Template successfully deleted.", "deleteSuccess": "Template successfully deleted.",
"createdVersion": "created the version", "createdVersion": "created the version"
"templateSettings": {
"title": "Template settings",
"dangerZone": {
"dangerZoneHeader": "Danger Zone",
"deleteTemplateHeader": "Delete this template",
"deleteTemplateCaption": "Do you want to permanently delete this template?",
"deleteCta": "Delete Template"
}
},
"displayNameLabel": "Display name",
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
} }

View File

@ -0,0 +1,24 @@
{
"title": "Template settings",
"nameLabel": "Name",
"displayNameLabel": "Display name",
"descriptionLabel": "Description",
"descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.",
"defaultTtlLabel": "Auto-stop default",
"iconLabel": "Icon",
"formAriaLabel": "Template settings form",
"selectEmoji": "Select emoji",
"ttlMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
"ttlMinError": "Default time until auto-stop must not be less than 0.",
"ttlHelperText_zero": "Workspaces created from this template will run until stopped manually.",
"ttlHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.",
"ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.",
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
"dangerZone": {
"dangerZoneHeader": "Danger Zone",
"deleteTemplateHeader": "Delete this template",
"deleteTemplateCaption": "Do you want to permanently delete this template?",
"deleteCta": "Delete Template"
}
}

View File

@ -24,43 +24,44 @@ import {
import * as Yup from "yup" import * as Yup from "yup"
import i18next from "i18next" import i18next from "i18next"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
export const Language = { const TTLHelperText = ({ ttl }: { ttl?: number }) => {
nameLabel: "Name", const { t } = useTranslation("templateSettingsPage")
descriptionLabel: "Description", const count = typeof ttl !== "number" ? 0 : ttl
defaultTtlLabel: "Auto-stop default", return (
iconLabel: "Icon", // no helper text if ttl is negative - error will show once field is considered touched
formAriaLabel: "Template settings form", <Maybe condition={count >= 0}>
selectEmoji: "Select emoji", <span>{t("ttlHelperText", { count })}</span>
ttlMaxError: </Maybe>
"Please enter a limit that is less than or equal to 168 hours (7 days).", )
descriptionMaxError:
"Please enter a description that is less than or equal to 128 characters.",
ttlHelperText: (ttl: number): string =>
`Workspaces created from this template will default to stopping after ${ttl} hours.`,
} }
const MAX_DESCRIPTION_CHAR_LIMIT = 128 const MAX_DESCRIPTION_CHAR_LIMIT = 128
const MAX_TTL_DAYS = 7 const MAX_TTL_DAYS = 7
const MS_HOUR_CONVERSION = 3600000 const MS_HOUR_CONVERSION = 3600000
export const validationSchema = Yup.object({ export const getValidationSchema = (): Yup.AnyObjectSchema =>
name: nameValidator(Language.nameLabel), Yup.object({
display_name: templateDisplayNameValidator( name: nameValidator(i18next.t("nameLabel", { ns: "templateSettingsPage" })),
i18next.t("displayNameLabel", { display_name: templateDisplayNameValidator(
ns: "templatePage", i18next.t("displayNameLabel", {
}), ns: "templateSettingsPage",
), }),
description: Yup.string().max( ),
MAX_DESCRIPTION_CHAR_LIMIT, description: Yup.string().max(
Language.descriptionMaxError, MAX_DESCRIPTION_CHAR_LIMIT,
), i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }),
default_ttl_ms: Yup.number() ),
.integer() default_ttl_ms: Yup.number()
.min(0) .integer()
.max(24 * MAX_TTL_DAYS /* 7 days in hours */, Language.ttlMaxError), .min(0, i18next.t("ttlMinError", { ns: "templateSettingsPage" }))
allow_user_cancel_workspace_jobs: Yup.boolean(), .max(
}) 24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("ttlMaxError", { ns: "templateSettingsPage" }),
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
})
export interface TemplateSettingsForm { export interface TemplateSettingsForm {
template: Template template: Template
@ -81,6 +82,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
initialTouched, initialTouched,
}) => { }) => {
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
const validationSchema = getValidationSchema()
const form: FormikContextType<UpdateTemplateMeta> = const form: FormikContextType<UpdateTemplateMeta> =
useFormik<UpdateTemplateMeta>({ useFormik<UpdateTemplateMeta>({
initialValues: { initialValues: {
@ -110,10 +112,10 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
const hasIcon = form.values.icon && form.values.icon !== "" const hasIcon = form.values.icon && form.values.icon !== ""
const emojiButtonRef = useRef<HTMLButtonElement>(null) const emojiButtonRef = useRef<HTMLButtonElement>(null)
const { t } = useTranslation("templatePage") const { t } = useTranslation("templateSettingsPage")
return ( return (
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}> <form onSubmit={form.handleSubmit} aria-label={t("formAriaLabel")}>
<Stack> <Stack>
<TextField <TextField
{...getFieldHelpers("name")} {...getFieldHelpers("name")}
@ -121,7 +123,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
autoFocus autoFocus
fullWidth fullWidth
label={Language.nameLabel} label={t("nameLabel")}
variant="outlined" variant="outlined"
/> />
@ -138,7 +140,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
multiline multiline
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth
label={Language.descriptionLabel} label={t("descriptionLabel")}
variant="outlined" variant="outlined"
rows={2} rows={2}
/> />
@ -148,7 +150,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
{...getFieldHelpers("icon")} {...getFieldHelpers("icon")}
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth
label={Language.iconLabel} label={t("iconLabel")}
variant="outlined" variant="outlined"
InputProps={{ InputProps={{
endAdornment: hasIcon ? ( endAdornment: hasIcon ? (
@ -177,7 +179,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
setIsEmojiPickerOpen((v) => !v) setIsEmojiPickerOpen((v) => !v)
}} }}
> >
{Language.selectEmoji} {t("selectEmoji")}
</Button> </Button>
<Popover <Popover
@ -204,20 +206,17 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
</div> </div>
<TextField <TextField
{...getFieldHelpers("default_ttl_ms")} {...getFieldHelpers(
"default_ttl_ms",
<TTLHelperText ttl={form.values.default_ttl_ms} />,
)}
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth
inputProps={{ min: 0, step: 1 }} inputProps={{ min: 0, step: 1 }}
label={Language.defaultTtlLabel} label={t("defaultTtlLabel")}
variant="outlined" variant="outlined"
type="number" type="number"
/> />
{/* If a value for default_ttl_ms has been entered and
there are no validation errors for that field, display helper text.
We do not use the MUI helper-text prop because it overrides the validation error */}
{form.values.default_ttl_ms && !form.errors.default_ttl_ms && (
<span>{Language.ttlHelperText(form.values.default_ttl_ms)}</span>
)}
<Box display="flex"> <Box display="flex">
<div> <div>

View File

@ -5,20 +5,20 @@ import { UpdateTemplateMeta } from "api/typesGenerated"
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
import { MockTemplate } from "../../testHelpers/entities" import { MockTemplate } from "../../testHelpers/entities"
import { renderWithAuth } from "../../testHelpers/renderHelpers" import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { import { getValidationSchema } from "./TemplateSettingsForm"
Language as FormLanguage,
validationSchema,
} from "./TemplateSettingsForm"
import { TemplateSettingsPage } from "./TemplateSettingsPage" import { TemplateSettingsPage } from "./TemplateSettingsPage"
import i18next from "i18next" import i18next from "i18next"
const { t } = i18next
const renderTemplateSettingsPage = async () => { const renderTemplateSettingsPage = async () => {
const renderResult = renderWithAuth(<TemplateSettingsPage />, { const renderResult = renderWithAuth(<TemplateSettingsPage />, {
route: `/templates/${MockTemplate.name}/settings`, route: `/templates/${MockTemplate.name}/settings`,
path: `/templates/:templateId/settings`, path: `/templates/:templateId/settings`,
}) })
// Wait the form to be rendered // Wait the form to be rendered
await screen.findAllByLabelText(FormLanguage.nameLabel) const label = t("nameLabel", { ns: "templateSettingsPage" })
await screen.findAllByLabelText(label)
return renderResult return renderResult
} }
@ -39,28 +39,29 @@ const fillAndSubmitForm = async ({
icon, icon,
allow_user_cancel_workspace_jobs, allow_user_cancel_workspace_jobs,
}: Required<UpdateTemplateMeta>) => { }: Required<UpdateTemplateMeta>) => {
const nameField = await screen.findByLabelText(FormLanguage.nameLabel) const label = t("nameLabel", { ns: "templateSettingsPage" })
const nameField = await screen.findByLabelText(label)
await userEvent.clear(nameField) await userEvent.clear(nameField)
await userEvent.type(nameField, name) await userEvent.type(nameField, name)
const { t } = i18next const displayNameLabel = t("displayNameLabel", { ns: "templateSettingsPage" })
const displayNameLabel = t("displayNameLabel", { ns: "templatePage" })
const displayNameField = await screen.findByLabelText(displayNameLabel) const displayNameField = await screen.findByLabelText(displayNameLabel)
await userEvent.clear(displayNameField) await userEvent.clear(displayNameField)
await userEvent.type(displayNameField, display_name) await userEvent.type(displayNameField, display_name)
const descriptionField = await screen.findByLabelText( const descriptionLabel = t("descriptionLabel", { ns: "templateSettingsPage" })
FormLanguage.descriptionLabel, const descriptionField = await screen.findByLabelText(descriptionLabel)
)
await userEvent.clear(descriptionField) await userEvent.clear(descriptionField)
await userEvent.type(descriptionField, description) await userEvent.type(descriptionField, description)
const iconField = await screen.findByLabelText(FormLanguage.iconLabel) const iconLabel = t("iconLabel", { ns: "templateSettingsPage" })
const iconField = await screen.findByLabelText(iconLabel)
await userEvent.clear(iconField) await userEvent.clear(iconField)
await userEvent.type(iconField, icon) await userEvent.type(iconField, icon)
const maxTtlField = await screen.findByLabelText(FormLanguage.defaultTtlLabel) const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
const maxTtlField = await screen.findByLabelText(defaultTtlLabel)
await userEvent.clear(maxTtlField) await userEvent.clear(maxTtlField)
await userEvent.type(maxTtlField, default_ttl_ms.toString()) await userEvent.type(maxTtlField, default_ttl_ms.toString())
@ -79,8 +80,8 @@ const fillAndSubmitForm = async ({
describe("TemplateSettingsPage", () => { describe("TemplateSettingsPage", () => {
it("renders", async () => { it("renders", async () => {
const { t } = i18next const { t } = i18next
const pageTitle = t("templateSettings.title", { const pageTitle = t("title", {
ns: "templatePage", ns: "templateSettingsPage",
}) })
await renderTemplateSettingsPage() await renderTemplateSettingsPage()
const element = await screen.findByText(pageTitle) const element = await screen.findByText(pageTitle)
@ -90,8 +91,8 @@ describe("TemplateSettingsPage", () => {
it("allows an admin to delete a template", async () => { it("allows an admin to delete a template", async () => {
const { t } = i18next const { t } = i18next
await renderTemplateSettingsPage() await renderTemplateSettingsPage()
const deleteCta = t("templateSettings.dangerZone.deleteCta", { const deleteCta = t("dangerZone.deleteCta", {
ns: "templatePage", ns: "templateSettingsPage",
}) })
const deleteButton = await screen.findByText(deleteCta) const deleteButton = await screen.findByText(deleteCta)
expect(deleteButton).toBeDefined() expect(deleteButton).toBeDefined()
@ -137,7 +138,7 @@ describe("TemplateSettingsPage", () => {
...validFormValues, ...validFormValues,
default_ttl_ms: 24 * 7, default_ttl_ms: 24 * 7,
} }
const validate = () => validationSchema.validateSync(values) const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError() expect(validate).not.toThrowError()
}) })
@ -146,7 +147,7 @@ describe("TemplateSettingsPage", () => {
...validFormValues, ...validFormValues,
default_ttl_ms: 0, default_ttl_ms: 0,
} }
const validate = () => validationSchema.validateSync(values) const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError() expect(validate).not.toThrowError()
}) })
@ -155,8 +156,10 @@ describe("TemplateSettingsPage", () => {
...validFormValues, ...validFormValues,
default_ttl_ms: 24 * 7 + 1, default_ttl_ms: 24 * 7 + 1,
} }
const validate = () => validationSchema.validateSync(values) const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(FormLanguage.ttlMaxError) expect(validate).toThrowError(
t("ttlMaxError", { ns: "templateSettingsPage" }),
)
}) })
it("allows a description of 128 chars", () => { it("allows a description of 128 chars", () => {
@ -165,7 +168,7 @@ describe("TemplateSettingsPage", () => {
description: description:
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port", "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port",
} }
const validate = () => validationSchema.validateSync(values) const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError() expect(validate).not.toThrowError()
}) })
@ -175,7 +178,9 @@ describe("TemplateSettingsPage", () => {
description: description:
"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 = () => validationSchema.validateSync(values) const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(FormLanguage.descriptionMaxError) expect(validate).toThrowError(
t("descriptionMaxError", { ns: "templateSettingsPage" }),
)
}) })
}) })

View File

@ -2,17 +2,15 @@ import { useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId" import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react" import { FC } from "react"
import { Helmet } from "react-helmet-async" import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page" import { pageTitle } from "util/page"
import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService" import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService"
import { TemplateSettingsPageView } from "./TemplateSettingsPageView" import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
const Language = {
title: "Template Settings",
}
export const TemplateSettingsPage: FC = () => { export const TemplateSettingsPage: FC = () => {
const { template: templateName } = useParams() as { template: string } const { template: templateName } = useParams() as { template: string }
const { t } = useTranslation("templateSettingsPage")
const navigate = useNavigate() const navigate = useNavigate()
const organizationId = useOrganizationId() const organizationId = useOrganizationId()
const [state, send] = useMachine(templateSettingsMachine, { const [state, send] = useMachine(templateSettingsMachine, {
@ -34,7 +32,7 @@ export const TemplateSettingsPage: FC = () => {
return ( return (
<> <>
<Helmet> <Helmet>
<title>{pageTitle(Language.title)}</title> <title>{pageTitle(t("title"))}</title>
</Helmet> </Helmet>
<TemplateSettingsPageView <TemplateSettingsPageView
isSubmitting={state.hasTag("submitting")} isSubmitting={state.hasTag("submitting")}

View File

@ -47,14 +47,14 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
}) => { }) => {
const classes = useStyles() const classes = useStyles()
const isLoading = !template && !errors.getTemplateError const isLoading = !template && !errors.getTemplateError
const { t } = useTranslation("templatePage") const { t } = useTranslation("templateSettingsPage")
if (isDeleted) { if (isDeleted) {
return <Navigate to="/templates" /> return <Navigate to="/templates" />
} }
return ( return (
<FullPageForm title={t("templateSettings.title")} onCancel={onCancel}> <FullPageForm title={t("title")} onCancel={onCancel}>
{Boolean(errors.getTemplateError) && ( {Boolean(errors.getTemplateError) && (
<Stack className={classes.errorContainer}> <Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateError} /> <AlertBanner severity="error" error={errors.getTemplateError} />
@ -78,24 +78,22 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
/> />
<Stack className={classes.dangerContainer}> <Stack className={classes.dangerContainer}>
<div className={classes.dangerHeader}> <div className={classes.dangerHeader}>
{t("templateSettings.dangerZone.dangerZoneHeader")} {t("dangerZone.dangerZoneHeader")}
</div> </div>
<Stack className={classes.dangerBorder}> <Stack className={classes.dangerBorder}>
<Stack spacing={0}> <Stack spacing={0}>
<p className={classes.deleteTemplateHeader}> <p className={classes.deleteTemplateHeader}>
{t("templateSettings.dangerZone.deleteTemplateHeader")} {t("dangerZone.deleteTemplateHeader")}
</p> </p>
<span> <span>{t("dangerZone.deleteTemplateCaption")}</span>
{t("templateSettings.dangerZone.deleteTemplateCaption")}
</span>
</Stack> </Stack>
<Button <Button
className={classes.deleteButton} className={classes.deleteButton}
onClick={onDelete} onClick={onDelete}
aria-label={t("templateSettings.dangerZone.deleteCta")} aria-label={t("dangerZone.deleteCta")}
> >
{t("templateSettings.dangerZone.deleteCta")} {t("dangerZone.deleteCta")}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>

View File

@ -14,7 +14,7 @@ import * as Yup from "yup"
export const Language = { export const Language = {
nameRequired: (name: string): string => { nameRequired: (name: string): string => {
return `Please enter a ${name.toLowerCase()}.` return name ? `Please enter a ${name.toLowerCase()}.` : "Required"
}, },
nameInvalidChars: (name: string): string => { nameInvalidChars: (name: string): string => {
return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -` return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -`
@ -37,7 +37,6 @@ interface FormHelpers {
helperText?: ReactNode helperText?: ReactNode
} }
// backendErrorName can be used if the backend names a field differently than the frontend does
export const getFormHelpers = export const getFormHelpers =
<T>(form: FormikContextType<T>, error?: Error | unknown) => <T>(form: FormikContextType<T>, error?: Error | unknown) =>
( (
@ -54,6 +53,7 @@ export const getFormHelpers =
`name must be type of string, instead received '${typeof name}'`, `name must be type of string, instead received '${typeof name}'`,
) )
} }
const apiErrorName = backendErrorName ?? name const apiErrorName = backendErrorName ?? name
// getIn is a util function from Formik that gets at any depth of nesting // getIn is a util function from Formik that gets at any depth of nesting