mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): modernize DurationField for agent settings (#23532)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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<typeof DurationField> = {
|
||||
title: "pages/AgentsPage/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 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;
|
||||
}
|
||||
@@ -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<DurationFieldProps> = ({
|
||||
valueMs,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
}) => {
|
||||
const [unit, setUnit] = useState<TimeUnit>(() => 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 (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(e) => handleTextChange(e.currentTarget.value)}
|
||||
aria-label={label}
|
||||
aria-invalid={error}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={unit}
|
||||
onValueChange={(v: string) => handleUnitChange(v as TimeUnit)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]" aria-label="Time unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hours">Hours</SelectItem>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{helperText && (
|
||||
<p
|
||||
className={cn(
|
||||
"m-0 text-xs",
|
||||
error ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user