node running, runtime version awareness
This commit is contained in:
318
web/src/components/common/SearchableSelect.tsx
Normal file
318
web/src/components/common/SearchableSelect.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user