Skip to content

Commit 4af0f09

Browse files
fix(site): fix floating number on duration fields (coder#13209)
1 parent d8bb5a0 commit 4af0f09

File tree

8 files changed

+343
-53
lines changed

8 files changed

+343
-53
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, within, userEvent } from "@storybook/test";
3+
import { useState } from "react";
4+
import { DurationField } from "./DurationField";
5+
6+
const meta: Meta<typeof DurationField> = {
7+
title: "components/DurationField",
8+
component: DurationField,
9+
args: {
10+
label: "Duration",
11+
},
12+
render: function RenderComponent(args) {
13+
const [value, setValue] = useState<number>(args.valueMs);
14+
return (
15+
<DurationField
16+
{...args}
17+
valueMs={value}
18+
onChange={(value) => setValue(value)}
19+
/>
20+
);
21+
},
22+
};
23+
24+
export default meta;
25+
type Story = StoryObj<typeof DurationField>;
26+
27+
export const Hours: Story = {
28+
args: {
29+
valueMs: hoursToMs(16),
30+
},
31+
};
32+
33+
export const Days: Story = {
34+
args: {
35+
valueMs: daysToMs(2),
36+
},
37+
};
38+
39+
export const TypeOnlyNumbers: Story = {
40+
args: {
41+
valueMs: 0,
42+
},
43+
play: async ({ canvasElement }) => {
44+
const canvas = within(canvasElement);
45+
const input = canvas.getByLabelText("Duration");
46+
await userEvent.clear(input);
47+
await userEvent.type(input, "abcd_.?/48.0");
48+
await expect(input).toHaveValue("480");
49+
},
50+
};
51+
52+
export const ChangeUnit: Story = {
53+
args: {
54+
valueMs: daysToMs(2),
55+
},
56+
play: async ({ canvasElement }) => {
57+
const canvas = within(canvasElement);
58+
const input = canvas.getByLabelText("Duration");
59+
const unitDropdown = canvas.getByLabelText("Time unit");
60+
await userEvent.click(unitDropdown);
61+
const hoursOption = within(document.body).getByText("Hours");
62+
await userEvent.click(hoursOption);
63+
await expect(input).toHaveValue("48");
64+
},
65+
};
66+
67+
export const CantConvertToDays: Story = {
68+
args: {
69+
valueMs: hoursToMs(2),
70+
},
71+
play: async ({ canvasElement }) => {
72+
const canvas = within(canvasElement);
73+
const unitDropdown = canvas.getByLabelText("Time unit");
74+
await userEvent.click(unitDropdown);
75+
const daysOption = within(document.body).getByText("Days");
76+
await expect(daysOption).toHaveAttribute("aria-disabled", "true");
77+
},
78+
};
79+
80+
function hoursToMs(hours: number): number {
81+
return hours * 60 * 60 * 1000;
82+
}
83+
84+
function daysToMs(days: number): number {
85+
return days * 24 * 60 * 60 * 1000;
86+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
2+
import FormHelperText from "@mui/material/FormHelperText";
3+
import MenuItem from "@mui/material/MenuItem";
4+
import Select from "@mui/material/Select";
5+
import TextField, { type TextFieldProps } from "@mui/material/TextField";
6+
import { type FC, useEffect, useReducer } from "react";
7+
import {
8+
type TimeUnit,
9+
durationInDays,
10+
durationInHours,
11+
suggestedTimeUnit,
12+
} from "utils/time";
13+
14+
type DurationFieldProps = Omit<TextFieldProps, "value" | "onChange"> & {
15+
valueMs: number;
16+
onChange: (value: number) => void;
17+
};
18+
19+
type State = {
20+
unit: TimeUnit;
21+
// Handling empty values as strings in the input simplifies the process,
22+
// especially when a user clears the input field.
23+
durationFieldValue: string;
24+
};
25+
26+
type Action =
27+
| { type: "SYNC_WITH_PARENT"; parentValueMs: number }
28+
| { type: "CHANGE_DURATION_FIELD_VALUE"; fieldValue: string }
29+
| { type: "CHANGE_TIME_UNIT"; unit: TimeUnit };
30+
31+
const reducer = (state: State, action: Action): State => {
32+
switch (action.type) {
33+
case "SYNC_WITH_PARENT": {
34+
return initState(action.parentValueMs);
35+
}
36+
case "CHANGE_DURATION_FIELD_VALUE": {
37+
return {
38+
...state,
39+
durationFieldValue: action.fieldValue,
40+
};
41+
}
42+
case "CHANGE_TIME_UNIT": {
43+
const currentDurationMs = durationInMs(
44+
state.durationFieldValue,
45+
state.unit,
46+
);
47+
48+
if (
49+
action.unit === "days" &&
50+
!canConvertDurationToDays(currentDurationMs)
51+
) {
52+
return state;
53+
}
54+
55+
return {
56+
unit: action.unit,
57+
durationFieldValue:
58+
action.unit === "hours"
59+
? durationInHours(currentDurationMs).toString()
60+
: durationInDays(currentDurationMs).toString(),
61+
};
62+
}
63+
default: {
64+
return state;
65+
}
66+
}
67+
};
68+
69+
export const DurationField: FC<DurationFieldProps> = (props) => {
70+
const {
71+
valueMs: parentValueMs,
72+
onChange,
73+
helperText,
74+
...textFieldProps
75+
} = props;
76+
const [state, dispatch] = useReducer(reducer, initState(parentValueMs));
77+
const currentDurationMs = durationInMs(state.durationFieldValue, state.unit);
78+
79+
useEffect(() => {
80+
if (parentValueMs !== currentDurationMs) {
81+
dispatch({ type: "SYNC_WITH_PARENT", parentValueMs });
82+
}
83+
}, [currentDurationMs, parentValueMs]);
84+
85+
return (
86+
<div>
87+
<div
88+
css={{
89+
display: "flex",
90+
gap: 8,
91+
}}
92+
>
93+
<TextField
94+
{...textFieldProps}
95+
fullWidth
96+
value={state.durationFieldValue}
97+
onChange={(e) => {
98+
const durationFieldValue = intMask(e.currentTarget.value);
99+
100+
dispatch({
101+
type: "CHANGE_DURATION_FIELD_VALUE",
102+
fieldValue: durationFieldValue,
103+
});
104+
105+
const newDurationInMs = durationInMs(
106+
durationFieldValue,
107+
state.unit,
108+
);
109+
if (newDurationInMs !== parentValueMs) {
110+
onChange(newDurationInMs);
111+
}
112+
}}
113+
inputProps={{
114+
step: 1,
115+
}}
116+
/>
117+
<Select
118+
disabled={props.disabled}
119+
css={{ width: 120, "& .MuiSelect-icon": { padding: 2 } }}
120+
value={state.unit}
121+
onChange={(e) => {
122+
const unit = e.target.value as TimeUnit;
123+
dispatch({
124+
type: "CHANGE_TIME_UNIT",
125+
unit,
126+
});
127+
}}
128+
inputProps={{ "aria-label": "Time unit" }}
129+
IconComponent={KeyboardArrowDown}
130+
>
131+
<MenuItem value="hours">Hours</MenuItem>
132+
<MenuItem
133+
value="days"
134+
disabled={!canConvertDurationToDays(currentDurationMs)}
135+
>
136+
Days
137+
</MenuItem>
138+
</Select>
139+
</div>
140+
141+
{helperText && (
142+
<FormHelperText error={props.error}>{helperText}</FormHelperText>
143+
)}
144+
</div>
145+
);
146+
};
147+
148+
function initState(value: number): State {
149+
const unit = suggestedTimeUnit(value);
150+
const durationFieldValue =
151+
unit === "hours"
152+
? durationInHours(value).toString()
153+
: durationInDays(value).toString();
154+
155+
return {
156+
unit,
157+
durationFieldValue,
158+
};
159+
}
160+
161+
function intMask(value: string): string {
162+
return value.replace(/\D/g, "");
163+
}
164+
165+
function durationInMs(durationFieldValue: string, unit: TimeUnit): number {
166+
const durationInMs = parseInt(durationFieldValue, 10);
167+
168+
if (Number.isNaN(durationInMs)) {
169+
return 0;
170+
}
171+
172+
return unit === "hours"
173+
? hoursToDuration(durationInMs)
174+
: daysToDuration(durationInMs);
175+
}
176+
177+
function hoursToDuration(hours: number): number {
178+
return hours * 60 * 60 * 1000;
179+
}
180+
181+
function daysToDuration(days: number): number {
182+
return days * 24 * hoursToDuration(1);
183+
}
184+
185+
function canConvertDurationToDays(duration: number): boolean {
186+
return Number.isInteger(durationInDays(duration));
187+
}

site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { humanDuration } from "utils/time";
2+
13
const hours = (h: number) => (h === 1 ? "hour" : "hours");
2-
const days = (d: number) => (d === 1 ? "day" : "days");
34

45
export const DefaultTTLHelperText = (props: { ttl?: number }) => {
56
const { ttl = 0 } = props;
@@ -60,7 +61,7 @@ export const FailureTTLHelperText = (props: { ttl?: number }) => {
6061

6162
return (
6263
<span>
63-
Coder will attempt to stop failed workspaces after {ttl} {days(ttl)}.
64+
Coder will attempt to stop failed workspaces after {humanDuration(ttl)}.
6465
</span>
6566
);
6667
};
@@ -79,8 +80,8 @@ export const DormancyTTLHelperText = (props: { ttl?: number }) => {
7980

8081
return (
8182
<span>
82-
Coder will mark workspaces as dormant after {ttl} {days(ttl)} without user
83-
connections.
83+
Coder will mark workspaces as dormant after {humanDuration(ttl)} without
84+
user connections.
8485
</span>
8586
);
8687
};
@@ -99,8 +100,8 @@ export const DormancyAutoDeletionTTLHelperText = (props: { ttl?: number }) => {
99100

100101
return (
101102
<span>
102-
Coder will automatically delete dormant workspaces after {ttl} {days(ttl)}
103-
.
103+
Coder will automatically delete dormant workspaces after{" "}
104+
{humanDuration(ttl)}.
104105
</span>
105106
);
106107
};

0 commit comments

Comments
 (0)