Skip to content

Commit

Permalink
fix(site): fix floating number on duration fields (#13209)
Browse files Browse the repository at this point in the history
  • Loading branch information
BrunoQuaresma committed May 17, 2024
1 parent d8bb5a0 commit 4af0f09
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 53 deletions.
86 changes: 86 additions & 0 deletions site/src/components/DurationField/DurationField.stories.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
187 changes: 187 additions & 0 deletions site/src/components/DurationField/DurationField.tsx
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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>
);
};
Expand All @@ -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>
);
};
Expand All @@ -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>
);
};
Loading

0 comments on commit 4af0f09

Please sign in to comment.