Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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.value);
return (
<DurationField
{...args}
value={value}
onChange={(value) => setValue(value)}
/>
);
},
};

export default meta;
type Story = StoryObj<typeof DurationField>;

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;
}
152 changes: 152 additions & 0 deletions site/src/components/DurationField/DurationField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 { useState, type FC, useEffect } from "react";
import {
type TimeUnit,
durationInDays,
durationInHours,
suggestedTimeUnit,
} from "utils/time";

type DurationFieldProps = Omit<TextFieldProps, "value" | "onChange"> & {
// Value is in ms
value: 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;
};

export const DurationField: FC<DurationFieldProps> = (props) => {
const { value: parentValue, onChange, helperText, ...textFieldProps } = props;
const [state, setState] = useState<State>(() => initState(parentValue));
const currentDurationInMs = durationInMs(
state.durationFieldValue,
state.unit,
);

useEffect(() => {
if (parentValue !== currentDurationInMs) {
setState(initState(parentValue));
}
}, [currentDurationInMs, parentValue]);

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;

setState((state) => ({
...state,
durationFieldValue,
}));

const newDurationInMs = durationInMs(
durationFieldValue,
state.unit,
);
if (newDurationInMs !== parentValue) {
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;
setState(() => ({
unit,
durationFieldValue:
unit === "hours"
? durationInHours(currentDurationInMs).toString()
: durationInDays(currentDurationInMs).toString(),
}));
}}
inputProps={{ "aria-label": "Time unit" }}
IconComponent={KeyboardArrowDown}
>
<MenuItem
value="hours"
disabled={!canConvertDurationToHours(currentDurationInMs)}
>
Hours
</MenuItem>
<MenuItem
value="days"
disabled={!canConvertDurationToDays(currentDurationInMs)}
>
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);

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));
}

function canConvertDurationToHours(duration: number): boolean {
return Number.isInteger(durationInHours(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"
value={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;
}