Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8ea0923

Browse files
authored
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
1 parent ee605b3 commit 8ea0923

8 files changed

+111
-97
lines changed

site/src/i18n/en/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import agent from "./agent.json"
88
import buildPage from "./buildPage.json"
99
import workspacesPage from "./workspacesPage.json"
1010
import usersPage from "./usersPage.json"
11+
import templateSettingsPage from "./templateSettingsPage.json"
1112
import templateVersionPage from "./templateVersionPage.json"
1213
import loginPage from "./loginPage.json"
1314
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
@@ -24,6 +25,7 @@ export const en = {
2425
buildPage,
2526
workspacesPage,
2627
usersPage,
28+
templateSettingsPage,
2729
templateVersionPage,
2830
loginPage,
2931
workspaceChangeVersionPage,

site/src/i18n/en/templatePage.json

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,4 @@
11
{
22
"deleteSuccess": "Template successfully deleted.",
3-
"createdVersion": "created the version",
4-
"templateSettings": {
5-
"title": "Template settings",
6-
"dangerZone": {
7-
"dangerZoneHeader": "Danger Zone",
8-
"deleteTemplateHeader": "Delete this template",
9-
"deleteTemplateCaption": "Do you want to permanently delete this template?",
10-
"deleteCta": "Delete Template"
11-
}
12-
},
13-
"displayNameLabel": "Display name",
14-
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
15-
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
3+
"createdVersion": "created the version"
164
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Template settings",
3+
"nameLabel": "Name",
4+
"displayNameLabel": "Display name",
5+
"descriptionLabel": "Description",
6+
"descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.",
7+
"defaultTtlLabel": "Auto-stop default",
8+
"iconLabel": "Icon",
9+
"formAriaLabel": "Template settings form",
10+
"selectEmoji": "Select emoji",
11+
"ttlMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
12+
"ttlMinError": "Default time until auto-stop must not be less than 0.",
13+
"ttlHelperText_zero": "Workspaces created from this template will run until stopped manually.",
14+
"ttlHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.",
15+
"ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.",
16+
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
17+
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
18+
"dangerZone": {
19+
"dangerZoneHeader": "Danger Zone",
20+
"deleteTemplateHeader": "Delete this template",
21+
"deleteTemplateCaption": "Do you want to permanently delete this template?",
22+
"deleteCta": "Delete Template"
23+
}
24+
}

site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -24,43 +24,44 @@ import {
2424
import * as Yup from "yup"
2525
import i18next from "i18next"
2626
import { useTranslation } from "react-i18next"
27+
import { Maybe } from "components/Conditionals/Maybe"
2728

28-
export const Language = {
29-
nameLabel: "Name",
30-
descriptionLabel: "Description",
31-
defaultTtlLabel: "Auto-stop default",
32-
iconLabel: "Icon",
33-
formAriaLabel: "Template settings form",
34-
selectEmoji: "Select emoji",
35-
ttlMaxError:
36-
"Please enter a limit that is less than or equal to 168 hours (7 days).",
37-
descriptionMaxError:
38-
"Please enter a description that is less than or equal to 128 characters.",
39-
ttlHelperText: (ttl: number): string =>
40-
`Workspaces created from this template will default to stopping after ${ttl} hours.`,
29+
const TTLHelperText = ({ ttl }: { ttl?: number }) => {
30+
const { t } = useTranslation("templateSettingsPage")
31+
const count = typeof ttl !== "number" ? 0 : ttl
32+
return (
33+
// no helper text if ttl is negative - error will show once field is considered touched
34+
<Maybe condition={count >= 0}>
35+
<span>{t("ttlHelperText", { count })}</span>
36+
</Maybe>
37+
)
4138
}
4239

4340
const MAX_DESCRIPTION_CHAR_LIMIT = 128
4441
const MAX_TTL_DAYS = 7
4542
const MS_HOUR_CONVERSION = 3600000
4643

47-
export const validationSchema = Yup.object({
48-
name: nameValidator(Language.nameLabel),
49-
display_name: templateDisplayNameValidator(
50-
i18next.t("displayNameLabel", {
51-
ns: "templatePage",
52-
}),
53-
),
54-
description: Yup.string().max(
55-
MAX_DESCRIPTION_CHAR_LIMIT,
56-
Language.descriptionMaxError,
57-
),
58-
default_ttl_ms: Yup.number()
59-
.integer()
60-
.min(0)
61-
.max(24 * MAX_TTL_DAYS /* 7 days in hours */, Language.ttlMaxError),
62-
allow_user_cancel_workspace_jobs: Yup.boolean(),
63-
})
44+
export const getValidationSchema = (): Yup.AnyObjectSchema =>
45+
Yup.object({
46+
name: nameValidator(i18next.t("nameLabel", { ns: "templateSettingsPage" })),
47+
display_name: templateDisplayNameValidator(
48+
i18next.t("displayNameLabel", {
49+
ns: "templateSettingsPage",
50+
}),
51+
),
52+
description: Yup.string().max(
53+
MAX_DESCRIPTION_CHAR_LIMIT,
54+
i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }),
55+
),
56+
default_ttl_ms: Yup.number()
57+
.integer()
58+
.min(0, i18next.t("ttlMinError", { ns: "templateSettingsPage" }))
59+
.max(
60+
24 * MAX_TTL_DAYS /* 7 days in hours */,
61+
i18next.t("ttlMaxError", { ns: "templateSettingsPage" }),
62+
),
63+
allow_user_cancel_workspace_jobs: Yup.boolean(),
64+
})
6465

6566
export interface TemplateSettingsForm {
6667
template: Template
@@ -81,6 +82,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
8182
initialTouched,
8283
}) => {
8384
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
85+
const validationSchema = getValidationSchema()
8486
const form: FormikContextType<UpdateTemplateMeta> =
8587
useFormik<UpdateTemplateMeta>({
8688
initialValues: {
@@ -110,18 +112,18 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
110112
const hasIcon = form.values.icon && form.values.icon !== ""
111113
const emojiButtonRef = useRef<HTMLButtonElement>(null)
112114

113-
const { t } = useTranslation("templatePage")
115+
const { t } = useTranslation("templateSettingsPage")
114116

115117
return (
116-
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}>
118+
<form onSubmit={form.handleSubmit} aria-label={t("formAriaLabel")}>
117119
<Stack>
118120
<TextField
119121
{...getFieldHelpers("name")}
120122
disabled={isSubmitting}
121123
onChange={onChangeTrimmed(form)}
122124
autoFocus
123125
fullWidth
124-
label={Language.nameLabel}
126+
label={t("nameLabel")}
125127
variant="outlined"
126128
/>
127129

@@ -138,7 +140,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
138140
multiline
139141
disabled={isSubmitting}
140142
fullWidth
141-
label={Language.descriptionLabel}
143+
label={t("descriptionLabel")}
142144
variant="outlined"
143145
rows={2}
144146
/>
@@ -148,7 +150,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
148150
{...getFieldHelpers("icon")}
149151
disabled={isSubmitting}
150152
fullWidth
151-
label={Language.iconLabel}
153+
label={t("iconLabel")}
152154
variant="outlined"
153155
InputProps={{
154156
endAdornment: hasIcon ? (
@@ -177,7 +179,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
177179
setIsEmojiPickerOpen((v) => !v)
178180
}}
179181
>
180-
{Language.selectEmoji}
182+
{t("selectEmoji")}
181183
</Button>
182184

183185
<Popover
@@ -204,20 +206,17 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
204206
</div>
205207

206208
<TextField
207-
{...getFieldHelpers("default_ttl_ms")}
209+
{...getFieldHelpers(
210+
"default_ttl_ms",
211+
<TTLHelperText ttl={form.values.default_ttl_ms} />,
212+
)}
208213
disabled={isSubmitting}
209214
fullWidth
210215
inputProps={{ min: 0, step: 1 }}
211-
label={Language.defaultTtlLabel}
216+
label={t("defaultTtlLabel")}
212217
variant="outlined"
213218
type="number"
214219
/>
215-
{/* If a value for default_ttl_ms has been entered and
216-
there are no validation errors for that field, display helper text.
217-
We do not use the MUI helper-text prop because it overrides the validation error */}
218-
{form.values.default_ttl_ms && !form.errors.default_ttl_ms && (
219-
<span>{Language.ttlHelperText(form.values.default_ttl_ms)}</span>
220-
)}
221220

222221
<Box display="flex">
223222
<div>

site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,20 @@ import { UpdateTemplateMeta } from "api/typesGenerated"
55
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
66
import { MockTemplate } from "../../testHelpers/entities"
77
import { renderWithAuth } from "../../testHelpers/renderHelpers"
8-
import {
9-
Language as FormLanguage,
10-
validationSchema,
11-
} from "./TemplateSettingsForm"
8+
import { getValidationSchema } from "./TemplateSettingsForm"
129
import { TemplateSettingsPage } from "./TemplateSettingsPage"
1310
import i18next from "i18next"
1411

12+
const { t } = i18next
13+
1514
const renderTemplateSettingsPage = async () => {
1615
const renderResult = renderWithAuth(<TemplateSettingsPage />, {
1716
route: `/templates/${MockTemplate.name}/settings`,
1817
path: `/templates/:templateId/settings`,
1918
})
2019
// Wait the form to be rendered
21-
await screen.findAllByLabelText(FormLanguage.nameLabel)
20+
const label = t("nameLabel", { ns: "templateSettingsPage" })
21+
await screen.findAllByLabelText(label)
2222
return renderResult
2323
}
2424

@@ -39,28 +39,29 @@ const fillAndSubmitForm = async ({
3939
icon,
4040
allow_user_cancel_workspace_jobs,
4141
}: Required<UpdateTemplateMeta>) => {
42-
const nameField = await screen.findByLabelText(FormLanguage.nameLabel)
42+
const label = t("nameLabel", { ns: "templateSettingsPage" })
43+
const nameField = await screen.findByLabelText(label)
4344
await userEvent.clear(nameField)
4445
await userEvent.type(nameField, name)
4546

46-
const { t } = i18next
47-
const displayNameLabel = t("displayNameLabel", { ns: "templatePage" })
47+
const displayNameLabel = t("displayNameLabel", { ns: "templateSettingsPage" })
4848

4949
const displayNameField = await screen.findByLabelText(displayNameLabel)
5050
await userEvent.clear(displayNameField)
5151
await userEvent.type(displayNameField, display_name)
5252

53-
const descriptionField = await screen.findByLabelText(
54-
FormLanguage.descriptionLabel,
55-
)
53+
const descriptionLabel = t("descriptionLabel", { ns: "templateSettingsPage" })
54+
const descriptionField = await screen.findByLabelText(descriptionLabel)
5655
await userEvent.clear(descriptionField)
5756
await userEvent.type(descriptionField, description)
5857

59-
const iconField = await screen.findByLabelText(FormLanguage.iconLabel)
58+
const iconLabel = t("iconLabel", { ns: "templateSettingsPage" })
59+
const iconField = await screen.findByLabelText(iconLabel)
6060
await userEvent.clear(iconField)
6161
await userEvent.type(iconField, icon)
6262

63-
const maxTtlField = await screen.findByLabelText(FormLanguage.defaultTtlLabel)
63+
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
64+
const maxTtlField = await screen.findByLabelText(defaultTtlLabel)
6465
await userEvent.clear(maxTtlField)
6566
await userEvent.type(maxTtlField, default_ttl_ms.toString())
6667

@@ -79,8 +80,8 @@ const fillAndSubmitForm = async ({
7980
describe("TemplateSettingsPage", () => {
8081
it("renders", async () => {
8182
const { t } = i18next
82-
const pageTitle = t("templateSettings.title", {
83-
ns: "templatePage",
83+
const pageTitle = t("title", {
84+
ns: "templateSettingsPage",
8485
})
8586
await renderTemplateSettingsPage()
8687
const element = await screen.findByText(pageTitle)
@@ -90,8 +91,8 @@ describe("TemplateSettingsPage", () => {
9091
it("allows an admin to delete a template", async () => {
9192
const { t } = i18next
9293
await renderTemplateSettingsPage()
93-
const deleteCta = t("templateSettings.dangerZone.deleteCta", {
94-
ns: "templatePage",
94+
const deleteCta = t("dangerZone.deleteCta", {
95+
ns: "templateSettingsPage",
9596
})
9697
const deleteButton = await screen.findByText(deleteCta)
9798
expect(deleteButton).toBeDefined()
@@ -137,7 +138,7 @@ describe("TemplateSettingsPage", () => {
137138
...validFormValues,
138139
default_ttl_ms: 24 * 7,
139140
}
140-
const validate = () => validationSchema.validateSync(values)
141+
const validate = () => getValidationSchema().validateSync(values)
141142
expect(validate).not.toThrowError()
142143
})
143144

@@ -146,7 +147,7 @@ describe("TemplateSettingsPage", () => {
146147
...validFormValues,
147148
default_ttl_ms: 0,
148149
}
149-
const validate = () => validationSchema.validateSync(values)
150+
const validate = () => getValidationSchema().validateSync(values)
150151
expect(validate).not.toThrowError()
151152
})
152153

@@ -155,8 +156,10 @@ describe("TemplateSettingsPage", () => {
155156
...validFormValues,
156157
default_ttl_ms: 24 * 7 + 1,
157158
}
158-
const validate = () => validationSchema.validateSync(values)
159-
expect(validate).toThrowError(FormLanguage.ttlMaxError)
159+
const validate = () => getValidationSchema().validateSync(values)
160+
expect(validate).toThrowError(
161+
t("ttlMaxError", { ns: "templateSettingsPage" }),
162+
)
160163
})
161164

162165
it("allows a description of 128 chars", () => {
@@ -165,7 +168,7 @@ describe("TemplateSettingsPage", () => {
165168
description:
166169
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port",
167170
}
168-
const validate = () => validationSchema.validateSync(values)
171+
const validate = () => getValidationSchema().validateSync(values)
169172
expect(validate).not.toThrowError()
170173
})
171174

@@ -175,7 +178,9 @@ describe("TemplateSettingsPage", () => {
175178
description:
176179
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a",
177180
}
178-
const validate = () => validationSchema.validateSync(values)
179-
expect(validate).toThrowError(FormLanguage.descriptionMaxError)
181+
const validate = () => getValidationSchema().validateSync(values)
182+
expect(validate).toThrowError(
183+
t("descriptionMaxError", { ns: "templateSettingsPage" }),
184+
)
180185
})
181186
})

site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@ import { useMachine } from "@xstate/react"
22
import { useOrganizationId } from "hooks/useOrganizationId"
33
import { FC } from "react"
44
import { Helmet } from "react-helmet-async"
5+
import { useTranslation } from "react-i18next"
56
import { useNavigate, useParams } from "react-router-dom"
67
import { pageTitle } from "util/page"
78
import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService"
89
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
910

10-
const Language = {
11-
title: "Template Settings",
12-
}
13-
1411
export const TemplateSettingsPage: FC = () => {
1512
const { template: templateName } = useParams() as { template: string }
13+
const { t } = useTranslation("templateSettingsPage")
1614
const navigate = useNavigate()
1715
const organizationId = useOrganizationId()
1816
const [state, send] = useMachine(templateSettingsMachine, {
@@ -34,7 +32,7 @@ export const TemplateSettingsPage: FC = () => {
3432
return (
3533
<>
3634
<Helmet>
37-
<title>{pageTitle(Language.title)}</title>
35+
<title>{pageTitle(t("title"))}</title>
3836
</Helmet>
3937
<TemplateSettingsPageView
4038
isSubmitting={state.hasTag("submitting")}

0 commit comments

Comments
 (0)