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

Skip to content

Commit 674647c

Browse files
committed
Add schedule
1 parent 13086b4 commit 674647c

File tree

7 files changed

+500
-1
lines changed

7 files changed

+500
-1
lines changed

site/src/AppRouter.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ const CreateTokenPage = lazy(
135135
const TemplateFilesPage = lazy(
136136
() => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"),
137137
)
138+
const TemplateSchedulePage = lazy(
139+
() =>
140+
import(
141+
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
142+
),
143+
)
138144

139145
export const AppRouter: FC = () => {
140146
return (
@@ -180,6 +186,7 @@ export const AppRouter: FC = () => {
180186
path="variables"
181187
element={<TemplateVariablesPage />}
182188
/>
189+
<Route path="schedule" element={<TemplateSchedulePage />} />
183190
</Route>
184191

185192
<Route path="versions">

site/src/pages/TemplateSettingsPage/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const Sidebar: React.FC<{ template: Template }> = ({ template }) => {
7777
Variables
7878
</SidebarNavItem>
7979
<SidebarNavItem
80-
href="tokens"
80+
href="schedule"
8181
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
8282
>
8383
Schedule
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import TextField from "@material-ui/core/TextField"
2+
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
3+
import { FormikContextType, FormikTouched, useFormik } from "formik"
4+
import { FC } from "react"
5+
import { getFormHelpers } from "util/formUtils"
6+
import * as Yup from "yup"
7+
import i18next from "i18next"
8+
import { useTranslation } from "react-i18next"
9+
import { Maybe } from "components/Conditionals/Maybe"
10+
import { FormSection, HorizontalForm, FormFooter } from "components/Form/Form"
11+
import { Stack } from "components/Stack/Stack"
12+
import { makeStyles } from "@material-ui/core/styles"
13+
import Link from "@material-ui/core/Link"
14+
15+
const TTLHelperText = ({
16+
ttl,
17+
translationName,
18+
}: {
19+
ttl?: number
20+
translationName: string
21+
}) => {
22+
const { t } = useTranslation("templateSettingsPage")
23+
const count = typeof ttl !== "number" ? 0 : ttl
24+
return (
25+
// no helper text if ttl is negative - error will show once field is considered touched
26+
<Maybe condition={count >= 0}>
27+
<span>{t(translationName, { count })}</span>
28+
</Maybe>
29+
)
30+
}
31+
32+
const MAX_TTL_DAYS = 7
33+
const MS_HOUR_CONVERSION = 3600000
34+
35+
export const getValidationSchema = (): Yup.AnyObjectSchema =>
36+
Yup.object({
37+
default_ttl_ms: Yup.number()
38+
.integer()
39+
.min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" }))
40+
.max(
41+
24 * MAX_TTL_DAYS /* 7 days in hours */,
42+
i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
43+
),
44+
max_ttl_ms: Yup.number()
45+
.integer()
46+
.min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }))
47+
.max(
48+
24 * MAX_TTL_DAYS /* 7 days in hours */,
49+
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }),
50+
),
51+
})
52+
53+
export interface TemplateScheduleForm {
54+
template: Template
55+
onSubmit: (data: UpdateTemplateMeta) => void
56+
onCancel: () => void
57+
isSubmitting: boolean
58+
error?: unknown
59+
canSetMaxTTL: boolean
60+
// Helpful to show field errors on Storybook
61+
initialTouched?: FormikTouched<UpdateTemplateMeta>
62+
}
63+
64+
export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
65+
template,
66+
onSubmit,
67+
onCancel,
68+
error,
69+
canSetMaxTTL,
70+
isSubmitting,
71+
initialTouched,
72+
}) => {
73+
const { t: commonT } = useTranslation("common")
74+
const validationSchema = getValidationSchema()
75+
const form: FormikContextType<UpdateTemplateMeta> =
76+
useFormik<UpdateTemplateMeta>({
77+
initialValues: {
78+
// on display, convert from ms => hours
79+
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
80+
// the API ignores this value, but to avoid tripping up validation set
81+
// it to zero if the user can't set the field.
82+
max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0,
83+
},
84+
validationSchema,
85+
onSubmit: (formData) => {
86+
// on submit, convert from hours => ms
87+
onSubmit({
88+
default_ttl_ms: formData.default_ttl_ms
89+
? formData.default_ttl_ms * MS_HOUR_CONVERSION
90+
: undefined,
91+
max_ttl_ms: formData.max_ttl_ms
92+
? formData.max_ttl_ms * MS_HOUR_CONVERSION
93+
: undefined,
94+
})
95+
},
96+
initialTouched,
97+
})
98+
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
99+
const { t } = useTranslation("templateSettingsPage")
100+
const styles = useStyles()
101+
102+
return (
103+
<HorizontalForm
104+
onSubmit={form.handleSubmit}
105+
aria-label={t("formAriaLabel")}
106+
>
107+
<FormSection
108+
title={t("schedule.title")}
109+
description={t("schedule.description")}
110+
>
111+
<Stack direction="row" className={styles.ttlFields}>
112+
<TextField
113+
{...getFieldHelpers(
114+
"default_ttl_ms",
115+
<TTLHelperText
116+
translationName="defaultTTLHelperText"
117+
ttl={form.values.default_ttl_ms}
118+
/>,
119+
)}
120+
disabled={isSubmitting}
121+
fullWidth
122+
inputProps={{ min: 0, step: 1 }}
123+
label={t("defaultTtlLabel")}
124+
variant="outlined"
125+
type="number"
126+
/>
127+
128+
<TextField
129+
{...getFieldHelpers(
130+
"max_ttl_ms",
131+
canSetMaxTTL ? (
132+
<TTLHelperText
133+
translationName="maxTTLHelperText"
134+
ttl={form.values.max_ttl_ms}
135+
/>
136+
) : (
137+
<>
138+
{commonT("licenseFieldTextHelper")}{" "}
139+
<Link href="https://coder.com/docs/v2/latest/enterprise">
140+
{commonT("learnMore")}
141+
</Link>
142+
.
143+
</>
144+
),
145+
)}
146+
disabled={isSubmitting || !canSetMaxTTL}
147+
fullWidth
148+
inputProps={{ min: 0, step: 1 }}
149+
label={t("maxTtlLabel")}
150+
variant="outlined"
151+
type="number"
152+
/>
153+
</Stack>
154+
</FormSection>
155+
156+
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
157+
</HorizontalForm>
158+
)
159+
}
160+
161+
const useStyles = makeStyles(() => ({
162+
ttlFields: {
163+
width: "100%",
164+
},
165+
}))
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import * as API from "api/api"
4+
import { UpdateTemplateMeta } from "api/typesGenerated"
5+
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
6+
import { MockTemplate } from "../../../testHelpers/entities"
7+
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
8+
import { getValidationSchema } from "./TemplateScheduleForm"
9+
import TemplateSchedulePage from "./TemplateSchedulePage"
10+
import i18next from "i18next"
11+
12+
const { t } = i18next
13+
14+
const validFormValues = {
15+
name: "Name",
16+
display_name: "A display name",
17+
description: "A description",
18+
icon: "vscode.png",
19+
// these are the form values which are actually hours
20+
default_ttl_ms: 1,
21+
max_ttl_ms: 2,
22+
allow_user_cancel_workspace_jobs: false,
23+
}
24+
25+
const renderTemplateSchedulePage = async () => {
26+
renderWithAuth(<TemplateSchedulePage />, {
27+
route: `/templates/${MockTemplate.name}/settings`,
28+
path: `/templates/:template/settings`,
29+
extraRoutes: [{ path: "templates/:template", element: <></> }],
30+
})
31+
// Wait the form to be rendered
32+
const label = t("nameLabel", { ns: "TemplateSchedulePage" })
33+
await screen.findAllByLabelText(label)
34+
}
35+
36+
const fillAndSubmitForm = async ({
37+
name,
38+
display_name,
39+
description,
40+
default_ttl_ms,
41+
max_ttl_ms,
42+
icon,
43+
allow_user_cancel_workspace_jobs,
44+
}: Required<UpdateTemplateMeta>) => {
45+
const label = t("nameLabel", { ns: "TemplateSchedulePage" })
46+
const nameField = await screen.findByLabelText(label)
47+
await userEvent.clear(nameField)
48+
await userEvent.type(nameField, name)
49+
50+
const displayNameLabel = t("displayNameLabel", { ns: "TemplateSchedulePage" })
51+
52+
const displayNameField = await screen.findByLabelText(displayNameLabel)
53+
await userEvent.clear(displayNameField)
54+
await userEvent.type(displayNameField, display_name)
55+
56+
const descriptionLabel = t("descriptionLabel", { ns: "TemplateSchedulePage" })
57+
const descriptionField = await screen.findByLabelText(descriptionLabel)
58+
await userEvent.clear(descriptionField)
59+
await userEvent.type(descriptionField, description)
60+
61+
const iconLabel = t("iconLabel", { ns: "TemplateSchedulePage" })
62+
const iconField = await screen.findByLabelText(iconLabel)
63+
await userEvent.clear(iconField)
64+
await userEvent.type(iconField, icon)
65+
66+
const defaultTtlLabel = t("defaultTtlLabel", { ns: "TemplateSchedulePage" })
67+
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
68+
await userEvent.clear(defaultTtlField)
69+
await userEvent.type(defaultTtlField, default_ttl_ms.toString())
70+
71+
const entitlements = await API.getEntitlements()
72+
if (entitlements.features["advanced_template_scheduling"].enabled) {
73+
const maxTtlLabel = t("maxTtlLabel", { ns: "TemplateSchedulePage" })
74+
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
75+
await userEvent.clear(maxTtlField)
76+
await userEvent.type(maxTtlField, max_ttl_ms.toString())
77+
}
78+
79+
const allowCancelJobsField = screen.getByRole("checkbox")
80+
// checkbox is checked by default, so it must be clicked to get unchecked
81+
if (!allow_user_cancel_workspace_jobs) {
82+
await userEvent.click(allowCancelJobsField)
83+
}
84+
85+
const submitButton = await screen.findByText(
86+
FooterFormLanguage.defaultSubmitLabel,
87+
)
88+
await userEvent.click(submitButton)
89+
}
90+
91+
describe("TemplateSchedulePage", () => {
92+
it("renders", async () => {
93+
const { t } = i18next
94+
const pageTitle = t("title", {
95+
ns: "TemplateSchedulePage",
96+
})
97+
await renderTemplateSchedulePage()
98+
const element = await screen.findByText(pageTitle)
99+
expect(element).toBeDefined()
100+
})
101+
102+
it("succeeds", async () => {
103+
await renderTemplateSchedulePage()
104+
105+
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
106+
...MockTemplate,
107+
...validFormValues,
108+
})
109+
await fillAndSubmitForm(validFormValues)
110+
111+
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
112+
})
113+
114+
test("ttl is converted to and from hours", async () => {
115+
await renderTemplateSchedulePage()
116+
117+
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
118+
...MockTemplate,
119+
...validFormValues,
120+
})
121+
122+
await fillAndSubmitForm(validFormValues)
123+
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
124+
await waitFor(() =>
125+
expect(API.updateTemplateMeta).toBeCalledWith(
126+
"test-template",
127+
expect.objectContaining({
128+
...validFormValues,
129+
// convert from the display value (hours) to ms
130+
default_ttl_ms: validFormValues.default_ttl_ms * 3600000,
131+
// this value is undefined if not entitled
132+
max_ttl_ms: undefined,
133+
}),
134+
),
135+
)
136+
})
137+
138+
it("allows a ttl of 7 days", () => {
139+
const values: UpdateTemplateMeta = {
140+
...validFormValues,
141+
default_ttl_ms: 24 * 7,
142+
}
143+
const validate = () => getValidationSchema().validateSync(values)
144+
expect(validate).not.toThrowError()
145+
})
146+
147+
it("allows ttl of 0", () => {
148+
const values: UpdateTemplateMeta = {
149+
...validFormValues,
150+
default_ttl_ms: 0,
151+
}
152+
const validate = () => getValidationSchema().validateSync(values)
153+
expect(validate).not.toThrowError()
154+
})
155+
156+
it("disallows a ttl of 7 days + 1 hour", () => {
157+
const values: UpdateTemplateMeta = {
158+
...validFormValues,
159+
default_ttl_ms: 24 * 7 + 1,
160+
}
161+
const validate = () => getValidationSchema().validateSync(values)
162+
expect(validate).toThrowError(
163+
t("defaultTTLMaxError", { ns: "TemplateSchedulePage" }),
164+
)
165+
})
166+
167+
it("allows a description of 128 chars", () => {
168+
const values: UpdateTemplateMeta = {
169+
...validFormValues,
170+
description:
171+
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port",
172+
}
173+
const validate = () => getValidationSchema().validateSync(values)
174+
expect(validate).not.toThrowError()
175+
})
176+
177+
it("disallows a description of 128 + 1 chars", () => {
178+
const values: UpdateTemplateMeta = {
179+
...validFormValues,
180+
description:
181+
"Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a",
182+
}
183+
const validate = () => getValidationSchema().validateSync(values)
184+
expect(validate).toThrowError(
185+
t("descriptionMaxError", { ns: "TemplateSchedulePage" }),
186+
)
187+
})
188+
})

0 commit comments

Comments
 (0)