mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): fix floating number on duration fields (#13209)
This commit is contained in:
@@ -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>
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user