- Notifications
You must be signed in to change notification settings - Fork928
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
6d359fd
5ac0602
95e4f44
4a48102
09ddb8f
3427160
eef159c
b3042c6
e6101cf
c82fc8f
1b77488
9036465
cac3a89
d89ec94
0a93c9f
5a35e1a
45a5eb5
a6ff426
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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; | ||
} | ||
Comment on lines +48 to +53 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 CollaboratorAuthor
| ||
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, | ||
}} | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
/> | ||
<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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Was there a reason why CollaboratorAuthor
| ||
)} | ||
</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)); | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.