Skip to content

Commit 3cfde48

Browse files
authored
feat: expose all run options in the test run page (#2227)
* Implement a new primitive UI component for picking durations * Implement a new component to input run tags * Expose all run options in the test run page * Add subtle animations when adding/removing run tags in the test page * Add a new resource endpoint for fetching queues * Fetch usable queues for the selected task * Fix width display issue in the select component * Enable locking a run to a version from the test page * Disable entering max attemps <0 * Validate tags * Add recent runs popover * Only show latest version for development environments * Update run options when selecting a recent run * Rearrange the test page layout * Add subtle animation to the duration picker segments on focus * Improve queue selection dropdown styling * Fix disabled state issue for the SelectTrigger component * Disable version selection field for dev envs * Add usage hints next to the run option fields * Add machine preset to the run options list * Allow arbitrary queue inputs for v1 engine runs * Show truncated run ID instead of run numbers for recent runs Run numbers will soon get deprecated due to contention issues * Fix duplicate queue issue * Extract common elements across the standard and scheduled test task forms * Apply values from recent runs to scheduled tasks too * Add additional run options for scheduled tasks * Use a slightly smaller font size for run option labels * Disallow commas in the run tag input field * Switch to a custom icon for recent runs button * Flatten the load function test task result object * Avoid redefining machine presets, use zod schema instead * Fix ClockRotateLeftIcon jsx issues * Remove recent runs button tooltip as it causes nesting errors * Adjust the page layout to make it clear which task is currently selected * Inline the tab group with the copy/clear buttons
1 parent ba2e0cc commit 3cfde48

File tree

16 files changed

+1590
-399
lines changed

16 files changed

+1590
-399
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function ClockRotateLeftIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<path
5+
d="M4.01784 10.9999C4.27072 9.07068 5.21806 7.29972 6.68252 6.01856C8.14697 4.73741 10.0282 4.03389 11.9739 4.03971C13.9197 4.04553 15.7966 4.76028 17.2534 6.05017C18.7101 7.34006 19.6469 9.11666 19.8882 11.0474C20.1296 12.9781 19.659 14.9306 18.5645 16.5394C17.4701 18.1482 15.8268 19.303 13.9424 19.7876C12.0579 20.2722 10.0615 20.0534 8.32671 19.1721C6.59196 18.2909 5.23784 16.8076 4.51784 14.9999M4.01784 19.9999L4.01784 14.9999L9.01784 14.9999"
6+
stroke="currentColor"
7+
strokeWidth="2"
8+
strokeLinecap="round"
9+
strokeLinejoin="round"
10+
/>
11+
<path d="M12 12L12 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
12+
<path d="M12 12L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
13+
</svg>
14+
);
15+
}

apps/webapp/app/components/code/JSONEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
2121
showClearButton?: boolean;
2222
linterEnabled?: boolean;
2323
allowEmpty?: boolean;
24+
additionalActions?: React.ReactNode;
2425
}
2526

2627
const languages = {
@@ -64,6 +65,7 @@ export function JSONEditor(opts: JSONEditorProps) {
6465
showClearButton = true,
6566
linterEnabled,
6667
allowEmpty,
68+
additionalActions,
6769
} = {
6870
...defaultProps,
6971
...opts,
@@ -152,6 +154,7 @@ export function JSONEditor(opts: JSONEditorProps) {
152154
>
153155
{showButtons && (
154156
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
157+
{additionalActions && additionalActions}
155158
{showClearButton && (
156159
<Button
157160
type="button"
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { Input } from "~/components/primitives/Input";
2+
import { cn } from "~/utils/cn";
3+
import React, { useRef, useState, useEffect } from "react";
4+
import { Button } from "./Buttons";
5+
6+
export interface DurationPickerProps {
7+
id?: string; // used for the hidden input for form submission
8+
name?: string; // used for the hidden input for form submission
9+
defaultValueSeconds?: number;
10+
value?: number;
11+
onChange?: (totalSeconds: number) => void;
12+
variant?: "small" | "medium";
13+
showClearButton?: boolean;
14+
}
15+
16+
export function DurationPicker({
17+
name,
18+
defaultValueSeconds: defaultValue = 0,
19+
value: controlledValue,
20+
onChange,
21+
variant = "small",
22+
showClearButton = true,
23+
}: DurationPickerProps) {
24+
// Use controlled value if provided, otherwise use default
25+
const initialValue = controlledValue ?? defaultValue;
26+
27+
const defaultHours = Math.floor(initialValue / 3600);
28+
const defaultMinutes = Math.floor((initialValue % 3600) / 60);
29+
const defaultSeconds = initialValue % 60;
30+
31+
const [hours, setHours] = useState<number>(defaultHours);
32+
const [minutes, setMinutes] = useState<number>(defaultMinutes);
33+
const [seconds, setSeconds] = useState<number>(defaultSeconds);
34+
35+
const minuteRef = useRef<HTMLInputElement>(null);
36+
const hourRef = useRef<HTMLInputElement>(null);
37+
const secondRef = useRef<HTMLInputElement>(null);
38+
39+
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
40+
41+
const isEmpty = hours === 0 && minutes === 0 && seconds === 0;
42+
43+
// Sync internal state with external value changes
44+
useEffect(() => {
45+
if (controlledValue !== undefined && controlledValue !== totalSeconds) {
46+
const newHours = Math.floor(controlledValue / 3600);
47+
const newMinutes = Math.floor((controlledValue % 3600) / 60);
48+
const newSeconds = controlledValue % 60;
49+
50+
setHours(newHours);
51+
setMinutes(newMinutes);
52+
setSeconds(newSeconds);
53+
}
54+
}, [controlledValue]);
55+
56+
useEffect(() => {
57+
onChange?.(totalSeconds);
58+
}, [totalSeconds, onChange]);
59+
60+
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
61+
const value = parseInt(e.target.value) || 0;
62+
setHours(Math.max(0, value));
63+
};
64+
65+
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
66+
const value = parseInt(e.target.value) || 0;
67+
if (value >= 60) {
68+
setHours((prev) => prev + Math.floor(value / 60));
69+
setMinutes(value % 60);
70+
return;
71+
}
72+
73+
setMinutes(Math.max(0, Math.min(59, value)));
74+
};
75+
76+
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
77+
const value = parseInt(e.target.value) || 0;
78+
if (value >= 60) {
79+
setMinutes((prev) => {
80+
const newMinutes = prev + Math.floor(value / 60);
81+
if (newMinutes >= 60) {
82+
setHours((prevHours) => prevHours + Math.floor(newMinutes / 60));
83+
return newMinutes % 60;
84+
}
85+
return newMinutes;
86+
});
87+
setSeconds(value % 60);
88+
return;
89+
}
90+
91+
setSeconds(Math.max(0, Math.min(59, value)));
92+
};
93+
94+
const handleKeyDown = (
95+
e: React.KeyboardEvent<HTMLInputElement>,
96+
nextRef?: React.RefObject<HTMLInputElement>,
97+
prevRef?: React.RefObject<HTMLInputElement>
98+
) => {
99+
if (e.key === "Tab") {
100+
return;
101+
}
102+
103+
if (e.key === "ArrowRight" && nextRef) {
104+
e.preventDefault();
105+
nextRef.current?.focus();
106+
nextRef.current?.select();
107+
return;
108+
}
109+
110+
if (e.key === "ArrowLeft" && prevRef) {
111+
e.preventDefault();
112+
prevRef.current?.focus();
113+
prevRef.current?.select();
114+
return;
115+
}
116+
};
117+
118+
const clearDuration = () => {
119+
setHours(0);
120+
setMinutes(0);
121+
setSeconds(0);
122+
hourRef.current?.focus();
123+
};
124+
125+
return (
126+
<div className="flex items-center gap-3">
127+
<input type="hidden" name={name} value={totalSeconds} />
128+
129+
<div className="flex items-center gap-1">
130+
<div className="group flex items-center gap-1">
131+
<Input
132+
variant={variant}
133+
ref={hourRef}
134+
className={cn(
135+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
136+
isEmpty && "text-text-dimmed"
137+
)}
138+
value={hours.toString()}
139+
onChange={handleHoursChange}
140+
onKeyDown={(e) => handleKeyDown(e, minuteRef)}
141+
onFocus={(e) => e.target.select()}
142+
type="number"
143+
min={0}
144+
inputMode="numeric"
145+
/>
146+
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
147+
h
148+
</span>
149+
</div>
150+
<div className="group flex items-center gap-1">
151+
<Input
152+
variant={variant}
153+
ref={minuteRef}
154+
className={cn(
155+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
156+
isEmpty && "text-text-dimmed"
157+
)}
158+
value={minutes.toString()}
159+
onChange={handleMinutesChange}
160+
onKeyDown={(e) => handleKeyDown(e, secondRef, hourRef)}
161+
onFocus={(e) => e.target.select()}
162+
type="number"
163+
min={0}
164+
max={59}
165+
inputMode="numeric"
166+
/>
167+
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
168+
m
169+
</span>
170+
</div>
171+
<div className="group flex items-center gap-1">
172+
<Input
173+
variant={variant}
174+
ref={secondRef}
175+
className={cn(
176+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
177+
isEmpty && "text-text-dimmed"
178+
)}
179+
value={seconds.toString()}
180+
onChange={handleSecondsChange}
181+
onKeyDown={(e) => handleKeyDown(e, undefined, minuteRef)}
182+
onFocus={(e) => e.target.select()}
183+
type="number"
184+
min={0}
185+
max={59}
186+
inputMode="numeric"
187+
/>
188+
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
189+
s
190+
</span>
191+
</div>
192+
</div>
193+
194+
{showClearButton && (
195+
<Button type="button" variant={`tertiary/${variant}`} onClick={clearDuration}>
196+
Clear
197+
</Button>
198+
)}
199+
</div>
200+
);
201+
}

apps/webapp/app/components/primitives/Label.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
44

55
const variants = {
66
small: {
7-
text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1",
7+
text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1",
88
},
99
medium: {
1010
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",

apps/webapp/app/components/primitives/Select.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ export function SelectTrigger({
327327
className
328328
)}
329329
ref={ref}
330+
disabled={disabled}
330331
{...props}
331332
/>
332333
}
@@ -615,7 +616,7 @@ export function SelectPopover({
615616
unmountOnHide={unmountOnHide}
616617
className={cn(
617618
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
618-
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
619+
"min-w-[max(180px,var(--popover-anchor-width))]",
619620
"max-w-[min(480px,var(--popover-available-width))]",
620621
"max-h-[min(600px,var(--popover-available-height))]",
621622
"origin-[var(--popover-transform-origin)]",

apps/webapp/app/components/runs/v3/RunTag.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ import tagLeftPath from "./tag-left.svg";
33
import { SimpleTooltip } from "~/components/primitives/Tooltip";
44
import { Link } from "@remix-run/react";
55
import { cn } from "~/utils/cn";
6-
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
6+
import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react";
77

88
type Tag = string | { key: string; value: string };
99

10-
export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) {
10+
export function RunTag({
11+
tag,
12+
to,
13+
tooltip,
14+
action = { type: "copy" },
15+
}: {
16+
tag: string;
17+
action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void };
18+
to?: string;
19+
tooltip?: string;
20+
}) {
1121
const tagResult = useMemo(() => splitTag(tag), [tag]);
1222
const [isHovered, setIsHovered] = useState(false);
1323

@@ -57,7 +67,11 @@ export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip
5767
return (
5868
<div className="group relative inline-flex shrink-0" onMouseLeave={() => setIsHovered(false)}>
5969
{tagContent}
60-
<CopyButton textToCopy={tag} isHovered={isHovered} />
70+
{action.type === "delete" ? (
71+
<DeleteButton tag={tag} onDelete={action.onDelete} isHovered={isHovered} />
72+
) : (
73+
<CopyButton textToCopy={tag} isHovered={isHovered} />
74+
)}
6175
</div>
6276
);
6377
}
@@ -105,6 +119,45 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered:
105119
);
106120
}
107121

122+
function DeleteButton({
123+
tag,
124+
onDelete,
125+
isHovered,
126+
}: {
127+
tag: string;
128+
onDelete: (tag: string) => void;
129+
isHovered: boolean;
130+
}) {
131+
const handleDelete = useCallback(
132+
(e: React.MouseEvent) => {
133+
e.preventDefault();
134+
e.stopPropagation();
135+
onDelete(tag);
136+
},
137+
[tag, onDelete]
138+
);
139+
140+
return (
141+
<SimpleTooltip
142+
button={
143+
<span
144+
onClick={handleDelete}
145+
onMouseDown={(e) => e.stopPropagation()}
146+
className={cn(
147+
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750",
148+
isHovered ? "flex" : "hidden",
149+
"text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-rose-400"
150+
)}
151+
>
152+
<XIcon className="size-3.5" />
153+
</span>
154+
}
155+
content="Remove tag"
156+
disableHoverableContent
157+
/>
158+
);
159+
}
160+
108161
/** Takes a string and turns it into a tag
109162
*
110163
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value

0 commit comments

Comments
 (0)