From f4da76cb3f5b9ea957ffa2b79054bb2f50d1dc45 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 10 Jul 2026 00:38:10 +1000 Subject: [PATCH 1/2] fix(site): keep activity bump editable when allow_user_autostop is on (#27083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. The UI guard added in #22112 disabled the `Activity bump` field and cleared its saved value whenever the template's `Default autostop` was 0. It did not check the "Allow users to customize autostop duration for workspaces" (`allow_user_autostop`) setting, so templates that relied on user-defined autostop timers had their `activity_bump_ms` silently cleared when saving in the Coder UI. Enable the field, preserve the value on submit, and update the helper text when either `default_ttl_ms > 0` or `allow_user_autostop` is true. Closes [DEVEX-438](https://linear.app/codercom/issue/DEVEX-438/allow-user-autostop-default-autostop-disabled-causes-activity-bump-to). > **Note:** This needs to be backported to 2.34 (ESR).
Implementation notes [#22112](https://github.com/coder/coder/pull/22112) introduced a UI guard that: 1. Disables the `Activity bump (hours)` field when `default_ttl_ms === 0`. 2. Sends `activity_bump_ms: undefined` on submit under the same condition, which the backend treats as "do not update", but combined with the disabled state users cannot re-enter a value once cleared and the previously stored value effectively becomes orphaned. The guard ignored `allow_user_autostop`. When that setting is enabled, workspaces still have a scheduled stop (whatever the user configures on their workspace), so `activity_bump_ms` is still meaningful. Broaden the guard to consider both signals. The field is only disabled and the value only discarded when **both** `default_ttl_ms === 0` **and** `allow_user_autostop === false`. Changes: - `TemplateScheduleForm.tsx` - `disabled` prop now checks `!default_ttl_ms && !allow_user_autostop`. - Submit path preserves `activity_bump_ms` when either signal is truthy. - Passes `allowUserAutostop` through to the helper text. - `TTLHelperText.tsx` - `ActivityBumpHelperText` accepts `allowUserAutostop` and only shows the "no scheduled stop" hint when neither signal is set. Updated copy mentions both signals. - Tests and stories - Existing tests explicitly uncheck `allow_user_autostop` before asserting the guard fires (since `MockTemplate.allow_user_autostop` defaults to `true`). - Added coverage: guard stays off when only `allow_user_autostop` is enabled; toggling `allow_user_autostop` re-enables the field without touching `default_ttl_ms`. - Added a story that verifies `activity_bump_ms` is preserved on submit when `allow_user_autostop` is enabled and `default_ttl_ms` is 0.
(cherry picked from commit 1eea4a7e5b9d096597d45c2a4a8fd14629ff1258) --- .../TemplateSchedulePage/TTLHelperText.tsx | 14 ++- .../TemplateScheduleForm.tsx | 14 ++- .../TemplateSchedulePage.test.tsx | 60 ---------- .../TemplateSchedulePageView.stories.tsx | 113 ++++++++++++++++++ 4 files changed, 133 insertions(+), 68 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index 90a1606d35b9e..f58e56a7408fb 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -25,14 +25,20 @@ export const DefaultTTLHelperText = (props: { ttl?: number }) => { export const ActivityBumpHelperText = (props: { bump?: number; defaultTTL?: number; + allowUserAutostop?: boolean; }) => { - const { bump = 0, defaultTTL = 0 } = props; + const { bump = 0, defaultTTL = 0, allowUserAutostop = false } = props; - if (!defaultTTL) { + // Activity bump extends a workspace's scheduled stop time. If there is no + // default TTL AND users cannot set their own autostop, there is no stop + // time to bump, so the field has no effect. + if (!defaultTTL && !allowUserAutostop) { return ( - Activity bump only applies when a default TTL is configured. Set a - default TTL above to enable activity bumping. + Activity bump only applies when "Default autostop" is configured or + users are allowed to customize autostop. Set "Default autostop" above or + check "Allow users to customize autostop duration for workspaces" below + to enable activity bumping. ); } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 2db8dafc2f9e3..d4eda81d32d92 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -201,10 +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 has no effect without a default TTL, so - // discard any stale value when default autostop is off. + // Activity bump has no effect without a scheduled stop time, so + // discard any stale value when there is no default TTL AND users + // cannot customize autostop on their workspaces. activity_bump_ms: - form.values.default_ttl_ms && form.values.activity_bump_ms + (form.values.default_ttl_ms || form.values.allow_user_autostop) && + form.values.activity_bump_ms ? form.values.activity_bump_ms * MS_HOUR_CONVERSION : undefined, failure_ttl_ms: form.values.failure_ttl_ms, @@ -307,10 +309,14 @@ export const TemplateScheduleForm: FC = ({ ), })} - disabled={isSubmitting || !form.values.default_ttl_ms} + disabled={ + isSubmitting || + (!form.values.default_ttl_ms && !form.values.allow_user_autostop) + } 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 21837080e625c..d2edb82598f6c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -349,64 +349,4 @@ 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 5ba34480bfbbb..d3b4686ee5e87 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx @@ -53,6 +53,10 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { // 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, + // Disable user autostop so the guard applies (activity bump + // remains editable when either default TTL or user autostop is + // enabled). + allow_user_autostop: false, }, onSubmit: fn(), }, @@ -70,6 +74,13 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { await expect(activityBumpField).toBeDisabled(); + // Helper text explains why the field is disabled. + await expect( + canvas.getByText( + /activity bump only applies when "default autostop" is configured or users are allowed to customize autostop/i, + ), + ).toBeInTheDocument(); + const submitButton = canvas.getByRole("button", { name: /save/i }); await user.click(submitButton); @@ -82,3 +93,105 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { }); }, }; + +export const SubmitPreservesActivityBumpWhenAllowUserAutostopIsEnabled: Story = + { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + activity_bump_ms: 3 * 60 * 60 * 1000, + // Users can customize autostop on their workspaces, so activity + // bump still has meaning even without a default TTL. + allow_user_autostop: true, + }, + 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"); + + // Activity bump stays enabled because allow_user_autostop is on. + await expect(activityBumpField).not.toBeDisabled(); + + const submitButton = canvas.getByRole("button", { name: /save/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + activity_bump_ms: 3 * 60 * 60 * 1000, + }), + ); + }); + }, + }; + +export const ReEnablesActivityBumpWhenDefaultTTLIsSetBack: Story = { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + allow_user_autostop: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + + // Guard fires: default TTL is 0 and user autostop is off. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + await expect(activityBumpField).toBeDisabled(); + + // Setting default TTL back to a non-zero value re-enables the field. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "8"); + await expect(activityBumpField).not.toBeDisabled(); + }, +}; + +export const ReEnablesActivityBumpWhenAllowUserAutostopIsToggledOn: Story = { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + allow_user_autostop: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + const allowUserAutostopCheckbox = canvas.getByRole("checkbox", { + name: /allow users to customize autostop/i, + }); + + // Guard fires: default TTL is 0 and user autostop is off. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + await expect(activityBumpField).toBeDisabled(); + + // Toggling user autostop back on re-enables the field without + // touching the default TTL. + await user.click(allowUserAutostopCheckbox); + await expect(activityBumpField).not.toBeDisabled(); + }, +}; From 1be48cf5d5c9be286ca300ab5c07ce39cb1d8bd9 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 14 Jul 2026 01:54:09 +0000 Subject: [PATCH 2/2] chore: commit bump