Skip to content

Commit 4af0f09

Browse files
fix(site): fix floating number on duration fields (#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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy