diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index ffeb4d78fb8..d2c4aff7781 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 f37ade6e31a..6456e7369bf 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,10 +1,8 @@ -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, @@ -12,14 +10,15 @@ import { FormSection, HorizontalForm, } from "components/Form/Form"; +import { Label } from "components/Label/Label"; import { Spinner } from "components/Spinner/Spinner"; -import { Stack } from "components/Stack/Stack"; 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, @@ -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; @@ -203,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: @@ -251,61 +252,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 ( @@ -334,17 +304,20 @@ export const TemplateScheduleForm: FC = ({ + ), })} - disabled={isSubmitting} + disabled={isSubmitting || !form.values.default_ttl_ms} fullWidth inputProps={{ min: 0, step: 1 }} label="Activity bump (hours)" type="number" /> - +
= ({ label="Weeks between required stops" type="number" /> - - - { - await form.setFieldValue("allow_user_autostop", checked); - }} - 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} + /> + +
@@ -426,29 +399,26 @@ export const TemplateScheduleForm: FC = ({ title="Autostart" description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template." > - - { - await form.setFieldValue( - "allow_user_autostart", - !form.values.allow_user_autostart, - ); - }} - 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 && ( = ({ }} /> )} - +
{allowAdvancedScheduling && ( @@ -474,18 +444,18 @@ 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." > - - - } - label={Enable Dormancy Threshold} - /> +
+
+ + +
= ({ isSubmitting || !form.values.inactivity_cleanup_enabled } /> - - - - - } - label={ +
+ +
+
+ + +
= ({ !form.values.dormant_autodeletion_cleanup_enabled } /> - - - - - } - label={ +
+ +
+
+ + +
= ({ onChange={(v) => form.setFieldValue("failure_ttl_ms", v)} disabled={isSubmitting || !form.values.failure_cleanup_enabled} /> - +
)} @@ -646,12 +612,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 5ce54cc1178..c270ddb10bc 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", { @@ -352,4 +349,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(); + }); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx index 364fadbd7ee..44f3f0e248a 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, + }), + ); + }); + }, +};