Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions frontend/app/(app)/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,13 +296,18 @@ describe("DashboardPage", () => {

const period = await screen.findByLabelText("Period");
const grace = screen.getByLabelText("Grace");
expect(period).toHaveValue(300);
expect(grace).toHaveValue(60);
expect(period).toHaveValue(5);
expect(grace).toHaveValue(1);

fireEvent.change(period, { target: { value: "600" } });
fireEvent.change(grace, { target: { value: "120" } });
expect(period).toHaveValue(600);
expect(grace).toHaveValue(120);
expect(screen.getAllByRole("button", { name: "Minutes" })[0]).toHaveAttribute(
"aria-pressed",
"true",
);

fireEvent.change(period, { target: { value: "10" } });
fireEvent.change(grace, { target: { value: "2" } });
expect(period).toHaveValue(10);
expect(grace).toHaveValue(2);
});

it("lets the user type an interval when creating an HTTP check", async () => {
Expand All @@ -314,9 +319,9 @@ describe("DashboardPage", () => {
fireEvent.click(await screen.findByRole("button", { name: /^http$/i }));

const interval = await screen.findByLabelText("Interval");
expect(interval).toHaveValue(60);
fireEvent.change(interval, { target: { value: "180" } });
expect(interval).toHaveValue(180);
expect(interval).toHaveValue(1);
fireEvent.change(interval, { target: { value: "3" } });
expect(interval).toHaveValue(3);
});

it("uses the SIGNAL creator floor when a SOLO collaborator creates a check", async () => {
Expand All @@ -329,7 +334,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);

await waitFor(() =>
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "60")
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "1")
);
});

Expand All @@ -343,7 +348,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);

await waitFor(() =>
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "300")
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "5")
);
});

Expand Down
55 changes: 48 additions & 7 deletions frontend/components/app/duration-slider-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,59 @@ describe("DurationSliderField", () => {
expect(screen.getByRole("status")).toHaveTextContent("1000");
});

it("lets the user type a value above a high minimum before blur", () => {
render(<Harness initialMs={300000} minMs={300000} maxMs={86400000} stepMs={1000} />);
it("does not offer minute or hour units for a timeout-scale range", () => {
render(<Harness />);

expect(screen.queryByRole("button", { name: "Minutes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Hours" })).not.toBeInTheDocument();
});

it("opens a 5-minute value in minutes and converts when the unit changes", () => {
render(
<Harness initialMs={300000} minMs={60000} maxMs={86400000} stepMs={1000} />,
);

const input = screen.getByLabelText("Timeout");
expect(input).toHaveValue(5);
expect(screen.getByRole("button", { name: "Minutes" })).toHaveAttribute(
"aria-pressed",
"true",
);

fireEvent.click(screen.getByRole("button", { name: "Seconds" }));
expect(input).toHaveValue(300);
expect(screen.getByRole("status")).toHaveTextContent("300000");

fireEvent.click(screen.getByRole("button", { name: "Hours" }));
expect(input).toHaveValue(300 / 3600);
expect(screen.getByRole("status")).toHaveTextContent("300000");
});

it("commits a value typed in minutes", () => {
render(
<Harness initialMs={300000} minMs={60000} maxMs={86400000} stepMs={1000} />,
);

fireEvent.change(input, { target: { value: "6" } });
expect(input).toHaveValue(6);
fireEvent.change(input, { target: { value: "600" } });
expect(input).toHaveValue(600);
const input = screen.getByLabelText("Timeout");
fireEvent.change(input, { target: { value: "10" } });
expect(screen.getByRole("status")).toHaveTextContent("600000");
});

it("lets the user type a value above a high minimum before blur", () => {
render(
<Harness initialMs={300000} minMs={300000} maxMs={86400000} stepMs={1000} />,
);

const input = screen.getByLabelText("Timeout");
expect(input).toHaveValue(5);

fireEvent.change(input, { target: { value: "1" } });
expect(input).toHaveValue(1);
expect(screen.getByRole("status")).toHaveTextContent("300000");
fireEvent.change(input, { target: { value: "10" } });
expect(input).toHaveValue(10);
fireEvent.blur(input);
expect(input).toHaveValue(600);
expect(input).toHaveValue(10);
expect(screen.getByRole("status")).toHaveTextContent("600000");
});
});
70 changes: 57 additions & 13 deletions frontend/components/app/duration-slider-field.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
availableDurationUnits,
durationUnitToMs,
DURATION_UNIT_LABEL,
msToDurationUnit,
nicestDurationUnit,
type DurationUnit,
} from "@/lib/duration-units";

function clampMs(ms: number, minMs: number, maxMs: number, stepMs: number) {
const snapped = Math.round(ms / stepMs) * stepMs;
Expand All @@ -27,16 +36,29 @@ export function DurationSliderField({
maxMs: number;
stepMs: number;
}) {
const units = availableDurationUnits(maxMs);
const [draft, setDraft] = useState<string | null>(null);
const [unit, setUnit] = useState<DurationUnit>(() =>
nicestDurationUnit(valueMs, units),
);
const selectedUnit = units.includes(unit) ? unit : units[0];
const displayValue = draft ?? msToDurationUnit(valueMs, selectedUnit);

function commitSeconds(raw: string) {
const seconds = parseFloat(raw);
function commit(raw: string) {
const amount = parseFloat(raw);
setDraft(null);
if (!Number.isFinite(seconds)) {
if (!Number.isFinite(amount)) {
onValueMsChange(clampMs(valueMs, minMs, maxMs, stepMs));
return;
}
onValueMsChange(clampMs(Math.round(seconds * 1000), minMs, maxMs, stepMs));
onValueMsChange(
clampMs(
Math.round(durationUnitToMs(amount, selectedUnit)),
minMs,
maxMs,
stepMs,
),
);
}

return (
Expand All @@ -47,33 +69,55 @@ export function DurationSliderField({
<Input
id={id}
type="number"
min={minMs / 1000}
max={maxMs / 1000}
step={stepMs / 1000}
min={msToDurationUnit(minMs, selectedUnit)}
max={msToDurationUnit(maxMs, selectedUnit)}
step={msToDurationUnit(stepMs, selectedUnit)}
className="h-8 w-20 text-right font-mono text-sm"
value={draft ?? valueMs / 1000}
value={displayValue}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
const seconds = parseFloat(raw);
if (!Number.isFinite(seconds)) {
const amount = parseFloat(raw);
if (!Number.isFinite(amount)) {
return;
}
const nextMs = Math.round(seconds * 1000);
const nextMs = Math.round(durationUnitToMs(amount, selectedUnit));
if (nextMs >= minMs && nextMs <= maxMs) {
onValueMsChange(clampMs(nextMs, minMs, maxMs, stepMs));
}
}}
onBlur={(event) => commitSeconds(event.target.value)}
onBlur={(event) => commit(event.target.value)}
/>
<span className="text-sm text-muted-foreground font-mono">s</span>
{units.length === 1 ? (
<span className="text-sm text-muted-foreground font-mono">s</span>
) : (
<div role="group" aria-label="Unit" className="flex gap-0.5">
{units.map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={option === selectedUnit ? "default" : "outline"}
aria-pressed={option === selectedUnit}
aria-label={DURATION_UNIT_LABEL[option]}
onClick={() => {
setDraft(null);
setUnit(option);
}}
>
{option}
</Button>
))}
</div>
)}
</div>
</div>
<Slider
min={minMs}
max={maxMs}
step={stepMs}
value={[valueMs]}
aria-label={`${label} range`}
onValueChange={(value) => {
setDraft(null);
const next = Array.isArray(value) ? value[0] : value;
Expand Down
60 changes: 27 additions & 33 deletions frontend/components/app/edit-check-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,17 +346,15 @@ export function EditCheckDialog({ open, onOpenChange, check, onSaved }: EditChec
</div>

{scheduleType === "simple" ? (
<div className="space-y-2">
<Label htmlFor="edit-check-period">Period (seconds)</Label>
<Input
id="edit-check-period"
type="number"
min={periodMinimum}
value={periodSeconds}
onChange={(e) => setPeriodSeconds(e.target.value)}
required
/>
</div>
<DurationSliderField
id="edit-check-period"
label="Period"
valueMs={parseInt(periodSeconds || String(periodMinimum), 10) * 1000}
onValueMsChange={(ms) => setPeriodSeconds(String(Math.round(ms / 1000)))}
minMs={periodMinimum * 1000}
maxMs={86400 * 1000}
stepMs={1000}
/>
) : (
<>
<div className="space-y-2">
Expand Down Expand Up @@ -393,17 +391,15 @@ export function EditCheckDialog({ open, onOpenChange, check, onSaved }: EditChec
</>
)}

<div className="space-y-2">
<Label htmlFor="edit-check-grace">Grace (seconds)</Label>
<Input
id="edit-check-grace"
type="number"
min={0}
value={graceSeconds}
onChange={(e) => setGraceSeconds(e.target.value)}
required
/>
</div>
<DurationSliderField
id="edit-check-grace"
label="Grace"
valueMs={parseInt(graceSeconds || "0", 10) * 1000}
onValueMsChange={(ms) => setGraceSeconds(String(Math.round(ms / 1000)))}
minMs={0}
maxMs={3600 * 1000}
stepMs={1000}
/>
</>
)}

Expand All @@ -421,17 +417,15 @@ export function EditCheckDialog({ open, onOpenChange, check, onSaved }: EditChec
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-check-interval">Interval (seconds)</Label>
<Input
id="edit-check-interval"
type="number"
min={intervalMinimum}
value={intervalSeconds}
onChange={(e) => setIntervalSeconds(e.target.value)}
required
/>
</div>
<DurationSliderField
id="edit-check-interval"
label="Interval"
valueMs={parseInt(intervalSeconds || String(intervalMinimum), 10) * 1000}
onValueMsChange={(ms) => setIntervalSeconds(String(Math.round(ms / 1000)))}
minMs={intervalMinimum * 1000}
maxMs={3600 * 1000}
stepMs={1000}
/>
<DurationSliderField
id="edit-check-timeout"
label="Timeout"
Expand Down
49 changes: 49 additions & 0 deletions frontend/lib/duration-units.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
availableDurationUnits,
durationUnitToMs,
msToDurationUnit,
nicestDurationUnit,
} from "./duration-units";

describe("availableDurationUnits", () => {
it("keeps timeout-scale ranges in seconds only", () => {
expect(availableDurationUnits(60_000)).toEqual(["s"]);
});

it("adds minutes when the range is longer than one minute", () => {
expect(availableDurationUnits(3_600_000)).toEqual(["s", "min", "hr"]);
});

it("adds minutes but not hours when the max is under an hour", () => {
expect(availableDurationUnits(3_599_000)).toEqual(["s", "min"]);
});
});

describe("nicestDurationUnit", () => {
const all = ["s", "min", "hr"] as const;

it("uses seconds for zero and for values that are not whole minutes", () => {
expect(nicestDurationUnit(0, [...all])).toBe("s");
expect(nicestDurationUnit(45_000, [...all])).toBe("s");
});

it("uses the coarsest whole unit", () => {
expect(nicestDurationUnit(5_000, [...all])).toBe("s");
expect(nicestDurationUnit(60_000, [...all])).toBe("min");
expect(nicestDurationUnit(300_000, [...all])).toBe("min");
expect(nicestDurationUnit(3_600_000, [...all])).toBe("hr");
});

it("does not pick a unit that is not offered", () => {
expect(nicestDurationUnit(3_600_000, ["s"])).toBe("s");
});
});

describe("duration unit conversion", () => {
it("converts between milliseconds and the selected unit", () => {
expect(msToDurationUnit(300_000, "min")).toBe(5);
expect(durationUnitToMs(5, "min")).toBe(300_000);
expect(durationUnitToMs(2, "hr")).toBe(7_200_000);
});
});
Loading
Loading