node running, runtime version awareness

This commit is contained in:
2026-02-25 23:24:07 -06:00
parent e89b5991ec
commit 495b81236a
54 changed files with 4308 additions and 246 deletions

View File

@@ -0,0 +1,318 @@
import { useState, useRef, useEffect, useCallback, memo } from "react";
import { ChevronDown, X } from "lucide-react";
export interface SearchableSelectOption {
value: string | number;
label: string;
}
interface SearchableSelectProps {
/** Optional HTML id for the wrapper (useful for label htmlFor association) */
id?: string;
/** The available options to choose from */
options: SearchableSelectOption[];
/** Currently selected value should match one of the option values, or an "empty" sentinel (0, "") */
value: string | number;
/** Called when the user picks an option */
onChange: (value: string | number) => void;
/** Placeholder shown when nothing is selected */
placeholder?: string;
/** Disables the control (read-only grey appearance) */
disabled?: boolean;
/** Shows a red error border */
error?: boolean;
/** Additional CSS classes applied to the outermost wrapper */
className?: string;
}
/**
* A single-value select control with a built-in text search filter.
*
* Drop-in replacement for `<select>` supports keyboard navigation,
* click-outside-to-close, disabled & error styling, and a clear button.
*/
const SearchableSelect = memo(function SearchableSelect({
id,
options,
value,
onChange,
placeholder = "Select...",
disabled = false,
error = false,
className = "",
}: SearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// ----- derived data -----
const selectedOption = options.find((o) => o.value === value) ?? null;
const filtered = searchQuery
? options.filter((o) =>
o.label.toLowerCase().includes(searchQuery.toLowerCase()),
)
: options;
// Clamp highlight when the filtered list shrinks
const safeIndex =
highlightedIndex >= filtered.length ? -1 : highlightedIndex;
// ----- side-effects -----
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
setSearchQuery("");
setHighlightedIndex(-1);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Auto-focus the search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
// Scroll highlighted item into view
useEffect(() => {
if (safeIndex >= 0 && listRef.current) {
const items = listRef.current.children;
if (items[safeIndex]) {
(items[safeIndex] as HTMLElement).scrollIntoView({ block: "nearest" });
}
}
}, [safeIndex]);
// ----- handlers -----
const openDropdown = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setSearchQuery("");
setHighlightedIndex(-1);
}, [disabled]);
const closeDropdown = useCallback(() => {
setIsOpen(false);
setSearchQuery("");
setHighlightedIndex(-1);
}, []);
const selectOption = useCallback(
(opt: SearchableSelectOption) => {
onChange(opt.value);
closeDropdown();
},
[onChange, closeDropdown],
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Reset to the "empty" sentinel that matches the value's type
const empty: string | number = typeof value === "number" ? 0 : "";
onChange(empty);
closeDropdown();
},
[onChange, value, closeDropdown],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openDropdown();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filtered.length - 1 ? prev + 1 : 0,
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filtered.length - 1,
);
break;
case "Enter":
e.preventDefault();
if (safeIndex >= 0 && safeIndex < filtered.length) {
selectOption(filtered[safeIndex]);
}
break;
case "Escape":
e.preventDefault();
closeDropdown();
break;
case "Tab":
closeDropdown();
break;
}
},
[isOpen, filtered, safeIndex, openDropdown, closeDropdown, selectOption],
);
// ----- styles -----
const borderColor = error
? "border-red-500"
: isOpen
? "border-blue-500 ring-2 ring-blue-500"
: "border-gray-300 hover:border-gray-400";
const disabledStyles = disabled ? "bg-gray-100 cursor-not-allowed" : "bg-white cursor-pointer";
return (
<div
id={id}
ref={containerRef}
className={`relative ${className}`}
onKeyDown={handleKeyDown}
>
{/* Trigger button (shows selected value or placeholder) */}
<div
onClick={() => (isOpen ? closeDropdown() : openDropdown())}
tabIndex={disabled ? -1 : 0}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
className={`flex items-center justify-between w-full px-3 py-2 border rounded-lg text-sm focus:outline-none ${borderColor} ${disabledStyles}`}
>
<span
className={
selectedOption ? "text-gray-900 truncate" : "text-gray-400 truncate"
}
>
{selectedOption ? selectedOption.label : placeholder}
</span>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
{selectedOption && !disabled && (
<button
type="button"
onClick={handleClear}
tabIndex={-1}
className="text-gray-400 hover:text-gray-600 p-0.5 rounded"
aria-label="Clear selection"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<ChevronDown
className={`h-4 w-4 text-gray-400 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</div>
</div>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg overflow-hidden">
{/* Search input */}
<div className="p-2 border-b border-gray-200">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setHighlightedIndex(0);
}}
placeholder="Type to search..."
autoComplete="off"
className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Options list */}
<ul
ref={listRef}
role="listbox"
className="max-h-56 overflow-y-auto"
>
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-500 text-center">
No options found
</li>
) : (
filtered.map((option, index) => {
const isHighlighted = index === safeIndex;
const isSelected = option.value === value;
// Highlight matching substring in the label
const matchIndex = searchQuery
? option.label
.toLowerCase()
.indexOf(searchQuery.toLowerCase())
: -1;
return (
<li
key={String(option.value)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => {
e.preventDefault();
selectOption(option);
}}
onMouseEnter={() => setHighlightedIndex(index)}
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between ${
isHighlighted ? "bg-blue-50" : ""
} ${isSelected ? "font-medium text-blue-900" : "text-gray-900"}`}
>
<span className="truncate">
{searchQuery && matchIndex >= 0 ? (
<>
{option.label.slice(0, matchIndex)}
<span className="font-semibold">
{option.label.slice(
matchIndex,
matchIndex + searchQuery.length,
)}
</span>
{option.label.slice(matchIndex + searchQuery.length)}
</>
) : (
option.label
)}
</span>
{isSelected && (
<span className="text-blue-600 text-xs flex-shrink-0 ml-2">
</span>
)}
</li>
);
})
)}
</ul>
</div>
)}
</div>
);
});
export default SearchableSelect;

View File

@@ -8,6 +8,7 @@ import ParamSchemaForm, {
validateParamSchema,
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
import SearchableSelect from "@/components/common/SearchableSelect";
import type { RuleResponse } from "@/types/api";
import { labelToRef, extractLocalRef, combineRefs } from "@/lib/format-utils";
@@ -262,22 +263,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
>
Pack <span className="text-red-500">*</span>
</label>
<select
<SearchableSelect
id="pack"
value={packId}
onChange={(e) => setPackId(Number(e.target.value))}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack: any) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.pack ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a pack...</option>
{packs.map((pack: any) => (
<option key={pack.id} value={pack.id}>
{pack.label} ({pack.version})
</option>
))}
</select>
error={!!errors.pack}
/>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
@@ -407,22 +404,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
>
Trigger <span className="text-red-500">*</span>
</label>
<select
<SearchableSelect
id="trigger"
value={triggerId}
onChange={(e) => setTriggerId(Number(e.target.value))}
onChange={(v) => setTriggerId(Number(v))}
options={triggers.map((trigger: any) => ({
value: trigger.id,
label: `${trigger.ref} - ${trigger.label}`,
}))}
placeholder="Select a trigger..."
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.trigger ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a trigger...</option>
{triggers.map((trigger: any) => (
<option key={trigger.id} value={trigger.id}>
{trigger.ref} - {trigger.label}
</option>
))}
</select>
error={!!errors.trigger}
/>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
@@ -497,22 +490,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
>
Action <span className="text-red-500">*</span>
</label>
<select
<SearchableSelect
id="action"
value={actionId}
onChange={(e) => setActionId(Number(e.target.value))}
onChange={(v) => setActionId(Number(v))}
options={actions.map((action: any) => ({
value: action.id,
label: `${action.ref} - ${action.label}`,
}))}
placeholder="Select an action..."
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.action ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select an action...</option>
{actions.map((action: any) => (
<option key={action.id} value={action.id}>
{action.ref} - {action.label}
</option>
))}
</select>
error={!!errors.action}
/>
{errors.action && (
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
)}

View File

@@ -9,6 +9,7 @@ import {
combinePackLocalRef,
} from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import SearchableSelect from "@/components/common/SearchableSelect";
import { WebhooksService } from "@/api";
interface TriggerFormProps {
@@ -206,22 +207,18 @@ export default function TriggerForm({
>
Pack <span className="text-red-500">*</span>
</label>
<select
<SearchableSelect
id="pack"
value={packId}
onChange={(e) => setPackId(Number(e.target.value))}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack: any) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.pack ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a pack...</option>
{packs.map((pack: any) => (
<option key={pack.id} value={pack.id}>
{pack.label} ({pack.version})
</option>
))}
</select>
error={!!errors.pack}
/>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}

View File

@@ -10,6 +10,7 @@ import {
Palette,
Loader2,
} from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
import type {
WorkflowTask,
RetryConfig,
@@ -354,18 +355,15 @@ export default function TaskInspector({
<label className="block text-xs font-medium text-gray-700 mb-1">
Action Reference
</label>
<select
<SearchableSelect
value={task.action || ""}
onChange={(e) => update({ action: e.target.value })}
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">-- Select an action --</option>
{availableActions.map((action) => (
<option key={action.id} value={action.ref}>
{action.ref} ({action.label})
</option>
))}
</select>
onChange={(v) => update({ action: String(v) })}
options={availableActions.map((action) => ({
value: action.ref,
label: `${action.ref} (${action.label})`,
}))}
placeholder="-- Select an action --"
/>
{(fetchedAction?.description || selectedAction?.description) && (
<p className="text-[10px] text-gray-400 mt-1">
{fetchedAction?.description || selectedAction?.description}
@@ -657,27 +655,23 @@ export default function TaskInspector({
</div>
))}
{otherTaskNames.length > 0 && (
<select
<SearchableSelect
value=""
onChange={(e) => {
if (e.target.value) {
addDoTarget(ti, e.target.value);
e.target.value = "";
onChange={(v) => {
if (v) {
addDoTarget(ti, String(v));
}
}}
className="w-full px-2 py-1 border border-gray-300 rounded text-xs text-gray-500 focus:ring-1 focus:ring-blue-500 mt-0.5"
>
<option value="">+ Add target task...</option>
{otherTaskNames
options={otherTaskNames
.filter(
(name) => !(transition.do || []).includes(name),
)
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
.map((name) => ({
value: name,
label: name,
}))}
placeholder="+ Add target task..."
/>
)}
</div>

View File

@@ -12,6 +12,7 @@ import {
Zap,
Settings2,
} from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
import yaml from "js-yaml";
import type { WorkflowYamlDefinition } from "@/types/workflow";
import ActionPalette from "@/components/workflows/ActionPalette";
@@ -548,18 +549,16 @@ export default function WorkflowBuilderPage() {
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Pack selector */}
<select
<SearchableSelect
value={state.packRef}
onChange={(e) => updateMetadata({ packRef: e.target.value })}
className="px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 max-w-[140px]"
>
<option value="">Pack...</option>
{packs.map((pack) => (
<option key={pack.id} value={pack.ref}>
{pack.ref}
</option>
))}
</select>
onChange={(v) => updateMetadata({ packRef: String(v) })}
options={packs.map((pack) => ({
value: pack.ref,
label: pack.ref,
}))}
placeholder="Pack..."
className="max-w-[140px]"
/>
<span className="text-gray-400 text-lg font-light">/</span>