diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx new file mode 100644 index 0000000000000..32e3953f9b5c6 --- /dev/null +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within, userEvent } from "@storybook/test"; +import { useState } from "react"; +import { DurationField } from "./DurationField"; + +const meta: Meta = { + title: "components/DurationField", + component: DurationField, + args: { + label: "Duration", + }, + render: function RenderComponent(args) { + const [value, setValue] = useState(args.valueMs); + return ( + setValue(value)} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Hours: Story = { + args: { + valueMs: hoursToMs(16), + }, +}; + +export const Days: Story = { + args: { + valueMs: daysToMs(2), + }, +}; + +export const TypeOnlyNumbers: Story = { + args: { + valueMs: 0, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Duration"); + await userEvent.clear(input); + await userEvent.type(input, "abcd_.?/48.0"); + await expect(input).toHaveValue("480"); + }, +}; + +export const ChangeUnit: Story = { + args: { + valueMs: daysToMs(2), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Duration"); + const unitDropdown = canvas.getByLabelText("Time unit"); + await userEvent.click(unitDropdown); + const hoursOption = within(document.body).getByText("Hours"); + await userEvent.click(hoursOption); + await expect(input).toHaveValue("48"); + }, +}; + +export const CantConvertToDays: Story = { + args: { + valueMs: hoursToMs(2), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const unitDropdown = canvas.getByLabelText("Time unit"); + await userEvent.click(unitDropdown); + const daysOption = within(document.body).getByText("Days"); + await expect(daysOption).toHaveAttribute("aria-disabled", "true"); + }, +}; + +function hoursToMs(hours: number): number { + return hours * 60 * 60 * 1000; +} + +function daysToMs(days: number): number { + return days * 24 * 60 * 60 * 1000; +} diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx new file mode 100644 index 0000000000000..8e2dc752ba410 --- /dev/null +++ b/site/src/components/DurationField/DurationField.tsx @@ -0,0 +1,187 @@ +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; +import FormHelperText from "@mui/material/FormHelperText"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import TextField, { type TextFieldProps } from "@mui/material/TextField"; +import { type FC, useEffect, useReducer } from "react"; +import { + type TimeUnit, + durationInDays, + durationInHours, + suggestedTimeUnit, +} from "utils/time"; + +type DurationFieldProps = Omit & { + valueMs: number; + onChange: (value: number) => void; +}; + +type State = { + unit: TimeUnit; + // Handling empty values as strings in the input simplifies the process, + // especially when a user clears the input field. + durationFieldValue: string; +}; + +type Action = + | { type: "SYNC_WITH_PARENT"; parentValueMs: number } + | { type: "CHANGE_DURATION_FIELD_VALUE"; fieldValue: string } + | { type: "CHANGE_TIME_UNIT"; unit: TimeUnit }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "SYNC_WITH_PARENT": { + return initState(action.parentValueMs); + } + case "CHANGE_DURATION_FIELD_VALUE": { + return { + ...state, + durationFieldValue: action.fieldValue, + }; + } + case "CHANGE_TIME_UNIT": { + const currentDurationMs = durationInMs( + state.durationFieldValue, + state.unit, + ); + + if ( + action.unit === "days" && + !canConvertDurationToDays(currentDurationMs) + ) { + return state; + } + + return { + unit: action.unit, + durationFieldValue: + action.unit === "hours" + ? durationInHours(currentDurationMs).toString() + : durationInDays(currentDurationMs).toString(), + }; + } + default: { + return state; + } + } +}; + +export const DurationField: FC = (props) => { + const { + valueMs: parentValueMs, + onChange, + helperText, + ...textFieldProps + } = props; + const [state, dispatch] = useReducer(reducer, initState(parentValueMs)); + const currentDurationMs = durationInMs(state.durationFieldValue, state.unit); + + useEffect(() => { + if (parentValueMs !== currentDurationMs) { + dispatch({ type: "SYNC_WITH_PARENT", parentValueMs }); + } + }, [currentDurationMs, parentValueMs]); + + return ( +
+
+ { + const durationFieldValue = intMask(e.currentTarget.value); + + dispatch({ + type: "CHANGE_DURATION_FIELD_VALUE", + fieldValue: durationFieldValue, + }); + + const newDurationInMs = durationInMs( + durationFieldValue, + state.unit, + ); + if (newDurationInMs !== parentValueMs) { + onChange(newDurationInMs); + } + }} + inputProps={{ + step: 1, + }} + /> + +
+ + {helperText && ( + {helperText} + )} +
+ ); +}; + +function initState(value: number): State { + const unit = suggestedTimeUnit(value); + const durationFieldValue = + unit === "hours" + ? durationInHours(value).toString() + : durationInDays(value).toString(); + + return { + unit, + durationFieldValue, + }; +} + +function intMask(value: string): string { + return value.replace(/\D/g, ""); +} + +function durationInMs(durationFieldValue: string, unit: TimeUnit): number { + const durationInMs = parseInt(durationFieldValue, 10); + + if (Number.isNaN(durationInMs)) { + return 0; + } + + return unit === "hours" + ? hoursToDuration(durationInMs) + : daysToDuration(durationInMs); +} + +function hoursToDuration(hours: number): number { + return hours * 60 * 60 * 1000; +} + +function daysToDuration(days: number): number { + return days * 24 * hoursToDuration(1); +} + +function canConvertDurationToDays(duration: number): boolean { + return Number.isInteger(durationInDays(duration)); +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index 4114f4d37d5b9..11f83f1e21a9c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -1,5 +1,6 @@ +import { humanDuration } from "utils/time"; + const hours = (h: number) => (h === 1 ? "hour" : "hours"); -const days = (d: number) => (d === 1 ? "day" : "days"); export const DefaultTTLHelperText = (props: { ttl?: number }) => { const { ttl = 0 } = props; @@ -60,7 +61,7 @@ export const FailureTTLHelperText = (props: { ttl?: number }) => { return ( - Coder will attempt to stop failed workspaces after {ttl} {days(ttl)}. + Coder will attempt to stop failed workspaces after {humanDuration(ttl)}. ); }; @@ -79,8 +80,8 @@ export const DormancyTTLHelperText = (props: { ttl?: number }) => { return ( - Coder will mark workspaces as dormant after {ttl} {days(ttl)} without user - connections. + Coder will mark workspaces as dormant after {humanDuration(ttl)} without + user connections. ); }; @@ -99,8 +100,8 @@ export const DormancyAutoDeletionTTLHelperText = (props: { ttl?: number }) => { return ( - Coder will automatically delete dormant workspaces after {ttl} {days(ttl)} - . + Coder will automatically delete dormant workspaces after{" "} + {humanDuration(ttl)}. ); }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 47e31f05498a3..25986850a2335 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -6,6 +6,7 @@ import TextField from "@mui/material/TextField"; import { type FormikTouched, useFormik } from "formik"; import { type ChangeEvent, type FC, useState, useEffect } from "react"; import type { Template, UpdateTemplateMeta } from "api/typesGenerated"; +import { DurationField } from "components/DurationField/DurationField"; import { FormSection, HorizontalForm, @@ -47,9 +48,9 @@ import { const MS_HOUR_CONVERSION = 3600000; const MS_DAY_CONVERSION = 86400000; -const FAILURE_CLEANUP_DEFAULT = 7; -const INACTIVITY_CLEANUP_DEFAULT = 180; -const DORMANT_AUTODELETION_DEFAULT = 30; +const FAILURE_CLEANUP_DEFAULT = 7 * MS_DAY_CONVERSION; +const INACTIVITY_CLEANUP_DEFAULT = 180 * MS_DAY_CONVERSION; +const DORMANT_AUTODELETION_DEFAULT = 30 * MS_DAY_CONVERSION; /** * The default form field space is 4 but since this form is quite heavy I think * increase the space can make it feels lighter. @@ -83,16 +84,9 @@ export const TemplateScheduleForm: FC = ({ // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, activity_bump_ms: template.activity_bump_ms / MS_HOUR_CONVERSION, - failure_ttl_ms: allowAdvancedScheduling - ? template.failure_ttl_ms / MS_DAY_CONVERSION - : 0, - time_til_dormant_ms: allowAdvancedScheduling - ? template.time_til_dormant_ms / MS_DAY_CONVERSION - : 0, - time_til_dormant_autodelete_ms: allowAdvancedScheduling - ? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION - : 0, - + failure_ttl_ms: template.failure_ttl_ms, + time_til_dormant_ms: template.time_til_dormant_ms, + time_til_dormant_autodelete_ms: template.time_til_dormant_autodelete_ms, autostop_requirement_days_of_week: allowAdvancedScheduling ? convertAutostopRequirementDaysValue( template.autostop_requirement.days_of_week, @@ -210,16 +204,10 @@ export const TemplateScheduleForm: FC = ({ activity_bump_ms: form.values.activity_bump_ms ? form.values.activity_bump_ms * MS_HOUR_CONVERSION : undefined, - failure_ttl_ms: form.values.failure_ttl_ms - ? form.values.failure_ttl_ms * MS_DAY_CONVERSION - : undefined, - time_til_dormant_ms: form.values.time_til_dormant_ms - ? form.values.time_til_dormant_ms * MS_DAY_CONVERSION - : undefined, - time_til_dormant_autodelete_ms: form.values.time_til_dormant_autodelete_ms - ? form.values.time_til_dormant_autodelete_ms * MS_DAY_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: + form.values.time_til_dormant_autodelete_ms, autostop_requirement: { days_of_week: calculateAutostopRequirementDaysValue( form.values.autostop_requirement_days_of_week, @@ -229,7 +217,6 @@ export const TemplateScheduleForm: FC = ({ autostart_requirement: { days_of_week: form.values.autostart_requirement_days_of_week, }, - 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, @@ -498,7 +485,8 @@ export const TemplateScheduleForm: FC = ({ } label={Enable Dormancy Threshold} /> - = ({ /> ), })} + label="Time until dormant" + valueMs={form.values.time_til_dormant_ms ?? 0} + onChange={(v) => form.setFieldValue("time_til_dormant_ms", v)} disabled={ isSubmitting || !form.values.inactivity_cleanup_enabled } - fullWidth - inputProps={{ min: 0, step: "any" }} - label="Time until dormant (days)" - type="number" /> @@ -539,7 +526,7 @@ export const TemplateScheduleForm: FC = ({ } /> - = ({ /> ), })} + label="Time until deletion" + valueMs={form.values.time_til_dormant_autodelete_ms ?? 0} + onChange={(v) => + form.setFieldValue("time_til_dormant_autodelete_ms", v) + } disabled={ isSubmitting || !form.values.dormant_autodeletion_cleanup_enabled } - fullWidth - inputProps={{ min: 0, step: "any" }} - label="Time until deletion (days)" - type="number" /> @@ -573,24 +561,23 @@ export const TemplateScheduleForm: FC = ({ Enable Failure Cleanup When enabled, Coder will attempt to stop workspaces that - are in a failed state after a specified number of days. + are in a failed state after a period of time. } /> - ), })} + label="Time until cleanup" + valueMs={form.values.failure_ttl_ms ?? 0} + onChange={(v) => form.setFieldValue("failure_ttl_ms", v)} disabled={ isSubmitting || !form.values.failure_cleanup_enabled } - fullWidth - inputProps={{ min: 0, step: "any" }} - label="Time until cleanup (days)" - type="number" /> diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 48d9d8ef44e4f..f1e5c51c9b2ce 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -286,7 +286,7 @@ describe("TemplateSchedulePage", () => { }; const validate = () => getValidationSchema().validateSync(values); expect(validate).toThrowError( - "Dormancy threshold days must not be less than 0.", + "Dormancy threshold must not be less than 0.", ); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx index 77a2d6d8f1596..606c590744871 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx @@ -57,10 +57,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => time_til_dormant_ms: Yup.number() .integer() .required() - .min(0, "Dormancy threshold days must not be less than 0.") + .min(0, "Dormancy threshold must not be less than 0.") .test( "positive-if-enabled", - "Dormancy threshold days must be greater than zero when enabled.", + "Dormancy threshold must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues; if (parent.inactivity_cleanup_enabled) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts index 978825dd00829..4e171f0978a8b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts @@ -25,7 +25,7 @@ export const useWorkspacesToGoDormant = ( const proposedLocking = new Date( new Date(workspace.last_used_at).getTime() + - formValues.time_til_dormant_ms * DayInMS, + formValues.time_til_dormant_ms, ); if (compareAsc(proposedLocking, fromDate) < 1) { @@ -34,8 +34,6 @@ export const useWorkspacesToGoDormant = ( }); }; -const DayInMS = 86400000; - export const useWorkspacesToBeDeleted = ( template: Template, formValues: TemplateScheduleFormValues, @@ -53,7 +51,7 @@ export const useWorkspacesToBeDeleted = ( const proposedLocking = new Date( new Date(workspace.dormant_at).getTime() + - formValues.time_til_dormant_autodelete_ms * DayInMS, + formValues.time_til_dormant_autodelete_ms, ); if (compareAsc(proposedLocking, fromDate) < 1) { diff --git a/site/src/utils/time.ts b/site/src/utils/time.ts new file mode 100644 index 0000000000000..67e3362bcbd69 --- /dev/null +++ b/site/src/utils/time.ts @@ -0,0 +1,31 @@ +export type TimeUnit = "days" | "hours"; + +export function humanDuration(durationInMs: number) { + if (durationInMs === 0) { + return "0 hours"; + } + + const timeUnit = suggestedTimeUnit(durationInMs); + const durationValue = + timeUnit === "days" + ? durationInDays(durationInMs) + : durationInHours(durationInMs); + + return `${durationValue} ${timeUnit}`; +} + +export function suggestedTimeUnit(duration: number): TimeUnit { + if (duration === 0) { + return "hours"; + } + + return Number.isInteger(durationInDays(duration)) ? "days" : "hours"; +} + +export function durationInHours(duration: number): number { + return duration / 1000 / 60 / 60; +} + +export function durationInDays(duration: number): number { + return duration / 1000 / 60 / 60 / 24; +}