diff --git a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx index 24677a51a9..6d24f84c3b 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPageView.tsx @@ -16,7 +16,6 @@ import { user } from "api/queries/users"; import type * as TypesGen from "api/typesGenerated"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; -import { DurationField } from "components/DurationField/DurationField"; import { Link } from "components/Link/Link"; import { PaginationAmount } from "components/PaginationWidget/PaginationAmount"; import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"; @@ -58,6 +57,7 @@ import { DateRangePicker, type DateRangeValue, } from "./components/DateRangePicker/DateRangePicker"; +import { DurationField } from "./components/DurationField/DurationField"; import { InsightsContent } from "./components/InsightsContent"; import { LimitsTab } from "./components/LimitsTab"; import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel"; diff --git a/site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx b/site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx new file mode 100644 index 0000000000..040f2cdb7e --- /dev/null +++ b/site/src/pages/AgentsPage/components/DurationField/DurationField.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; +import { DurationField } from "./DurationField"; + +const meta: Meta = { + title: "pages/AgentsPage/DurationField", + component: DurationField, + args: { + label: "Duration", + }, + render: function RenderComponent(args) { + const [value, setValue] = useState(args.valueMs); + return ( + setValue(value)} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +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 unitTrigger = canvas.getByLabelText("Time unit"); + await userEvent.click(unitTrigger); + const hoursOption = await within(document.body).findByText("Hours"); + await userEvent.click(hoursOption); + await expect(input).toHaveValue("48"); + }, +}; + +export const ConvertSmallHoursToDays: Story = { + args: { + valueMs: hoursToMs(2), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = canvas.getByLabelText("Duration"); + await expect(input).toHaveValue("2"); + + const unitTrigger = canvas.getByLabelText("Time unit"); + await userEvent.click(unitTrigger); + + const daysOption = await within(document.body).findByText("Days"); + await userEvent.click(daysOption); + + await expect(input).toHaveValue("1"); + }, +}; + +export const WithError: Story = { + args: { + valueMs: hoursToMs(1), + error: true, + helperText: "Duration must be greater than zero.", + }, +}; + +function hoursToMs(hours: number): number { + return hours * 60 * 60 * 1000; +} + +function daysToMs(days: number): number { + return days * 24 * 60 * 60 * 1000; +} diff --git a/site/src/pages/AgentsPage/components/DurationField/DurationField.tsx b/site/src/pages/AgentsPage/components/DurationField/DurationField.tsx new file mode 100644 index 0000000000..78f34e2eef --- /dev/null +++ b/site/src/pages/AgentsPage/components/DurationField/DurationField.tsx @@ -0,0 +1,132 @@ +import dayjs from "dayjs"; +import { type FC, type ReactNode, useState } from "react"; +import { Input } from "#/components/Input/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import { cn } from "#/utils/cn"; +import { + durationInDays, + durationInHours, + suggestedTimeUnit, + type TimeUnit, +} from "#/utils/time"; + +type DurationFieldProps = { + valueMs: number; + onChange: (value: number) => void; + label?: string; + disabled?: boolean; + error?: boolean; + helperText?: ReactNode; + className?: string; +}; + +function toMs(value: string, unit: TimeUnit): number { + const n = Number.parseInt(value, 10); + if (Number.isNaN(n)) { + return 0; + } + return unit === "hours" + ? dayjs.duration(n, "hours").asMilliseconds() + : dayjs.duration(n, "days").asMilliseconds(); +} + +function toDisplayValue(ms: number, unit: TimeUnit): string { + return unit === "hours" + ? durationInHours(ms).toString() + : durationInDays(ms).toString(); +} + +export const DurationField: FC = ({ + valueMs, + onChange, + label, + disabled, + error, + helperText, + className, +}) => { + const [unit, setUnit] = useState(() => suggestedTimeUnit(valueMs)); + const [text, setText] = useState(() => toDisplayValue(valueMs, unit)); + + // Adjust local state when the parent value diverges from ours. + const localMs = toMs(text, unit); + if (valueMs !== localMs) { + const newUnit = suggestedTimeUnit(valueMs); + setUnit(newUnit); + setText(toDisplayValue(valueMs, newUnit)); + } + + const handleTextChange = (raw: string) => { + const digits = raw.replace(/\D/g, ""); + setText(digits); + + const ms = toMs(digits, unit); + if (ms !== valueMs) { + onChange(ms); + } + }; + + const handleUnitChange = (newUnit: TimeUnit) => { + const currentMs = toMs(text, unit); + + let newMs: number; + if (newUnit === "hours") { + newMs = currentMs; + } else { + const days = Math.ceil(durationInDays(currentMs)); + newMs = dayjs.duration(days, "days").asMilliseconds(); + } + + setUnit(newUnit); + setText(toDisplayValue(newMs, newUnit)); + + if (newMs !== valueMs) { + onChange(newMs); + } + }; + + return ( +
+
+ handleTextChange(e.currentTarget.value)} + aria-label={label} + aria-invalid={error} + disabled={disabled} + className="flex-1" + /> + +
+ + {helperText && ( +

+ {helperText} +

+ )} +
+ ); +};