fix(site): fix floating number on duration fields (#13209)

This commit is contained in:
Bruno Quaresma
2024-05-17 15:26:00 -03:00
committed by GitHub
parent d8bb5a05db
commit 4af0f093ee
8 changed files with 343 additions and 53 deletions
@@ -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<typeof DurationField> = {
title: "components/DurationField",
component: DurationField,
args: {
label: "Duration",
},
render: function RenderComponent(args) {
const [value, setValue] = useState<number>(args.valueMs);
return (
<DurationField
{...args}
valueMs={value}
onChange={(value) => setValue(value)}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof DurationField>;
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;
}
@@ -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<TextFieldProps, "value" | "onChange"> & {
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<DurationFieldProps> = (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 (
<div>
<div
css={{
display: "flex",
gap: 8,
}}
>
<TextField
{...textFieldProps}
fullWidth
value={state.durationFieldValue}
onChange={(e) => {
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,
}}
/>
<Select
disabled={props.disabled}
css={{ width: 120, "& .MuiSelect-icon": { padding: 2 } }}
value={state.unit}
onChange={(e) => {
const unit = e.target.value as TimeUnit;
dispatch({
type: "CHANGE_TIME_UNIT",
unit,
});
}}
inputProps={{ "aria-label": "Time unit" }}
IconComponent={KeyboardArrowDown}
>
<MenuItem value="hours">Hours</MenuItem>
<MenuItem
value="days"
disabled={!canConvertDurationToDays(currentDurationMs)}
>
Days
</MenuItem>
</Select>
</div>
{helperText && (
<FormHelperText error={props.error}>{helperText}</FormHelperText>
)}
</div>
);
};
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));
}
@@ -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 (
<span>
Coder will attempt to stop failed workspaces after {ttl} {days(ttl)}.
Coder will attempt to stop failed workspaces after {humanDuration(ttl)}.
</span>
);
};
@@ -79,8 +80,8 @@ export const DormancyTTLHelperText = (props: { ttl?: number }) => {
return (
<span>
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.
</span>
);
};
@@ -99,8 +100,8 @@ export const DormancyAutoDeletionTTLHelperText = (props: { ttl?: number }) => {
return (
<span>
Coder will automatically delete dormant workspaces after {ttl} {days(ttl)}
.
Coder will automatically delete dormant workspaces after{" "}
{humanDuration(ttl)}.
</span>
);
};
@@ -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<TemplateScheduleForm> = ({
// 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<TemplateScheduleForm> = ({
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<TemplateScheduleForm> = ({
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<TemplateScheduleForm> = ({
}
label={<StackLabel>Enable Dormancy Threshold</StackLabel>}
/>
<TextField
<DurationField
{...getFieldHelpers("time_til_dormant_ms", {
helperText: (
<DormancyTTLHelperText
@@ -506,13 +494,12 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
),
})}
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"
/>
</Stack>
@@ -539,7 +526,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</StackLabel>
}
/>
<TextField
<DurationField
{...getFieldHelpers("time_til_dormant_autodelete_ms", {
helperText: (
<DormancyAutoDeletionTTLHelperText
@@ -547,14 +534,15 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
),
})}
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"
/>
</Stack>
@@ -573,24 +561,23 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
Enable Failure Cleanup
<StackLabelHelperText>
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.
</StackLabelHelperText>
</StackLabel>
}
/>
<TextField
<DurationField
{...getFieldHelpers("failure_ttl_ms", {
helperText: (
<FailureTTLHelperText ttl={form.values.failure_ttl_ms} />
),
})}
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"
/>
</Stack>
</FormFields>
@@ -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.",
);
});
@@ -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) {
@@ -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) {
+31
View File
@@ -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;
}