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; +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy