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
14 changes: 14 additions & 0 deletions frontend/app/(app)/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,20 @@ describe("DashboardPage", () => {
);
});

it("lets the user type a timeout when creating an HTTP check", async () => {
renderDashboard("SIGNAL");

fireEvent.click(
(await screen.findAllByRole("button", { name: /new check/i }))[0],
);
fireEvent.click(await screen.findByRole("button", { name: /^http$/i }));

const timeout = await screen.findByLabelText("Timeout");
expect(timeout).toHaveValue(5);
fireEvent.change(timeout, { target: { value: "8" } });
expect(timeout).toHaveValue(8);
});

it("uses the SIGNAL creator floor when a SOLO collaborator creates a check", async () => {
context.activeOrg.plan = "SIGNAL";
renderDashboard("SOLO");
Expand Down
19 changes: 10 additions & 9 deletions frontend/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "@/components/ui/select";
import { CircleGauge, Plus, Pause, Play } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { DurationSliderField } from "@/components/app/duration-slider-field";
import { formatDuration } from "@/lib/format";
import { usePollWhenVisible } from "@/lib/use-poll-when-visible";
import { CHECK_POLL_INTERVAL_MS } from "@/lib/polling";
Expand Down Expand Up @@ -460,15 +461,15 @@ function CreateCheckDialog({
value={[parseInt(intervalSeconds || String(floor), 10)]}
onValueChange={(v) => setIntervalSeconds(String(Array.isArray(v) ? v[0] : v))} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="check-timeout">Timeout</Label>
<span className="text-sm text-muted-foreground font-mono">{Math.round(parseInt(timeoutMs || "0", 10) / 1000)} s</span>
</div>
<Slider id="check-timeout" min={1000} max={60000} step={500}
value={[parseInt(timeoutMs || "1000", 10)]}
onValueChange={(v) => setTimeoutMs(String(Array.isArray(v) ? v[0] : v))} />
</div>
<DurationSliderField
id="check-timeout"
label="Timeout"
valueMs={parseInt(timeoutMs || "1000", 10)}
onValueMsChange={(ms) => setTimeoutMs(String(ms))}
minMs={1000}
maxMs={60000}
stepMs={500}
/>
{checkType === "HTTP" && (
<div className="space-y-2">
<Label htmlFor="check-expected-status">
Expand Down
60 changes: 60 additions & 0 deletions frontend/components/app/duration-slider-field.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DurationSliderField } from "./duration-slider-field";

function Harness({ initialMs = 5000 }: { initialMs?: number }) {
const [ms, setMs] = useState(initialMs);
return (
<>
<DurationSliderField
id="timeout"
label="Timeout"
valueMs={ms}
onValueMsChange={setMs}
minMs={1000}
maxMs={60000}
stepMs={500}
/>
<output>{ms}</output>
</>
);
}

describe("DurationSliderField", () => {
it("lets the user type seconds or drag the slider for the same millisecond value", () => {
render(<Harness />);

const input = screen.getByLabelText("Timeout");
const slider = screen.getByRole("slider", { hidden: true });

expect(input).toHaveValue(5);
expect(slider).toHaveValue("5000");

fireEvent.change(input, { target: { value: "12" } });
expect(input).toHaveValue(12);
expect(slider).toHaveValue("12000");
expect(screen.getByRole("status")).toHaveTextContent("12000");

fireEvent.change(slider, { target: { value: "3500" } });
expect(input).toHaveValue(3.5);
expect(slider).toHaveValue("3500");
expect(screen.getByRole("status")).toHaveTextContent("3500");
});

it("clamps a typed value to the slider range on blur", () => {
render(<Harness />);

const input = screen.getByLabelText("Timeout");

fireEvent.change(input, { target: { value: "90" } });
fireEvent.blur(input);
expect(input).toHaveValue(60);
expect(screen.getByRole("status")).toHaveTextContent("60000");

fireEvent.change(input, { target: { value: "0" } });
fireEvent.blur(input);
expect(input).toHaveValue(1);
expect(screen.getByRole("status")).toHaveTextContent("1000");
});
});
69 changes: 69 additions & 0 deletions frontend/components/app/duration-slider-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";

function clampMs(ms: number, minMs: number, maxMs: number, stepMs: number) {
const snapped = Math.round(ms / stepMs) * stepMs;
return Math.min(maxMs, Math.max(minMs, snapped));
}

export function DurationSliderField({
id,
label,
valueMs,
onValueMsChange,
minMs,
maxMs,
stepMs,
}: {
id: string;
label: string;
valueMs: number;
onValueMsChange: (ms: number) => void;
minMs: number;
maxMs: number;
stepMs: number;
}) {
function commitSeconds(raw: string) {
const seconds = parseFloat(raw);
if (!Number.isFinite(seconds)) {
onValueMsChange(clampMs(valueMs, minMs, maxMs, stepMs));
return;
}
onValueMsChange(clampMs(Math.round(seconds * 1000), minMs, maxMs, stepMs));
}

return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id}>{label}</Label>
<div className="flex items-center gap-1.5">
<Input
id={id}
type="number"
min={minMs / 1000}
max={maxMs / 1000}
step={stepMs / 1000}
className="h-8 w-16 text-right font-mono text-sm"
value={valueMs / 1000}
onChange={(event) => commitSeconds(event.target.value)}
onBlur={(event) => commitSeconds(event.target.value)}
/>
<span className="text-sm text-muted-foreground font-mono">s</span>
</div>
</div>
<Slider
min={minMs}
max={maxMs}
step={stepMs}
value={[valueMs]}
onValueChange={(value) => {
const next = Array.isArray(value) ? value[0] : value;
onValueMsChange(clampMs(next, minMs, maxMs, stepMs));
}}
/>
</div>
);
}
21 changes: 10 additions & 11 deletions frontend/components/app/edit-check-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useOrg } from "@/lib/org-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { DurationSliderField } from "@/components/app/duration-slider-field";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -431,17 +432,15 @@ export function EditCheckDialog({ open, onOpenChange, check, onSaved }: EditChec
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-check-timeout">Timeout (ms)</Label>
<Input
id="edit-check-timeout"
type="number"
min={1}
value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)}
required
/>
</div>
<DurationSliderField
id="edit-check-timeout"
label="Timeout"
valueMs={parseInt(timeoutMs, 10) || 5000}
onValueMsChange={(ms) => setTimeoutMs(String(ms))}
minMs={1000}
maxMs={60000}
stepMs={500}
/>
{type === "HTTP" && (
<>
<div className="space-y-2">
Expand Down
26 changes: 26 additions & 0 deletions frontend/test/edit-check-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ describe("EditCheckDialog", () => {
expect(screen.queryByLabelText(/period/i)).not.toBeInTheDocument();
});

it("lets the user type timeout in seconds or set it with a slider", () => {
renderDialog(HTTP);

const input = screen.getByLabelText("Timeout");
expect(input).toHaveValue(5);
expect(screen.getByRole("slider", { hidden: true })).toHaveValue("5000");

fireEvent.change(input, { target: { value: "10" } });
expect(input).toHaveValue(10);
expect(screen.getByRole("slider", { hidden: true })).toHaveValue("10000");
});

it("submits typed timeout seconds as milliseconds", async () => {
const { captured } = renderDialogCapturingVariables(HTTP);
await waitForPlan();

fireEvent.change(screen.getByLabelText("Timeout"), {
target: { value: "10" },
});
submitForm();

await waitFor(() => expect(captured).toHaveLength(1));
const input = captured[0].input as Record<string, unknown>;
expect(input.timeoutMs).toBe(10000);
});

it("uses the paid plan floor for heartbeat periods and active intervals", async () => {
const { unmount } = renderDialog(HEARTBEAT);
await waitFor(() =>
Expand Down
Loading