From c6fac0105480443d2dcf0dfab5c5225a16f5f3dc Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 14 Feb 2026 14:16:42 +0000 Subject: [PATCH 1/7] fix: disable default ttl and explain why --- .../TemplateSchedulePage/TTLHelperText.tsx | 16 ++++- .../TemplateScheduleForm.tsx | 7 ++- .../TemplateSchedulePage.test.tsx | 60 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index ffeb4d78fb89b..d2c4aff778142 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -22,8 +22,20 @@ export const DefaultTTLHelperText = (props: { ttl?: number }) => { ); }; -export const ActivityBumpHelperText = (props: { bump?: number }) => { - const { bump = 0 } = props; +export const ActivityBumpHelperText = (props: { + bump?: number; + defaultTTL?: number; +}) => { + const { bump = 0, defaultTTL = 0 } = props; + + if (!defaultTTL) { + return ( + + Activity bump only applies when a default TTL is configured. Set a + default TTL above to enable activity bumping. + + ); + } // Error will show once field is considered touched if (bump < 0) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index f37ade6e31a75..236a7a5b8e870 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -334,10 +334,13 @@ export const TemplateScheduleForm: FC = ({ + ), })} - disabled={isSubmitting} + disabled={isSubmitting || !form.values.default_ttl_ms} fullWidth inputProps={{ min: 0, step: 1 }} label="Activity bump (hours)" diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 5ce54cc117884..71d2fb655da64 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -352,4 +352,64 @@ describe("TemplateSchedulePage", () => { const validate = () => getValidationSchema().validateSync(values); expect(validate).toThrowError(); }); + + it("disables activity bump field when default TTL is 0", async () => { + await renderTemplateSchedulePage(); + const user = userEvent.setup(); + + const defaultTtlField = await screen.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = screen.getByLabelText("Activity bump (hours)"); + + // Activity bump should be enabled when default TTL is non-zero. + expect(activityBumpField).not.toBeDisabled(); + + // Set default TTL to 0. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + + // Activity bump should now be disabled. + expect(activityBumpField).toBeDisabled(); + }); + + it("shows helper text on activity bump when default TTL is 0", async () => { + await renderTemplateSchedulePage(); + const user = userEvent.setup(); + + const defaultTtlField = await screen.findByLabelText( + "Default autostop (hours)", + ); + + // Set default TTL to 0. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + + // Should show the explanatory helper text. + expect( + screen.getByText( + /activity bump only applies when a default TTL is configured/i, + ), + ).toBeInTheDocument(); + }); + + it("re-enables activity bump field when default TTL is set back to non-zero", async () => { + await renderTemplateSchedulePage(); + const user = userEvent.setup(); + + const defaultTtlField = await screen.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = screen.getByLabelText("Activity bump (hours)"); + + // Set default TTL to 0. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + expect(activityBumpField).toBeDisabled(); + + // Set default TTL back to a non-zero value. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "8"); + expect(activityBumpField).not.toBeDisabled(); + }); }); From f020a2f2734d8e0962496a23a8180cd29d8ee9e7 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 14 Feb 2026 14:23:16 +0000 Subject: [PATCH 2/7] fix: remove mui --- .../TemplateScheduleForm.tsx | 110 ++++++------------ .../TemplateSchedulePage.test.tsx | 15 +-- 2 files changed, 40 insertions(+), 85 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 236a7a5b8e870..1fc86101d9401 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,10 +1,9 @@ -import Checkbox from "@mui/material/Checkbox"; import FormControlLabel from "@mui/material/FormControlLabel"; import MenuItem from "@mui/material/MenuItem"; -import Switch from "@mui/material/Switch"; import TextField from "@mui/material/TextField"; import type { Template, UpdateTemplateMeta } from "api/typesGenerated"; import { Button } from "components/Button/Button"; +import { Checkbox } from "components/Checkbox/Checkbox"; import { DurationField } from "components/DurationField/DurationField"; import { FormFields, @@ -18,8 +17,9 @@ import { StackLabel, StackLabelHelperText, } from "components/StackLabel/StackLabel"; +import { Switch } from "components/Switch/Switch"; import { type FormikTouched, useFormik } from "formik"; -import { type ChangeEvent, type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { getFormHelpers } from "utils/formUtils"; import { calculateAutostopRequirementDaysValue, @@ -251,61 +251,30 @@ export const TemplateScheduleForm: FC = ({ } }, [currentValues, setValues]); - const handleToggleFailureCleanup = async (e: ChangeEvent) => { - form.handleChange(e); - if (!form.values.failure_cleanup_enabled) { - // fill failure_ttl_ms with defaults - await form.setValues({ - ...form.values, - failure_cleanup_enabled: true, - failure_ttl_ms: FAILURE_CLEANUP_DEFAULT, - }); - } else { - // clear failure_ttl_ms - await form.setValues({ - ...form.values, - failure_cleanup_enabled: false, - failure_ttl_ms: 0, - }); - } + const handleToggleFailureCleanup = async (checked: boolean) => { + await form.setValues({ + ...form.values, + failure_cleanup_enabled: checked, + failure_ttl_ms: checked ? FAILURE_CLEANUP_DEFAULT : 0, + }); }; - const handleToggleInactivityCleanup = async (e: ChangeEvent) => { - form.handleChange(e); - if (!form.values.inactivity_cleanup_enabled) { - // fill time_til_dormant_ms with defaults - await form.setValues({ - ...form.values, - inactivity_cleanup_enabled: true, - time_til_dormant_ms: INACTIVITY_CLEANUP_DEFAULT, - }); - } else { - // clear time_til_dormant_ms - await form.setValues({ - ...form.values, - inactivity_cleanup_enabled: false, - time_til_dormant_ms: 0, - }); - } + const handleToggleInactivityCleanup = async (checked: boolean) => { + await form.setValues({ + ...form.values, + inactivity_cleanup_enabled: checked, + time_til_dormant_ms: checked ? INACTIVITY_CLEANUP_DEFAULT : 0, + }); }; - const handleToggleDormantAutoDeletion = async (e: ChangeEvent) => { - form.handleChange(e); - if (!form.values.dormant_autodeletion_cleanup_enabled) { - // fill failure_ttl_ms with defaults - await form.setValues({ - ...form.values, - dormant_autodeletion_cleanup_enabled: true, - time_til_dormant_autodelete_ms: DORMANT_AUTODELETION_DEFAULT, - }); - } else { - // clear failure_ttl_ms - await form.setValues({ - ...form.values, - dormant_autodeletion_cleanup_enabled: false, - time_til_dormant_autodelete_ms: 0, - }); - } + const handleToggleDormantAutoDeletion = async (checked: boolean) => { + await form.setValues({ + ...form.values, + dormant_autodeletion_cleanup_enabled: checked, + time_til_dormant_autodelete_ms: checked + ? DORMANT_AUTODELETION_DEFAULT + : 0, + }); }; return ( @@ -347,7 +316,7 @@ export const TemplateScheduleForm: FC = ({ type="number" /> - + = ({ control={ { - await form.setFieldValue("allow_user_autostop", checked); + onCheckedChange={async (checked) => { + await form.setFieldValue( + "allow_user_autostop", + checked === true, + ); }} name="allow_user_autostop" checked={form.values.allow_user_autostop} @@ -434,12 +405,11 @@ export const TemplateScheduleForm: FC = ({ control={ { + onCheckedChange={async (checked) => { await form.setFieldValue( "allow_user_autostart", - !form.values.allow_user_autostart, + checked === true, ); }} name="allow_user_autostart" @@ -481,10 +451,9 @@ export const TemplateScheduleForm: FC = ({ } label={Enable Dormancy Threshold} @@ -511,10 +480,9 @@ export const TemplateScheduleForm: FC = ({ } label={ @@ -554,10 +522,9 @@ export const TemplateScheduleForm: FC = ({ } label={ @@ -649,12 +616,3 @@ export const TemplateScheduleForm: FC = ({ ); }; - -const styles = { - ttlFields: { - width: "100%", - }, - dayButtons: { - borderRadius: "0px", - }, -}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 71d2fb655da64..c270ddb10bc5f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -75,27 +75,24 @@ const fillAndSubmitForm = async ({ } if (failure_ttl_ms) { - const failureTtlField = screen.getByRole("checkbox", { + const failureTtlField = screen.getByRole("switch", { name: /Failure Cleanup/i, }); - await user.type(failureTtlField, failure_ttl_ms.toString()); + await user.click(failureTtlField); } if (time_til_dormant_ms) { - const inactivityTtlField = screen.getByRole("checkbox", { + const inactivityTtlField = screen.getByRole("switch", { name: /Dormancy Threshold/i, }); - await user.type(inactivityTtlField, time_til_dormant_ms.toString()); + await user.click(inactivityTtlField); } if (time_til_dormant_autodelete_ms) { - const dormancyAutoDeletionField = screen.getByRole("checkbox", { + const dormancyAutoDeletionField = screen.getByRole("switch", { name: /Dormancy Auto-Deletion/i, }); - await user.type( - dormancyAutoDeletionField, - time_til_dormant_autodelete_ms.toString(), - ); + await user.click(dormancyAutoDeletionField); } const submitButton = screen.getByRole("button", { From f3fab6d0b02db104e86d1b7e5de220235269a0d5 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 14 Feb 2026 14:25:43 +0000 Subject: [PATCH 3/7] fix: remove old `` --- .../TemplateScheduleForm.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 1fc86101d9401..3010b61fee02a 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -12,7 +12,6 @@ import { HorizontalForm, } from "components/Form/Form"; import { Spinner } from "components/Spinner/Spinner"; -import { Stack } from "components/Stack/Stack"; import { StackLabel, StackLabelHelperText, @@ -58,7 +57,6 @@ const DORMANT_AUTODELETION_DEFAULT = 30 * MS_DAY_CONVERSION; * increase the space can make it feels lighter. */ const FORM_FIELDS_SPACING = 8; -const DORMANT_FIELDSET_SPACING = 4; export interface TemplateScheduleForm { template: Template; @@ -316,7 +314,7 @@ export const TemplateScheduleForm: FC = ({ type="number" /> - +
= ({ label="Weeks between required stops" type="number" /> - +
= ({ title="Autostart" description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template." > - +
= ({ }} /> )} - +
{allowAdvancedScheduling && ( @@ -447,7 +445,7 @@ export const TemplateScheduleForm: FC = ({ description="When enabled, Coder will mark workspaces as dormant after a period of time with no connections. Dormant workspaces can be auto-deleted (see below) or manually reviewed by the workspace owner or admins." > - +
= ({ isSubmitting || !form.values.inactivity_cleanup_enabled } /> - +
- +
= ({ !form.values.dormant_autodeletion_cleanup_enabled } /> - +
- +
= ({ onChange={(v) => form.setFieldValue("failure_ttl_ms", v)} disabled={isSubmitting || !form.values.failure_cleanup_enabled} /> - +
)} From f5fc795f4b853edd7736bd49be05ed1fb2f41be5 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 14 Feb 2026 14:31:56 +0000 Subject: [PATCH 4/7] feat: migrate out `` --- .../TemplateScheduleForm.tsx | 133 +++++++++--------- 1 file changed, 64 insertions(+), 69 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 3010b61fee02a..9d607a47ee016 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,4 +1,3 @@ -import FormControlLabel from "@mui/material/FormControlLabel"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import type { Template, UpdateTemplateMeta } from "api/typesGenerated"; @@ -11,6 +10,7 @@ import { FormSection, HorizontalForm, } from "components/Form/Form"; +import { Label } from "components/Label/Label"; import { Spinner } from "components/Spinner/Spinner"; import { StackLabel, @@ -365,22 +365,20 @@ export const TemplateScheduleForm: FC = ({ /> - { - await form.setFieldValue( - "allow_user_autostop", - checked === true, - ); - }} - name="allow_user_autostop" - checked={form.values.allow_user_autostop} - /> - } - label={ +
+ { + await form.setFieldValue( + "allow_user_autostop", + checked === true, + ); + }} + name="allow_user_autostop" + checked={form.values.allow_user_autostop} + /> + +
@@ -399,27 +397,25 @@ export const TemplateScheduleForm: FC = ({ description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template." >
- { - await form.setFieldValue( - "allow_user_autostart", - checked === true, - ); - }} - name="allow_user_autostart" - checked={form.values.allow_user_autostart} - /> - } - label={ +
+ { + await form.setFieldValue( + "allow_user_autostart", + checked === true, + ); + }} + name="allow_user_autostart" + checked={form.values.allow_user_autostart} + /> + +
{allowAdvancedScheduling && ( = ({ >
- - } - label={Enable Dormancy Threshold} - /> +
+ + +
= ({
- - } - label={ +
+ + +
= ({
- - } - label={ +
+ + +
Date: Sat, 14 Feb 2026 14:33:55 +0000 Subject: [PATCH 5/7] fix: top-align checkboxes and switches --- .../TemplateSchedulePage/TemplateScheduleForm.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 9d607a47ee016..3fe862d6a3a09 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -365,7 +365,7 @@ export const TemplateScheduleForm: FC = ({ />
-
+
= ({ description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template." >
-
+
= ({ >
-
+
= ({
-
+
= ({
-
+
Date: Sun, 15 Feb 2026 04:11:28 +0000 Subject: [PATCH 6/7] fix: `undefined` `activity_bump_ms` --- .../TemplateSchedulePage/TemplateScheduleForm.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 3fe862d6a3a09..6456e7369bf94 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -201,9 +201,12 @@ export const TemplateScheduleForm: FC = ({ default_ttl_ms: form.values.default_ttl_ms ? form.values.default_ttl_ms * MS_HOUR_CONVERSION : undefined, - activity_bump_ms: form.values.activity_bump_ms - ? form.values.activity_bump_ms * MS_HOUR_CONVERSION - : undefined, + // Activity bump has no effect without a default TTL, so + // discard any stale value when default autostop is off. + activity_bump_ms: + form.values.default_ttl_ms && form.values.activity_bump_ms + ? form.values.activity_bump_ms * MS_HOUR_CONVERSION + : undefined, failure_ttl_ms: form.values.failure_ttl_ms, time_til_dormant_ms: form.values.time_til_dormant_ms, time_til_dormant_autodelete_ms: From 9053a9090fd40105e85b81e35a95a6a6c5e17242 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sun, 15 Feb 2026 04:15:58 +0000 Subject: [PATCH 7/7] feat: tests for `SubmitClearsActivityBumpWhenDefaultTTLIsZero` --- .../TemplateSchedulePageView.stories.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx index 364fadbd7ee31..44f3f0e248a62 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx @@ -2,6 +2,7 @@ import { MockTemplate } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "react-query"; import { action } from "storybook/actions"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { TemplateSchedulePageView } from "./TemplateSchedulePageView"; const queryClient = new QueryClient({ @@ -43,3 +44,41 @@ export const Example: Story = { export const CantSetMaxTTL: Story = { args: { ...defaultArgs, allowAdvancedScheduling: false }, }; + +export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + // Start with a non-zero activity bump so we can verify + // it gets discarded when default TTL is set to 0. + activity_bump_ms: 3 * 60 * 60 * 1000, + }, + onSubmit: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + + await expect(activityBumpField).toBeDisabled(); + + const submitButton = canvas.getByRole("button", { name: /save/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + activity_bump_ms: undefined, + }), + ); + }); + }, +};