diff --git a/frontend/app/(app)/dashboard/page.test.tsx b/frontend/app/(app)/dashboard/page.test.tsx
index 97c8445..2dc4db4 100644
--- a/frontend/app/(app)/dashboard/page.test.tsx
+++ b/frontend/app/(app)/dashboard/page.test.tsx
@@ -287,6 +287,38 @@ describe("DashboardPage", () => {
expect(timeout).toHaveValue(8);
});
+ it("lets the user type period and grace when creating a heartbeat check", async () => {
+ renderDashboard("SIGNAL");
+
+ fireEvent.click(
+ (await screen.findAllByRole("button", { name: /new check/i }))[0],
+ );
+
+ const period = await screen.findByLabelText("Period");
+ const grace = screen.getByLabelText("Grace");
+ expect(period).toHaveValue(300);
+ expect(grace).toHaveValue(60);
+
+ fireEvent.change(period, { target: { value: "600" } });
+ fireEvent.change(grace, { target: { value: "120" } });
+ expect(period).toHaveValue(600);
+ expect(grace).toHaveValue(120);
+ });
+
+ it("lets the user type an interval 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 interval = await screen.findByLabelText("Interval");
+ expect(interval).toHaveValue(60);
+ fireEvent.change(interval, { target: { value: "180" } });
+ expect(interval).toHaveValue(180);
+ });
+
it("uses the SIGNAL creator floor when a SOLO collaborator creates a check", async () => {
context.activeOrg.plan = "SIGNAL";
renderDashboard("SOLO");
@@ -297,7 +329,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);
await waitFor(() =>
- expect(document.getElementById("check-period")).toHaveAttribute("data-min", "60")
+ expect(screen.getByLabelText("Period")).toHaveAttribute("min", "60")
);
});
@@ -311,7 +343,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);
await waitFor(() =>
- expect(document.getElementById("check-period")).toHaveAttribute("data-min", "300")
+ expect(screen.getByLabelText("Period")).toHaveAttribute("min", "300")
);
});
diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx
index 2c06295..ff64504 100644
--- a/frontend/app/(app)/dashboard/page.tsx
+++ b/frontend/app/(app)/dashboard/page.tsx
@@ -33,9 +33,7 @@ import {
SelectValue,
} 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";
import { planIntervalFloor } from "@/lib/plan-limits";
@@ -361,24 +359,24 @@ function CreateCheckDialog({
{scheduleType === "simple" ? (
<>
-
-
-
- {formatDuration(parseInt(periodSeconds || "0", 10))}
-
-
setPeriodSeconds(String(Array.isArray(v) ? v[0] : v))} />
-
-
-
-
- {formatDuration(parseInt(graceSeconds || "0", 10))}
-
-
setGraceSeconds(String(Array.isArray(v) ? v[0] : v))} />
-
+ setPeriodSeconds(String(Math.round(ms / 1000)))}
+ minMs={floor * 1000}
+ maxMs={86400 * 1000}
+ stepMs={1000}
+ />
+ setGraceSeconds(String(Math.round(ms / 1000)))}
+ minMs={0}
+ maxMs={3600 * 1000}
+ stepMs={1000}
+ />
>
) : (
<>
@@ -408,15 +406,15 @@ function CreateCheckDialog({
-
-
-
- {formatDuration(parseInt(graceSeconds || "0", 10))}
-
-
setGraceSeconds(String(Array.isArray(v) ? v[0] : v))} />
-
+ setGraceSeconds(String(Math.round(ms / 1000)))}
+ minMs={0}
+ maxMs={3600 * 1000}
+ stepMs={1000}
+ />
{cronValid ? (
Next runs:
@@ -452,15 +450,15 @@ function CreateCheckDialog({
required
/>
-
-
-
- {formatDuration(parseInt(intervalSeconds || "0", 10))}
-
-
setIntervalSeconds(String(Array.isArray(v) ? v[0] : v))} />
-
+ setIntervalSeconds(String(Math.round(ms / 1000)))}
+ minMs={floor * 1000}
+ maxMs={3600 * 1000}
+ stepMs={1000}
+ />
@@ -12,9 +22,9 @@ function Harness({ initialMs = 5000 }: { initialMs?: number }) {
label="Timeout"
valueMs={ms}
onValueMsChange={setMs}
- minMs={1000}
- maxMs={60000}
- stepMs={500}
+ minMs={minMs}
+ maxMs={maxMs}
+ stepMs={stepMs}
/>
>
@@ -48,13 +58,30 @@ describe("DurationSliderField", () => {
const input = screen.getByLabelText("Timeout");
fireEvent.change(input, { target: { value: "90" } });
+ expect(input).toHaveValue(90);
fireEvent.blur(input);
expect(input).toHaveValue(60);
expect(screen.getByRole("status")).toHaveTextContent("60000");
fireEvent.change(input, { target: { value: "0" } });
+ expect(input).toHaveValue(0);
fireEvent.blur(input);
expect(input).toHaveValue(1);
expect(screen.getByRole("status")).toHaveTextContent("1000");
});
+
+ it("lets the user type a value above a high minimum before blur", () => {
+ render();
+
+ const input = screen.getByLabelText("Timeout");
+ expect(input).toHaveValue(300);
+
+ fireEvent.change(input, { target: { value: "6" } });
+ expect(input).toHaveValue(6);
+ fireEvent.change(input, { target: { value: "600" } });
+ expect(input).toHaveValue(600);
+ fireEvent.blur(input);
+ expect(input).toHaveValue(600);
+ expect(screen.getByRole("status")).toHaveTextContent("600000");
+ });
});
diff --git a/frontend/components/app/duration-slider-field.tsx b/frontend/components/app/duration-slider-field.tsx
index 57ff416..8dee1e0 100644
--- a/frontend/components/app/duration-slider-field.tsx
+++ b/frontend/components/app/duration-slider-field.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
@@ -26,8 +27,11 @@ export function DurationSliderField({
maxMs: number;
stepMs: number;
}) {
+ const [draft, setDraft] = useState(null);
+
function commitSeconds(raw: string) {
const seconds = parseFloat(raw);
+ setDraft(null);
if (!Number.isFinite(seconds)) {
onValueMsChange(clampMs(valueMs, minMs, maxMs, stepMs));
return;
@@ -46,9 +50,20 @@ export function DurationSliderField({
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)}
+ className="h-8 w-20 text-right font-mono text-sm"
+ value={draft ?? valueMs / 1000}
+ onChange={(event) => {
+ const raw = event.target.value;
+ setDraft(raw);
+ const seconds = parseFloat(raw);
+ if (!Number.isFinite(seconds)) {
+ return;
+ }
+ const nextMs = Math.round(seconds * 1000);
+ if (nextMs >= minMs && nextMs <= maxMs) {
+ onValueMsChange(clampMs(nextMs, minMs, maxMs, stepMs));
+ }
+ }}
onBlur={(event) => commitSeconds(event.target.value)}
/>
s
@@ -60,6 +75,7 @@ export function DurationSliderField({
step={stepMs}
value={[valueMs]}
onValueChange={(value) => {
+ setDraft(null);
const next = Array.isArray(value) ? value[0] : value;
onValueMsChange(clampMs(next, minMs, maxMs, stepMs));
}}