From 6d359fd1138f968d3166cabcb550670bd330c8bd Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 8 May 2024 17:13:24 +0000 Subject: [PATCH 01/16] Add DurationField component --- .../DurationField/DurationField.stories.tsx | 44 +++++++ .../DurationField/DurationField.tsx | 115 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 site/src/components/DurationField/DurationField.stories.tsx create mode 100644 site/src/components/DurationField/DurationField.tsx diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx new file mode 100644 index 0000000000000..dda950cbb154b --- /dev/null +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +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.value); + return ( + setValue(value)} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Hours: Story = { + args: { + value: hoursToMs(16), + }, +}; + +export const Days: Story = { + args: { + value: daysToMs(2), + }, +}; + +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..c801e3d62ba6a --- /dev/null +++ b/site/src/components/DurationField/DurationField.tsx @@ -0,0 +1,115 @@ +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import TextField from "@mui/material/TextField"; +import { useState, type FC } from "react"; + +type TimeUnit = "days" | "hours"; + +// Value should be in milliseconds or undefined. Undefined means no value. +type DurationValue = number | undefined; + +type DurationFieldProps = { + label: string; + value: DurationValue; + onChange: (value: DurationValue) => void; +}; + +export const DurationField: FC = (props) => { + const { label, value, onChange } = props; + const [timeUnit, setTimeUnit] = useState(() => { + if (!value) { + return "hours"; + } + + return Number.isInteger(durationToDays(value)) ? "days" : "hours"; + }); + + return ( +
+ { + if (e.target.value === "") { + onChange(undefined); + } + + const value = parseInt(e.target.value); + + if (Number.isNaN(value)) { + return; + } + + onChange( + timeUnit === "hours" + ? hoursToDuration(value) + : daysToDuration(value), + ); + }} + inputProps={{ + step: 1, + type: "number", + }} + /> + +
+ ); +}; + +function durationToHours(duration: number): number { + return duration / 1000 / 60 / 60; +} + +function hoursToDuration(hours: number): number { + return hours * 60 * 60 * 1000; +} + +function durationToDays(duration: number): number { + return duration / 1000 / 60 / 60 / 24; +} + +function daysToDuration(days: number): number { + return days * 24 * 60 * 60 * 1000; +} + +function canConvertDurationToDays(duration: number): boolean { + return Number.isInteger(durationToDays(duration)); +} + +function canConvertDurationToHours(duration: number): boolean { + return Number.isInteger(durationToHours(duration)); +} From 5ac060232c5754146f0fdfca1acd1241efdcb788 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 8 May 2024 19:06:45 +0000 Subject: [PATCH 02/16] Add empty story --- site/src/components/DurationField/DurationField.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx index dda950cbb154b..20db55e6551f5 100644 --- a/site/src/components/DurationField/DurationField.stories.tsx +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -23,6 +23,12 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const Empty: Story = { + args: { + value: undefined, + }, +}; + export const Hours: Story = { args: { value: hoursToMs(16), From 95e4f44c3850fc612d4515c4b67db7414ad69551 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 8 May 2024 19:23:19 +0000 Subject: [PATCH 03/16] Avoid negative values --- site/src/components/DurationField/DurationField.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index c801e3d62ba6a..fa827008fef04 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -47,12 +47,15 @@ export const DurationField: FC = (props) => { onChange(undefined); } - const value = parseInt(e.target.value); + let value = parseInt(e.target.value); if (Number.isNaN(value)) { return; } + // Avoid negative values + value = Math.abs(value); + onChange( timeUnit === "hours" ? hoursToDuration(value) From 4a48102bd08c33a5a632f241810853f5c307ca7b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 9 May 2024 14:04:14 +0000 Subject: [PATCH 04/16] Use duration field time_til_dormant_ms --- .../DurationField/DurationField.tsx | 133 ++++++++++-------- .../TemplateScheduleForm.tsx | 26 +++- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index fa827008fef04..1b6d03de47008 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -1,8 +1,9 @@ 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 from "@mui/material/TextField"; -import { useState, type FC } from "react"; +import { type ReactNode, useState, type FC } from "react"; type TimeUnit = "days" | "hours"; @@ -12,11 +13,13 @@ type DurationValue = number | undefined; type DurationFieldProps = { label: string; value: DurationValue; + disabled?: boolean; + helperText?: ReactNode; onChange: (value: DurationValue) => void; }; export const DurationField: FC = (props) => { - const { label, value, onChange } = props; + const { label, value, disabled, helperText, onChange } = props; const [timeUnit, setTimeUnit] = useState(() => { if (!value) { return "hours"; @@ -26,69 +29,75 @@ export const DurationField: FC = (props) => { }); return ( -
- { - if (e.target.value === "") { - onChange(undefined); - } - - let value = parseInt(e.target.value); - - if (Number.isNaN(value)) { - return; - } - - // Avoid negative values - value = Math.abs(value); - - onChange( - timeUnit === "hours" - ? hoursToDuration(value) - : daysToDuration(value), - ); - }} - inputProps={{ - step: 1, - type: "number", - }} - /> - { + setTimeUnit(e.target.value as TimeUnit); + }} + inputProps={{ "aria-label": "Time unit" }} + IconComponent={KeyboardArrowDown} > - Days - - + + Hours + + + Days + + +
+ + {helperText && {helperText}} ); }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 47e31f05498a3..16e99f09f1bad 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, @@ -86,9 +87,7 @@ export const TemplateScheduleForm: FC = ({ 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_ms: template.time_til_dormant_ms, time_til_dormant_autodelete_ms: allowAdvancedScheduling ? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION : 0, @@ -213,9 +212,7 @@ export const TemplateScheduleForm: FC = ({ 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_ms: form.values.time_til_dormant_ms, time_til_dormant_autodelete_ms: form.values.time_til_dormant_autodelete_ms ? form.values.time_til_dormant_autodelete_ms * MS_DAY_CONVERSION : undefined, @@ -498,7 +495,8 @@ export const TemplateScheduleForm: FC = ({ } label={Enable Dormancy Threshold} /> - = ({ inputProps={{ min: 0, step: "any" }} label="Time until dormant (days)" type="number" + /> */} + + + } + value={form.values.time_til_dormant_ms} + onChange={(v) => form.setFieldValue("time_til_dormant_ms", v)} + disabled={ + isSubmitting || !form.values.inactivity_cleanup_enabled + } /> From 09ddb8f1d59c7a4468fd5ad59dd478039a4da4d8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 9 May 2024 15:28:31 +0000 Subject: [PATCH 05/16] Fix parent updates --- .../DurationField/DurationField.stories.tsx | 8 +- .../DurationField/DurationField.tsx | 128 +++++++++++------- .../TemplateScheduleForm.tsx | 21 +-- 3 files changed, 84 insertions(+), 73 deletions(-) diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx index 20db55e6551f5..92c9278f3a529 100644 --- a/site/src/components/DurationField/DurationField.stories.tsx +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -9,7 +9,7 @@ const meta: Meta = { label: "Duration", }, render: function RenderComponent(args) { - const [value, setValue] = useState(args.value); + const [value, setValue] = useState(args.value); return ( = { export default meta; type Story = StoryObj; -export const Empty: Story = { - args: { - value: undefined, - }, -}; - export const Hours: Story = { args: { value: hoursToMs(16), diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index 1b6d03de47008..ed88ef6a1b94c 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -3,30 +3,39 @@ import FormHelperText from "@mui/material/FormHelperText"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; import TextField from "@mui/material/TextField"; -import { type ReactNode, useState, type FC } from "react"; +import { type ReactNode, useState, type FC, useEffect } from "react"; type TimeUnit = "days" | "hours"; -// Value should be in milliseconds or undefined. Undefined means no value. -type DurationValue = number | undefined; - type DurationFieldProps = { label: string; - value: DurationValue; + // Value is in ms + value: number; disabled?: boolean; helperText?: ReactNode; - onChange: (value: DurationValue) => void; + 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; }; export const DurationField: FC = (props) => { - const { label, value, disabled, helperText, onChange } = props; - const [timeUnit, setTimeUnit] = useState(() => { - if (!value) { - return "hours"; - } + const { label, value: parentValue, disabled, helperText, onChange } = props; + const [state, setState] = useState(() => initState(parentValue)); + const currentDurationInMs = durationInMs( + state.durationFieldValue, + state.unit, + ); - return Number.isInteger(durationToDays(value)) ? "days" : "hours"; - }); + useEffect(() => { + if (parentValue !== currentDurationInMs) { + setState(initState(parentValue)); + } + }, [currentDurationInMs, parentValue]); return (
@@ -41,32 +50,22 @@ export const DurationField: FC = (props) => { css={{ maxWidth: 160 }} label={label} disabled={disabled} - value={ - !value - ? "" - : timeUnit === "hours" - ? durationToHours(value) - : durationToDays(value) - } + value={state.durationFieldValue} onChange={(e) => { - if (e.target.value === "") { - onChange(undefined); - } - - let value = parseInt(e.target.value); - - if (Number.isNaN(value)) { - return; - } + const durationFieldValue = e.currentTarget.value; - // Avoid negative values - value = Math.abs(value); + setState((state) => ({ + ...state, + durationFieldValue, + })); - onChange( - timeUnit === "hours" - ? hoursToDuration(value) - : daysToDuration(value), + const newDurationInMs = durationInMs( + durationFieldValue, + state.unit, ); + if (newDurationInMs !== parentValue) { + onChange(newDurationInMs); + } }} inputProps={{ step: 1, @@ -75,22 +74,29 @@ export const DurationField: FC = (props) => { { @@ -107,7 +103,9 @@ export const DurationField: FC = (props) => {
- {helperText && {helperText}} + {helperText && ( + {helperText} + )} ); }; @@ -127,6 +125,11 @@ function initState(value: number): State { function durationInMs(durationFieldValue: string, unit: TimeUnit): number { const durationInMs = parseInt(durationFieldValue); + + if (Number.isNaN(durationInMs)) { + return 0; + } + return unit === "hours" ? hoursToDuration(durationInMs) : daysToDuration(durationInMs); diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 018f470401d26..03797768f7f6e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -497,12 +497,14 @@ export const TemplateScheduleForm: FC = ({ /> + ), + })} label="Time until dormant" - helperText={ - - } value={form.values.time_til_dormant_ms ?? 0} onChange={(v) => form.setFieldValue("time_til_dormant_ms", v)} disabled={ 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) { From b3042c6ca7394a01d6536fefb8f170ac9496223a Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 10 May 2024 12:28:40 +0000 Subject: [PATCH 08/16] Use valueMs to make the value is in miliseconds --- .../DurationField/DurationField.stories.tsx | 8 ++--- .../DurationField/DurationField.tsx | 33 ++++++++++--------- .../TemplateScheduleForm.tsx | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx index 92c9278f3a529..689e3f0fabc18 100644 --- a/site/src/components/DurationField/DurationField.stories.tsx +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -9,11 +9,11 @@ const meta: Meta = { label: "Duration", }, render: function RenderComponent(args) { - const [value, setValue] = useState(args.value); + const [value, setValue] = useState(args.valueMs); return ( setValue(value)} /> ); @@ -25,13 +25,13 @@ type Story = StoryObj; export const Hours: Story = { args: { - value: hoursToMs(16), + valueMs: hoursToMs(16), }, }; export const Days: Story = { args: { - value: daysToMs(2), + valueMs: daysToMs(2), }, }; diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index c82d9d30c371d..68c72b47f8da7 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -12,8 +12,7 @@ import { } from "utils/time"; type DurationFieldProps = Omit & { - // Value is in ms - value: number; + valueMs: number; onChange: (value: number) => void; }; @@ -25,18 +24,20 @@ type State = { }; export const DurationField: FC = (props) => { - const { value: parentValue, onChange, helperText, ...textFieldProps } = props; - const [state, setState] = useState(() => initState(parentValue)); - const currentDurationInMs = durationInMs( - state.durationFieldValue, - state.unit, - ); + const { + valueMs: parentValueMs, + onChange, + helperText, + ...textFieldProps + } = props; + const [state, setState] = useState(() => initState(parentValueMs)); + const currentDurationMs = durationInMs(state.durationFieldValue, state.unit); useEffect(() => { - if (parentValue !== currentDurationInMs) { - setState(initState(parentValue)); + if (parentValueMs !== currentDurationMs) { + setState(initState(parentValueMs)); } - }, [currentDurationInMs, parentValue]); + }, [currentDurationMs, parentValueMs]); return (
@@ -63,7 +64,7 @@ export const DurationField: FC = (props) => { durationFieldValue, state.unit, ); - if (newDurationInMs !== parentValue) { + if (newDurationInMs !== parentValueMs) { onChange(newDurationInMs); } }} @@ -81,8 +82,8 @@ export const DurationField: FC = (props) => { unit, durationFieldValue: unit === "hours" - ? durationInHours(currentDurationInMs).toString() - : durationInDays(currentDurationInMs).toString(), + ? durationInHours(currentDurationMs).toString() + : durationInDays(currentDurationMs).toString(), })); }} inputProps={{ "aria-label": "Time unit" }} @@ -90,13 +91,13 @@ export const DurationField: FC = (props) => { > Hours Days diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 03797768f7f6e..cfe55bad9fa77 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -505,7 +505,7 @@ export const TemplateScheduleForm: FC = ({ ), })} label="Time until dormant" - value={form.values.time_til_dormant_ms ?? 0} + valueMs={form.values.time_til_dormant_ms ?? 0} onChange={(v) => form.setFieldValue("time_til_dormant_ms", v)} disabled={ isSubmitting || !form.values.inactivity_cleanup_enabled From e6101cf18f77cf04ac3371ed3cfb07db36f12d37 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 10 May 2024 12:43:10 +0000 Subject: [PATCH 09/16] Replace useState by useReducer --- .../DurationField/DurationField.tsx | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index 68c72b47f8da7..838723e654c09 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -3,7 +3,7 @@ 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 { useState, type FC, useEffect } from "react"; +import { type FC, useEffect, useReducer } from "react"; import { type TimeUnit, durationInDays, @@ -23,6 +23,49 @@ type State = { 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, @@ -30,12 +73,12 @@ export const DurationField: FC = (props) => { helperText, ...textFieldProps } = props; - const [state, setState] = useState(() => initState(parentValueMs)); + const [state, dispatch] = useReducer(reducer, initState(parentValueMs)); const currentDurationMs = durationInMs(state.durationFieldValue, state.unit); useEffect(() => { if (parentValueMs !== currentDurationMs) { - setState(initState(parentValueMs)); + dispatch({ type: "SYNC_WITH_PARENT", parentValueMs }); } }, [currentDurationMs, parentValueMs]); @@ -55,10 +98,10 @@ export const DurationField: FC = (props) => { onChange={(e) => { const durationFieldValue = e.currentTarget.value; - setState((state) => ({ - ...state, - durationFieldValue, - })); + dispatch({ + type: "CHANGE_DURATION_FIELD_VALUE", + fieldValue: durationFieldValue, + }); const newDurationInMs = durationInMs( durationFieldValue, @@ -78,23 +121,15 @@ export const DurationField: FC = (props) => { value={state.unit} onChange={(e) => { const unit = e.target.value as TimeUnit; - setState(() => ({ + dispatch({ + type: "CHANGE_TIME_UNIT", unit, - durationFieldValue: - unit === "hours" - ? durationInHours(currentDurationMs).toString() - : durationInDays(currentDurationMs).toString(), - })); + }); }} inputProps={{ "aria-label": "Time unit" }} IconComponent={KeyboardArrowDown} > - - Hours - + Hours Date: Fri, 10 May 2024 12:46:20 +0000 Subject: [PATCH 10/16] Add 10 to base int --- site/src/components/DurationField/DurationField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index 838723e654c09..85104dc0ec32f 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -160,7 +160,7 @@ function initState(value: number): State { } function durationInMs(durationFieldValue: string, unit: TimeUnit): number { - const durationInMs = parseInt(durationFieldValue); + const durationInMs = parseInt(durationFieldValue, 10); if (Number.isNaN(durationInMs)) { return 0; From 90364658dde439012fb00253045e3a372fc6ea7f Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 13 May 2024 15:19:19 +0000 Subject: [PATCH 11/16] Use a number mask --- site/src/components/DurationField/DurationField.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index 85104dc0ec32f..ba095b38e02d7 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -92,11 +92,10 @@ export const DurationField: FC = (props) => { > { - const durationFieldValue = e.currentTarget.value; + const durationFieldValue = intMask(e.currentTarget.value); dispatch({ type: "CHANGE_DURATION_FIELD_VALUE", @@ -159,6 +158,10 @@ function initState(value: number): State { }; } +function intMask(value: string): string { + return value.replace(/\D/g, ""); +} + function durationInMs(durationFieldValue: string, unit: TimeUnit): number { const durationInMs = parseInt(durationFieldValue, 10); From cac3a890f4ea5611a48d51dd2fa7212e3d6f16e4 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 13 May 2024 15:23:12 +0000 Subject: [PATCH 12/16] Make number input full width --- site/src/components/DurationField/DurationField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/DurationField/DurationField.tsx b/site/src/components/DurationField/DurationField.tsx index ba095b38e02d7..8e2dc752ba410 100644 --- a/site/src/components/DurationField/DurationField.tsx +++ b/site/src/components/DurationField/DurationField.tsx @@ -92,7 +92,7 @@ export const DurationField: FC = (props) => { > { const durationFieldValue = intMask(e.currentTarget.value); From d89ec94bfdd8df0a5908f7db3335b2a0be751873 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 13 May 2024 15:30:08 +0000 Subject: [PATCH 13/16] Add duration field to auto deletion --- .../TemplateSchedulePage/TTLHelperText.tsx | 4 +-- .../TemplateScheduleForm.tsx | 25 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index c3e2e0ef90844..b6695f4cdd95c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -101,8 +101,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 cfe55bad9fa77..7f6d2b9a148aa 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -50,7 +50,7 @@ const MS_HOUR_CONVERSION = 3600000; const MS_DAY_CONVERSION = 86400000; const FAILURE_CLEANUP_DEFAULT = 7; const INACTIVITY_CLEANUP_DEFAULT = 180 * MS_DAY_CONVERSION; -const DORMANT_AUTODELETION_DEFAULT = 30; +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. @@ -88,10 +88,7 @@ export const TemplateScheduleForm: FC = ({ ? template.failure_ttl_ms / MS_DAY_CONVERSION : 0, time_til_dormant_ms: template.time_til_dormant_ms, - time_til_dormant_autodelete_ms: allowAdvancedScheduling - ? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION - : 0, - + time_til_dormant_autodelete_ms: template.time_til_dormant_autodelete_ms, autostop_requirement_days_of_week: allowAdvancedScheduling ? convertAutostopRequirementDaysValue( template.autostop_requirement.days_of_week, @@ -213,10 +210,8 @@ export const TemplateScheduleForm: FC = ({ ? form.values.failure_ttl_ms * MS_DAY_CONVERSION : undefined, time_til_dormant_ms: form.values.time_til_dormant_ms, - time_til_dormant_autodelete_ms: form.values.time_til_dormant_autodelete_ms - ? form.values.time_til_dormant_autodelete_ms * MS_DAY_CONVERSION - : undefined, - + 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, @@ -226,7 +221,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, @@ -536,7 +530,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" /> From 0a93c9f486fcd692f65efdaf8b3c8bc3c499e289 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 13 May 2024 15:36:31 +0000 Subject: [PATCH 14/16] Apply duration field to failure clean up --- .../TemplateSchedulePage/TTLHelperText.tsx | 3 +-- .../TemplateScheduleForm.tsx | 21 +++++++------------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index b6695f4cdd95c..11f83f1e21a9c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -1,7 +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; @@ -62,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)}. ); }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 7f6d2b9a148aa..25986850a2335 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -48,7 +48,7 @@ import { const MS_HOUR_CONVERSION = 3600000; const MS_DAY_CONVERSION = 86400000; -const FAILURE_CLEANUP_DEFAULT = 7; +const FAILURE_CLEANUP_DEFAULT = 7 * MS_DAY_CONVERSION; const INACTIVITY_CLEANUP_DEFAULT = 180 * MS_DAY_CONVERSION; const DORMANT_AUTODELETION_DEFAULT = 30 * MS_DAY_CONVERSION; /** @@ -84,9 +84,7 @@ 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, + 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 @@ -206,9 +204,7 @@ 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, + 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, @@ -565,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" /> From 45a5eb5405bf3bac7c285161727fcc5a253ca3ae Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 17 May 2024 16:28:32 +0000 Subject: [PATCH 15/16] Add extra tests to storybook --- .../DurationField/DurationField.stories.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/site/src/components/DurationField/DurationField.stories.tsx b/site/src/components/DurationField/DurationField.stories.tsx index 689e3f0fabc18..32e3953f9b5c6 100644 --- a/site/src/components/DurationField/DurationField.stories.tsx +++ b/site/src/components/DurationField/DurationField.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within, userEvent } from "@storybook/test"; import { useState } from "react"; import { DurationField } from "./DurationField"; @@ -35,6 +36,47 @@ export const Days: Story = { }, }; +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; } From a6ff4263b703c9d325c6a0f6e9c4b31024988718 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 17 May 2024 18:15:51 +0000 Subject: [PATCH 16/16] Fix tests --- .../TemplateSchedulePage/TemplateSchedulePage.test.tsx | 2 +- .../TemplateSchedulePage/useWorkspacesToBeDeleted.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) 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/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) {