From bd4ced003141663e0a0c4fd8c5b2cb85acd3f27d Mon Sep 17 00:00:00 2001 From: Nihey Takizawa Date: Wed, 16 Sep 2026 10:45:51 -0300 Subject: [PATCH] Let users type or slide period, grace, and interval on create. Create used slider-only duration fields, so heartbeat and probe times could not be typed; they now share the timeout seconds-plus-slider control and keep out-of-range drafts until blur. --- frontend/app/(app)/dashboard/page.test.tsx | 36 ++++++++- frontend/app/(app)/dashboard/page.tsx | 74 +++++++++---------- .../app/duration-slider-field.test.tsx | 35 ++++++++- .../components/app/duration-slider-field.tsx | 22 +++++- 4 files changed, 120 insertions(+), 47 deletions(-) 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} /> {ms} @@ -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)); }}