Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions site/src/components/DurationField/DurationField.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react";
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),
},
};

function hoursToMs(hours: number): number {
return hours * 60 * 60 * 1000;
}

function daysToMs(days: number): number {
return days * 24 * 60 * 60 * 1000;
}
184 changes: 184 additions & 0 deletions site/src/components/DurationField/DurationField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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;
}
Comment on lines +48 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just making sure: this is just a precaution/double-bookkeeping, right? Even though the UI has the disabled check to prevent the days unit from being selected at certain points, the reducer is also enforcing that the state update can't go through, in case the UI is set up wrong?

Copy link
Collaborator Author

@BrunoQuaresma BrunoQuaresma May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think it can be useful to have it in the reducer + disabled attribute. Wdyt?


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}
type="number"
css={{ maxWidth: 160 }}
value={state.durationFieldValue}
onChange={(e) => {
const durationFieldValue = 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>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a reason why props.error wasn't destructured from the props at the top of the component?

Copy link
Collaborator Author

@BrunoQuaresma BrunoQuaresma May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it is used by the TextField props as well.

)}
</div>
);
};

function initState(value: number): State {
const unit = suggestedTimeUnit(value);
const durationFieldValue =
unit === "hours"
? durationInHours(value).toString()
: durationInDays(value).toString();

return {
unit,
durationFieldValue,
};
}

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));
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { humanDuration } from "utils/time";

const hours = (h: number) => (h === 1 ? "hour" : "hours");
const days = (d: number) => (d === 1 ? "day" : "days");

Expand Down Expand Up @@ -79,8 +81,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>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,7 +49,7 @@ import {
const MS_HOUR_CONVERSION = 3600000;
const MS_DAY_CONVERSION = 86400000;
const FAILURE_CLEANUP_DEFAULT = 7;
const INACTIVITY_CLEANUP_DEFAULT = 180;
const INACTIVITY_CLEANUP_DEFAULT = 180 * MS_DAY_CONVERSION;
const DORMANT_AUTODELETION_DEFAULT = 30;
/**
* The default form field space is 4 but since this form is quite heavy I think
Expand Down Expand Up @@ -86,9 +87,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
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,
Expand Down Expand Up @@ -213,9 +212,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
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,
Expand Down Expand Up @@ -498,21 +495,21 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
}
label={<StackLabel>Enable Dormancy Threshold</StackLabel>}
/>
<TextField

<DurationField
{...getFieldHelpers("time_til_dormant_ms", {
helperText: (
<DormancyTTLHelperText
ttl={form.values.time_til_dormant_ms}
/>
),
})}
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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions site/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -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;
}