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) { 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