diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 54d3574697..5a1cac890c 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -317,7 +317,7 @@ func TestScheduleOverride(t *testing.T) { ) require.Zero(t, template.DefaultTTLMillis) require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.Zero(t, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) diff --git a/coderd/database/migrations/000152_rename_template_restart_requirement.down.sql b/coderd/database/migrations/000152_rename_template_restart_requirement.down.sql index b0c321087c..1dc90e708d 100644 --- a/coderd/database/migrations/000152_rename_template_restart_requirement.down.sql +++ b/coderd/database/migrations/000152_rename_template_restart_requirement.down.sql @@ -1,7 +1,25 @@ BEGIN; +DROP VIEW template_with_users; + ALTER TABLE templates RENAME COLUMN autostop_requirement_days_of_week TO restart_requirement_days_of_week; ALTER TABLE templates RENAME COLUMN autostop_requirement_weeks TO restart_requirement_weeks; +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + COMMIT; diff --git a/coderd/database/migrations/000152_rename_template_restart_requirement.up.sql b/coderd/database/migrations/000152_rename_template_restart_requirement.up.sql index 3ff9dcf3da..6732328751 100644 --- a/coderd/database/migrations/000152_rename_template_restart_requirement.up.sql +++ b/coderd/database/migrations/000152_rename_template_restart_requirement.up.sql @@ -1,11 +1,11 @@ BEGIN; +DROP VIEW template_with_users; + ALTER TABLE templates RENAME COLUMN restart_requirement_days_of_week TO autostop_requirement_days_of_week; ALTER TABLE templates RENAME COLUMN restart_requirement_weeks TO autostop_requirement_weeks; -DROP VIEW template_with_users; - CREATE VIEW template_with_users AS diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index f75a44359c..74fe0d6453 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -72,8 +72,8 @@ func VerifyTemplateAutostopRequirement(days uint8, weeks int64) error { if days > 0b11111111 { return xerrors.New("invalid autostop requirement days, too large") } - if weeks < 0 { - return xerrors.New("invalid autostop requirement weeks, negative") + if weeks < 1 { + return xerrors.New("invalid autostop requirement weeks, less than 1") } if weeks > MaxTemplateAutostopRequirementWeeks { return xerrors.New("invalid autostop requirement weeks, too large") @@ -154,8 +154,10 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te UseAutostopRequirement: false, MaxTTL: 0, AutostopRequirement: TemplateAutostopRequirement{ + // No days means never. The weeks value should always be greater + // than zero though. DaysOfWeek: 0, - Weeks: 0, + Weeks: 1, }, FailureTTL: 0, TimeTilDormant: 0, diff --git a/coderd/templates.go b/coderd/templates.go index 6de442f8ae..09dc68d786 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -527,6 +527,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.AutostopRequirement.Weeks < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."}) } + if req.AutostopRequirement.Weeks == 0 { + req.AutostopRequirement.Weeks = 1 + } + if template.AutostopRequirementWeeks <= 0 { + template.AutostopRequirementWeeks = 1 + } if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)}) } @@ -737,6 +743,11 @@ func (api *API) convertTemplate( buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID) + autostopRequirementWeeks := template.AutostopRequirementWeeks + if autostopRequirementWeeks < 1 { + autostopRequirementWeeks = 1 + } + return codersdk.Template{ ID: template.ID, CreatedAt: template.CreatedAt, @@ -762,7 +773,7 @@ func (api *API) convertTemplate( TimeTilDormantAutoDeleteMillis: time.Duration(template.TimeTilDormantAutoDelete).Milliseconds(), AutostopRequirement: codersdk.TemplateAutostopRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.AutostopRequirementDaysOfWeek)), - Weeks: template.AutostopRequirementWeeks, + Weeks: autostopRequirementWeeks, }, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 5df2d9061e..069ee1db7e 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -296,7 +296,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) require.Empty(t, got.AutostopRequirement.DaysOfWeek) - require.Zero(t, got.AutostopRequirement.Weeks) + require.EqualValues(t, 1, got.AutostopRequirement.Weeks) }) t.Run("OK", func(t *testing.T) { @@ -379,7 +379,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.NoError(t, err) // ignored and use AGPL defaults require.Empty(t, got.AutostopRequirement.DaysOfWeek) - require.Zero(t, got.AutostopRequirement.Weeks) + require.EqualValues(t, 1, got.AutostopRequirement.Weeks) }) }) } @@ -1006,7 +1006,7 @@ func TestPatchTemplateMeta(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.Zero(t, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, DisplayName: template.DisplayName, @@ -1045,7 +1045,7 @@ func TestPatchTemplateMeta(t *testing.T) { SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { assert.EqualValues(t, 0, options.AutostopRequirement.DaysOfWeek) - assert.EqualValues(t, 0, options.AutostopRequirement.Weeks) + assert.EqualValues(t, 1, options.AutostopRequirement.Weeks) } err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ @@ -1102,12 +1102,12 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) require.Empty(t, updated.AutostopRequirement.DaysOfWeek) - require.EqualValues(t, 0, updated.AutostopRequirement.Weeks) + require.EqualValues(t, 1, updated.AutostopRequirement.Weeks) template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.EqualValues(t, 0, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) }) t.Run("EnterpriseOnly", func(t *testing.T) { @@ -1118,7 +1118,7 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.Zero(t, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, DisplayName: template.DisplayName, @@ -1138,12 +1138,12 @@ func TestPatchTemplateMeta(t *testing.T) { updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) require.Empty(t, updated.AutostopRequirement.DaysOfWeek) - require.Zero(t, updated.AutostopRequirement.Weeks) + require.EqualValues(t, 1, updated.AutostopRequirement.Weeks) template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.Zero(t, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) }) }) } diff --git a/codersdk/templates.go b/codersdk/templates.go index a144388f7f..7fc441bda5 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -83,7 +83,7 @@ func WeekdaysToBitmap(days []string) (uint8, error) { // BitmapToWeekdays converts a bitmap to a list of weekdays in accordance with // the schedule package's rules (see above). func BitmapToWeekdays(bitmap uint8) []string { - var days []string + days := []string{} for i := 0; i < 7; i++ { if bitmap&(1< 0b11111111 { return agpl.TemplateScheduleOptions{}, xerrors.New("invalid autostop requirement days, too large") } + if tpl.AutostopRequirementWeeks == 0 { + tpl.AutostopRequirementWeeks = 1 + } err = agpl.VerifyTemplateAutostopRequirement(uint8(tpl.AutostopRequirementDaysOfWeek), tpl.AutostopRequirementWeeks) if err != nil { return agpl.TemplateScheduleOptions{}, err @@ -94,6 +97,13 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S ctx, span := tracing.StartSpan(ctx) defer span.End() + if opts.AutostopRequirement.Weeks <= 0 { + opts.AutostopRequirement.Weeks = 1 + } + if tpl.AutostopRequirementWeeks <= 0 { + tpl.AutostopRequirementWeeks = 1 + } + if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.MaxTTL) == tpl.MaxTTL && int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek && diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 305f19b105..ad961368ee 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -158,7 +158,7 @@ func TestTemplates(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Empty(t, 0, template.AutostopRequirement.DaysOfWeek) - require.Zero(t, template.AutostopRequirement.Weeks) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) // ctx := testutil.Context(t, testutil.WaitLong) ctx := context.Background() diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 6190750d16..74d89af268 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -351,7 +351,7 @@ export const WorkspaceScheduleForm: FC< = ({ @@ -195,6 +211,7 @@ export const CreateTemplateForm: FC = ({ logs, allowAdvancedScheduling, allowDisableEveryoneAccess, + allowAutostopRequirement, }) => { const styles = useStyles() const form = useFormik({ @@ -223,6 +240,25 @@ export const CreateTemplateForm: FC = ({ } }, [logs, jobError]) + // Set autostop_requirement weeks to 1 when days_of_week is set to "off" or + // "daily". Technically you can set weeks to a different value in the backend + // and it will work, but this is a UX decision so users don't set days=daily + // and weeks=2 and get confused when workspaces only restart daily during + // every second week. + // + // We want to set the value to 1 when the user selects "off" or "daily" + // because the input gets disabled so they can't change it to 1 themselves. + const { + values: { autostop_requirement_days_of_week }, + setFieldValue, + } = form + useEffect(() => { + if (!["saturday", "sunday"].includes(autostop_requirement_days_of_week)) { + // This is async but we don't really need to await the value. + void setFieldValue("autostop_requirement_weeks", 1) + } + }, [autostop_requirement_days_of_week, setFieldValue]) + return ( {/* General info */} @@ -312,30 +348,84 @@ export const CreateTemplateForm: FC = ({ type="number" /> - - ) : ( - <> - {commonT("licenseFieldTextHelper")}{" "} - - {commonT("learnMore")} - - . - - ), - )} - disabled={isSubmitting || !allowAdvancedScheduling} - fullWidth - label={t("form.fields.maxTTL")} - type="number" - /> + {!allowAutostopRequirement && ( + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !allowAdvancedScheduling} + fullWidth + label={t("form.fields.maxTTL")} + type="number" + /> + )} + + {allowAutostopRequirement && ( + + , + )} + disabled={isSubmitting} + fullWidth + select + value={form.values.autostop_requirement_days_of_week} + label={t("form.fields.autostopRequirementDays")} + > + + {t("form.fields.autostopRequirementDays_off")} + + + {t("form.fields.autostopRequirementDays_daily")} + + + {t("form.fields.autostopRequirementDays_saturday")} + + + {t("form.fields.autostopRequirementDays_sunday")} + + + + , + )} + disabled={ + isSubmitting || + !["saturday", "sunday"].includes( + form.values.autostop_requirement_days_of_week || "", + ) + } + fullWidth + inputProps={{ min: 1, max: 16, step: 1 }} + label={t("form.fields.autostopRequirementWeeks")} + type="number" + /> + + )} + { const { starterTemplate, error, file, jobError, jobLogs, variables } = state.context const shouldDisplayForm = !state.hasTag("loading") - const { entitlements } = useDashboard() + const { entitlements, experiments } = useDashboard() const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled // Requires the template RBAC feature, otherwise disabling everyone access // means no one can access. const allowDisableEveryoneAccess = entitlements.features["template_rbac"].enabled + const allowAutostopRequirement = experiments.includes( + "template_autostop_requirement", + ) const onCancel = () => { navigate(-1) @@ -69,6 +72,7 @@ const CreateTemplatePage: FC = () => { copiedTemplate={state.context.copiedTemplate} allowAdvancedScheduling={allowAdvancedScheduling} allowDisableEveryoneAccess={allowDisableEveryoneAccess} + allowAutostopRequirement={allowAutostopRequirement} error={error} starterTemplate={starterTemplate} isSubmitting={state.hasTag("submitting")} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/AutostopRequirementHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/AutostopRequirementHelperText.tsx new file mode 100644 index 0000000000..90127df6a4 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/AutostopRequirementHelperText.tsx @@ -0,0 +1,86 @@ +import { Template } from "api/typesGenerated" +import { useTranslation } from "react-i18next" + +export type TemplateAutostopRequirementDaysValue = + | "off" + | "daily" + | "saturday" + | "sunday" + +export const convertAutostopRequirementDaysValue = ( + days: Template["autostop_requirement"]["days_of_week"], +): TemplateAutostopRequirementDaysValue => { + if (days.length === 7) { + return "daily" + } else if (days.length === 1 && days[0] === "saturday") { + return "saturday" + } else if (days.length === 1 && days[0] === "sunday") { + return "sunday" + } + + // On unsupported values we default to "off". + return "off" +} + +export const calculateAutostopRequirementDaysValue = ( + value: TemplateAutostopRequirementDaysValue, +): Template["autostop_requirement"]["days_of_week"] => { + switch (value) { + case "daily": + return [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + case "saturday": + return ["saturday"] + case "sunday": + return ["sunday"] + } + + return [] +} + +export const AutostopRequirementDaysHelperText = ({ + days, +}: { + days: TemplateAutostopRequirementDaysValue +}) => { + const { t } = useTranslation("templateSettingsPage") + + let str = "off" + if (days) { + str = days + } + + return {t("autostopRequirementDaysHelperText_" + str)} +} + +export const AutostopRequirementWeeksHelperText = ({ + days, + weeks, +}: { + days: TemplateAutostopRequirementDaysValue + weeks: number +}) => { + const { t } = useTranslation("templateSettingsPage") + + let str = "disabled" + if (days === "saturday" || days === "sunday") { + if (weeks === 0 || weeks === 1) { + str = "one" + } else { + str = "other" + } + } + + return ( + + {t("autostopRequirementWeeksHelperText_" + str, { count: weeks })} + + ) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 99107b4d28..bd48b3fcd7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -1,7 +1,7 @@ import TextField from "@mui/material/TextField" import { Template, UpdateTemplateMeta } from "api/typesGenerated" import { FormikTouched, useFormik } from "formik" -import { FC, ChangeEvent, useState } from "react" +import { FC, ChangeEvent, useState, useEffect } from "react" import { getFormHelpers } from "utils/formUtils" import { useTranslation } from "react-i18next" import { @@ -24,6 +24,13 @@ import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" import { docs } from "utils/docs" import { ScheduleDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import MenuItem from "@mui/material/MenuItem" +import { + AutostopRequirementDaysHelperText, + AutostopRequirementWeeksHelperText, + calculateAutostopRequirementDaysValue, + convertAutostopRequirementDaysValue, +} from "./AutostopRequirementHelperText" const MS_HOUR_CONVERSION = 3600000 const MS_DAY_CONVERSION = 86400000 @@ -39,6 +46,7 @@ export interface TemplateScheduleForm { error?: unknown allowAdvancedScheduling: boolean allowWorkspaceActions: boolean + allowAutostopRequirement: boolean // Helpful to show field errors on Storybook initialTouched?: FormikTouched } @@ -50,6 +58,7 @@ export const TemplateScheduleForm: FC = ({ error, allowAdvancedScheduling, allowWorkspaceActions, + allowAutostopRequirement, isSubmitting, initialTouched, }) => { @@ -74,10 +83,16 @@ export const TemplateScheduleForm: FC = ({ ? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION : 0, - autostop_requirement: { - days_of_week: template.autostop_requirement.days_of_week, - weeks: template.autostop_requirement.weeks, - }, + autostop_requirement_days_of_week: allowAutostopRequirement + ? convertAutostopRequirementDaysValue( + template.autostop_requirement.days_of_week, + ) + : "off", + autostop_requirement_weeks: allowAutostopRequirement + ? template.autostop_requirement.weeks > 0 + ? template.autostop_requirement.weeks + : 1 + : 1, allow_user_autostart: template.allow_user_autostart, allow_user_autostop: template.allow_user_autostop, @@ -120,6 +135,7 @@ export const TemplateScheduleForm: FC = ({ }, initialTouched, }) + const getFieldHelpers = getFormHelpers( form, error, @@ -167,6 +183,12 @@ export const TemplateScheduleForm: FC = ({ useState(false) const submitValues = () => { + const autostop_requirement_weeks = ["saturday", "sunday"].includes( + form.values.autostop_requirement_days_of_week, + ) + ? form.values.autostop_requirement_weeks + : 1 + // on submit, convert from hours => ms onSubmit({ default_ttl_ms: form.values.default_ttl_ms @@ -185,6 +207,13 @@ export const TemplateScheduleForm: FC = ({ ? form.values.time_til_dormant_autodelete_ms * MS_DAY_CONVERSION : undefined, + autostop_requirement: { + days_of_week: calculateAutostopRequirementDaysValue( + form.values.autostop_requirement_days_of_week, + ), + weeks: autostop_requirement_weeks, + }, + allow_user_autostart: form.values.allow_user_autostart, allow_user_autostop: form.values.allow_user_autostop, update_workspace_last_used_at: form.values.update_workspace_last_used_at, @@ -192,6 +221,30 @@ export const TemplateScheduleForm: FC = ({ }) } + // Set autostop_requirement weeks to 1 when days_of_week is set to "off" or + // "daily". Technically you can set weeks to a different value in the backend + // and it will work, but this is a UX decision so users don't set days=daily + // and weeks=2 and get confused when workspaces only restart daily during + // every second week. + // + // We want to set the value to 1 when the user selects "off" or "daily" + // because the input gets disabled so they can't change it to 1 themselves. + const { values: currentValues, setValues } = form + useEffect(() => { + if ( + !["saturday", "sunday"].includes( + currentValues.autostop_requirement_days_of_week, + ) && + currentValues.autostop_requirement_weeks !== 1 + ) { + // This is async but we don't really need to await the value. + void setValues({ + ...currentValues, + autostop_requirement_weeks: 1, + }) + } + }, [currentValues, setValues]) + const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.failure_cleanup_enabled) { @@ -274,31 +327,91 @@ export const TemplateScheduleForm: FC = ({ type="number" /> - - ) : ( - <> - {commonT("licenseFieldTextHelper")}{" "} - {commonT("learnMore")} - . - - ), - )} - disabled={isSubmitting || !allowAdvancedScheduling} - fullWidth - inputProps={{ min: 0, step: 1 }} - label={t("maxTtlLabel")} - type="number" - /> + {!allowAutostopRequirement && ( + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !allowAdvancedScheduling} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("maxTtlLabel")} + type="number" + /> + )} + {allowAutostopRequirement && ( + + + , + )} + disabled={isSubmitting} + fullWidth + select + value={form.values.autostop_requirement_days_of_week} + label={t("autostopRequirementDaysLabel")} + > + + {t("autostopRequirementDays_off")} + + + {t("autostopRequirementDays_daily")} + + + {t("autostopRequirementDays_saturday")} + + + {t("autostopRequirementDays_sunday")} + + + + , + )} + disabled={ + isSubmitting || + !["saturday", "sunday"].includes( + form.values.autostop_requirement_days_of_week || "", + ) + } + fullWidth + inputProps={{ min: 1, max: 16, step: 1 }} + label={t("autostopRequirementWeeksLabel")} + type="number" + /> + + + )} + { + autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue + autostop_requirement_weeks: number failure_cleanup_enabled: boolean inactivity_cleanup_enabled: boolean dormant_autodeletion_cleanup_enabled: boolean @@ -80,4 +84,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => ), allow_user_autostart: Yup.boolean(), allow_user_autostop: Yup.boolean(), + + autostop_requirement_days_of_week: Yup.string().required(), + autostop_requirement_weeks: Yup.number().required().min(1).max(16), }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 476d5085d0..5c8e235400 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -1,7 +1,6 @@ import { screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import * as API from "api/api" -import { UpdateTemplateMeta } from "api/typesGenerated" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" import { MockEntitlementsWithScheduling, @@ -11,13 +10,16 @@ import { renderWithTemplateSettingsLayout, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers" -import { getValidationSchema } from "./TemplateScheduleForm/formHelpers" +import { + TemplateScheduleFormValues, + getValidationSchema, +} from "./TemplateScheduleForm/formHelpers" import TemplateSchedulePage from "./TemplateSchedulePage" import i18next from "i18next" const { t } = i18next -const validFormValues = { +const validFormValues: TemplateScheduleFormValues = { default_ttl_ms: 1, max_ttl_ms: 2, failure_ttl_ms: 7, @@ -25,6 +27,11 @@ const validFormValues = { time_til_dormant_autodelete_ms: 30, update_workspace_last_used_at: false, update_workspace_dormant_at: false, + autostop_requirement_days_of_week: "off", + autostop_requirement_weeks: 1, + failure_cleanup_enabled: false, + inactivity_cleanup_enabled: false, + dormant_autodeletion_cleanup_enabled: false, } const renderTemplateSchedulePage = async () => { @@ -42,40 +49,51 @@ const fillAndSubmitForm = async ({ time_til_dormant_ms, time_til_dormant_autodelete_ms, }: { - default_ttl_ms: number - max_ttl_ms: number - failure_ttl_ms: number - time_til_dormant_ms: number - time_til_dormant_autodelete_ms: number + default_ttl_ms?: number + max_ttl_ms?: number + failure_ttl_ms?: number + time_til_dormant_ms?: number + time_til_dormant_autodelete_ms?: number }) => { const user = userEvent.setup() - const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) - const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) - await user.clear(defaultTtlField) - await user.type(defaultTtlField, default_ttl_ms.toString()) - const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) - const maxTtlField = await screen.findByLabelText(maxTtlLabel) - await user.clear(maxTtlField) - await user.type(maxTtlField, max_ttl_ms.toString()) + if (default_ttl_ms) { + const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) + const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) + await user.clear(defaultTtlField) + await user.type(defaultTtlField, default_ttl_ms.toString()) + } - const failureTtlField = screen.getByRole("checkbox", { - name: /Failure Cleanup/i, - }) - await user.type(failureTtlField, failure_ttl_ms.toString()) + if (max_ttl_ms) { + const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) + const maxTtlField = await screen.findByLabelText(maxTtlLabel) + await user.clear(maxTtlField) + await user.type(maxTtlField, max_ttl_ms.toString()) + } - const inactivityTtlField = screen.getByRole("checkbox", { - name: /Dormancy Threshold/i, - }) - await user.type(inactivityTtlField, time_til_dormant_ms.toString()) + if (failure_ttl_ms) { + const failureTtlField = screen.getByRole("checkbox", { + name: /Failure Cleanup/i, + }) + await user.type(failureTtlField, failure_ttl_ms.toString()) + } - const dormancyAutoDeletionField = screen.getByRole("checkbox", { - name: /Dormancy Auto-Deletion/i, - }) - await user.type( - dormancyAutoDeletionField, - time_til_dormant_autodelete_ms.toString(), - ) + if (time_til_dormant_ms) { + const inactivityTtlField = screen.getByRole("checkbox", { + name: /Dormancy Threshold/i, + }) + await user.type(inactivityTtlField, time_til_dormant_ms.toString()) + } + + if (time_til_dormant_autodelete_ms) { + const dormancyAutoDeletionField = screen.getByRole("checkbox", { + name: /Dormancy Auto-Deletion/i, + }) + await user.type( + dormancyAutoDeletionField, + time_til_dormant_autodelete_ms.toString(), + ) + } const submitButton = await screen.findByText( FooterFormLanguage.defaultSubmitLabel, @@ -121,8 +139,8 @@ describe("TemplateSchedulePage", () => { expect(API.updateTemplateMeta).toBeCalledWith( "test-template", expect.objectContaining({ - default_ttl_ms: validFormValues.default_ttl_ms * 3600000, - max_ttl_ms: validFormValues.max_ttl_ms * 3600000, + default_ttl_ms: (validFormValues.default_ttl_ms || 0) * 3600000, + max_ttl_ms: (validFormValues.max_ttl_ms || 0) * 3600000, }), ), ) @@ -142,17 +160,18 @@ describe("TemplateSchedulePage", () => { expect(API.updateTemplateMeta).toBeCalledWith( "test-template", expect.objectContaining({ - failure_ttl_ms: validFormValues.failure_ttl_ms * 86400000, - time_til_dormant_ms: validFormValues.time_til_dormant_ms * 86400000, + failure_ttl_ms: (validFormValues.failure_ttl_ms || 0) * 86400000, + time_til_dormant_ms: + (validFormValues.time_til_dormant_ms || 0) * 86400000, time_til_dormant_autodelete_ms: - validFormValues.time_til_dormant_autodelete_ms * 86400000, + (validFormValues.time_til_dormant_autodelete_ms || 0) * 86400000, }), ), ) }) it("allows a default ttl of 7 days", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, default_ttl_ms: 24 * 7, } @@ -161,7 +180,7 @@ describe("TemplateSchedulePage", () => { }) it("allows default ttl of 0", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, default_ttl_ms: 0, } @@ -170,7 +189,7 @@ describe("TemplateSchedulePage", () => { }) it("allows a default ttl of 30 days", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, default_ttl_ms: 24 * 30, } @@ -179,7 +198,7 @@ describe("TemplateSchedulePage", () => { }) it("disallows a default ttl of 30 days + 1 hour", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, default_ttl_ms: 24 * 30 + 1, } @@ -190,7 +209,7 @@ describe("TemplateSchedulePage", () => { }) it("allows a failure ttl of 7 days", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, failure_ttl_ms: 86400000 * 7, } @@ -199,7 +218,7 @@ describe("TemplateSchedulePage", () => { }) it("allows failure ttl of 0", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, failure_ttl_ms: 0, } @@ -208,7 +227,7 @@ describe("TemplateSchedulePage", () => { }) it("disallows a negative failure ttl", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, failure_ttl_ms: -1, } @@ -219,7 +238,7 @@ describe("TemplateSchedulePage", () => { }) it("allows an inactivity ttl of 7 days", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_ms: 86400000 * 7, } @@ -228,7 +247,7 @@ describe("TemplateSchedulePage", () => { }) it("allows an inactivity ttl of 0", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_ms: 0, } @@ -237,7 +256,7 @@ describe("TemplateSchedulePage", () => { }) it("disallows a negative inactivity ttl", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_ms: -1, } @@ -248,7 +267,7 @@ describe("TemplateSchedulePage", () => { }) it("allows a dormancy ttl of 7 days", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_autodelete_ms: 86400000 * 7, } @@ -257,7 +276,7 @@ describe("TemplateSchedulePage", () => { }) it("allows a dormancy ttl of 0", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_autodelete_ms: 0, } @@ -266,7 +285,7 @@ describe("TemplateSchedulePage", () => { }) it("disallows a negative inactivity ttl", () => { - const values: UpdateTemplateMeta = { + const values: TemplateScheduleFormValues = { ...validFormValues, time_til_dormant_autodelete_ms: -1, } @@ -275,4 +294,41 @@ describe("TemplateSchedulePage", () => { "Dormancy auto-deletion days must not be less than 0.", ) }) + + it("allows an autostop requirement weeks of 1", () => { + const values: TemplateScheduleFormValues = { + ...validFormValues, + autostop_requirement_days_of_week: "saturday", + autostop_requirement_weeks: 1, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("allows a autostop requirement weeks of 16", () => { + const values: TemplateScheduleFormValues = { + ...validFormValues, + autostop_requirement_weeks: 16, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("disallows a autostop requirement weeks of 0", () => { + const values: TemplateScheduleFormValues = { + ...validFormValues, + autostop_requirement_weeks: 0, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).toThrowError() + }) + + it("disallows a autostop requirement weeks of 17", () => { + const values: TemplateScheduleFormValues = { + ...validFormValues, + autostop_requirement_weeks: 17, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).toThrowError() + }) }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx index 2145099a67..0cf726705e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -21,6 +21,9 @@ const TemplateSchedulePage: FC = () => { // This check can be removed when https://github.com/coder/coder/milestone/19 // is merged up const allowWorkspaceActions = experiments.includes("workspace_actions") + const allowAutostopRequirement = experiments.includes( + "template_autostop_requirement", + ) const { clearLocal } = useLocalStorage() const { @@ -47,6 +50,7 @@ const TemplateSchedulePage: FC = () => { ["initialTouched"] allowAdvancedScheduling: boolean allowWorkspaceActions: boolean + allowAutostopRequirement: boolean } export const TemplateSchedulePageView: FC = ({ @@ -22,6 +23,7 @@ export const TemplateSchedulePageView: FC = ({ isSubmitting, allowAdvancedScheduling, allowWorkspaceActions, + allowAutostopRequirement, submitError, initialTouched, }) => { @@ -36,6 +38,7 @@ export const TemplateSchedulePageView: FC = ({ {