FN-7300: hide local-only Quick Add node pickers
Hide the Quick Add node picker whenever local execution is the only available route. - Gate QuickEntryBox and InlineCreateCard node controls behind meaningful remote or multi-node choices. - Clear stale node overrides when the hidden picker no longer offers a valid selection. - Cover local-only and multi-node picker behavior in Quick Add component tests. - Add a patch changeset for the published Fusion package. Files changed: ...n-7300-hide-local-only-quick-add-node-button.md | 7 ++ .../dashboard/app/components/InlineCreateCard.tsx | 121 ++++++++++++--------- .../dashboard/app/components/QuickEntryBox.tsx | 102 ++++++++++------- .../components/__tests__/InlineCreateCard.test.tsx | 74 +++++++++++++ .../components/__tests__/QuickEntryBox.test.tsx | 89 +++++++++++++++ 5 files changed, 303 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-7300 Fusion-Task-Lineage: 83ef956f-7993-4f58-8497-49da15509c08 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Hide Quick Add node pickers when only local execution is available.
|
||||||
|
category: fix
|
||||||
|
dev: QuickEntryBox and InlineCreateCard now clear hidden stale node overrides before create submission.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import "./InlineCreateCard.css";
|
import "./InlineCreateCard.css";
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Brain, Link, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react";
|
import { Brain, Link, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react";
|
||||||
@@ -75,6 +75,14 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
|
||||||
|
/*
|
||||||
|
FNXC:QuickAddNodeRouting 2026-06-30-00:00:
|
||||||
|
Local-only inline quick-create should not show a Node button because the project-default route already means local execution. Keep the picker only when a remote or second registered node makes routing a real choice.
|
||||||
|
*/
|
||||||
|
return nodes.length > 1 || nodes.some((node) => node.type !== "local");
|
||||||
|
}
|
||||||
|
|
||||||
export function InlineCreateCard({
|
export function InlineCreateCard({
|
||||||
tasks,
|
tasks,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -103,6 +111,9 @@ export function InlineCreateCard({
|
|||||||
const [showPresets, setShowPresets] = useState(false);
|
const [showPresets, setShowPresets] = useState(false);
|
||||||
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
||||||
const { nodes } = useNodes();
|
const { nodes } = useNodes();
|
||||||
|
const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]);
|
||||||
|
const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||||||
|
const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined;
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
||||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||||||
@@ -182,6 +193,15 @@ export function InlineCreateCard({
|
|||||||
if (!showDeps) setDepSearch("");
|
if (!showDeps) setDepSearch("");
|
||||||
}, [showDeps]);
|
}, [showDeps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodeId(undefined);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
}, [nodeId, selectedNode, shouldShowNodePicker]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAgentPicker) return;
|
if (!showAgentPicker) return;
|
||||||
|
|
||||||
@@ -482,7 +502,7 @@ export function InlineCreateCard({
|
|||||||
*/
|
*/
|
||||||
enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
|
enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
|
||||||
priority,
|
priority,
|
||||||
nodeId,
|
nodeId: effectiveNodeId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -497,7 +517,7 @@ export function InlineCreateCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await submitTask(input);
|
await submitTask(input);
|
||||||
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, optionalSteps.length, enabledOptionalStepIds, priority, nodeId, projectId, addToast, submitTask]);
|
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]);
|
||||||
|
|
||||||
const handleDuplicateProceed = useCallback(async () => {
|
const handleDuplicateProceed = useCallback(async () => {
|
||||||
const matches = duplicateMatches;
|
const matches = duplicateMatches;
|
||||||
@@ -627,7 +647,6 @@ export function InlineCreateCard({
|
|||||||
|
|
||||||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||||||
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
|
||||||
|
|
||||||
const handleExecutorChange = useCallback((value: string) => {
|
const handleExecutorChange = useCallback((value: string) => {
|
||||||
const next = parseModelSelection(value);
|
const next = parseModelSelection(value);
|
||||||
@@ -937,59 +956,61 @@ export function InlineCreateCard({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
{shouldShowNodePicker && (
|
||||||
<button
|
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-sm dep-trigger"
|
type="button"
|
||||||
data-testid="inline-create-node-button"
|
className="btn btn-sm dep-trigger"
|
||||||
onClick={() => {
|
data-testid="inline-create-node-button"
|
||||||
setShowNodePicker((prev) => {
|
onClick={() => {
|
||||||
const next = !prev;
|
setShowNodePicker((prev) => {
|
||||||
if (next) {
|
const next = !prev;
|
||||||
setShowDeps(false);
|
if (next) {
|
||||||
setShowAgentPicker(false);
|
setShowDeps(false);
|
||||||
setIsModelModalOpen(false);
|
setShowAgentPicker(false);
|
||||||
setShowPresets(false);
|
setIsModelModalOpen(false);
|
||||||
}
|
setShowPresets(false);
|
||||||
return next;
|
}
|
||||||
});
|
return next;
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
>
|
||||||
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
|
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||||
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
|
||||||
</button>
|
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
||||||
{showNodePicker && (
|
</button>
|
||||||
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
{showNodePicker && (
|
||||||
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
|
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||||
<button
|
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
|
||||||
type="button"
|
|
||||||
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setNodeId(undefined);
|
|
||||||
setShowNodePicker(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
|
|
||||||
</button>
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<button
|
<button
|
||||||
key={node.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
|
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNodeId(node.id);
|
setNodeId(undefined);
|
||||||
setShowNodePicker(false);
|
setShowNodePicker(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NodeHealthDot status={node.status} />
|
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
|
||||||
<span className="dep-dropdown-title">{node.name}</span>
|
|
||||||
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
{nodes.map((node) => (
|
||||||
</div>
|
<button
|
||||||
)}
|
key={node.id}
|
||||||
</div>
|
type="button"
|
||||||
|
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setNodeId(node.id);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeHealthDot status={node.status} />
|
||||||
|
<span className="dep-dropdown-title">{node.name}</span>
|
||||||
|
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
||||||
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||||
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition } from "../api";
|
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api";
|
||||||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
|
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
|
||||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
||||||
@@ -125,6 +125,14 @@ function resolveQuickAddWorkflowId(
|
|||||||
return workflowOptions[0]?.id ?? null;
|
return workflowOptions[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
|
||||||
|
/*
|
||||||
|
FNXC:QuickAddNodeRouting 2026-06-30-00:00:
|
||||||
|
Local-only projects should not show a Node button because the project-default route already means local execution. Keep the picker only when a remote or second registered node makes routing a real choice.
|
||||||
|
*/
|
||||||
|
return nodes.length > 1 || nodes.some((node) => node.type !== "local");
|
||||||
|
}
|
||||||
|
|
||||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [description, setDescription] = useState(() => {
|
const [description, setDescription] = useState(() => {
|
||||||
@@ -217,6 +225,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||||
const submitInFlightRef = useRef(false);
|
const submitInFlightRef = useRef(false);
|
||||||
const { nodes } = useNodes();
|
const { nodes } = useNodes();
|
||||||
|
const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]);
|
||||||
|
const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||||||
|
const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined;
|
||||||
// AI Refinement state
|
// AI Refinement state
|
||||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||||
const [isRefining, setIsRefining] = useState(false);
|
const [isRefining, setIsRefining] = useState(false);
|
||||||
@@ -224,6 +235,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const refineMenuPortalRef = useRef<HTMLDivElement>(null);
|
const refineMenuPortalRef = useRef<HTMLDivElement>(null);
|
||||||
const [refineMenuPosition, setRefineMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
const [refineMenuPosition, setRefineMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodeId(undefined);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
|
}, [nodeId, selectedNode, shouldShowNodePicker]);
|
||||||
|
|
||||||
// Use parent-provided favorites when available, otherwise internal state
|
// Use parent-provided favorites when available, otherwise internal state
|
||||||
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
|
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
|
||||||
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
|
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
|
||||||
@@ -684,7 +705,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
...(isFastMode ? { executionMode: "fast" } : {}),
|
...(isFastMode ? { executionMode: "fast" } : {}),
|
||||||
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
|
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
|
||||||
priority,
|
priority,
|
||||||
nodeId,
|
nodeId: effectiveNodeId,
|
||||||
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
|
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
|
||||||
});
|
});
|
||||||
if (createdTask && pendingImages.length > 0) {
|
if (createdTask && pendingImages.length > 0) {
|
||||||
@@ -730,7 +751,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
settings,
|
settings,
|
||||||
githubTrackingOverride,
|
githubTrackingOverride,
|
||||||
priority,
|
priority,
|
||||||
nodeId,
|
effectiveNodeId,
|
||||||
pendingImages,
|
pendingImages,
|
||||||
projectId,
|
projectId,
|
||||||
addToast,
|
addToast,
|
||||||
@@ -1582,7 +1603,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
|
|
||||||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||||||
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
|
||||||
const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
|
const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
|
||||||
const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault;
|
const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault;
|
||||||
const githubToggleLabel = effectiveGithubTracking
|
const githubToggleLabel = effectiveGithubTracking
|
||||||
@@ -2069,43 +2089,45 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
{modelMenuLabel}
|
{modelMenuLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
{shouldShowNodePicker && (
|
||||||
<button
|
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||||
type="button"
|
<button
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
type="button"
|
||||||
className="btn btn-sm dep-trigger"
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
data-testid="quick-entry-node-button"
|
className="btn btn-sm dep-trigger"
|
||||||
onClick={() => {
|
data-testid="quick-entry-node-button"
|
||||||
setShowDeps(false);
|
onClick={() => {
|
||||||
setShowAgentPicker(false);
|
setShowDeps(false);
|
||||||
setAgentPickerPosition(null);
|
setShowAgentPicker(false);
|
||||||
setIsModelMenuOpen(false);
|
setAgentPickerPosition(null);
|
||||||
setModelMenuPosition(null);
|
setIsModelMenuOpen(false);
|
||||||
setActiveModelSubmenu(null);
|
setModelMenuPosition(null);
|
||||||
setShowPriorityPicker(false);
|
setActiveModelSubmenu(null);
|
||||||
setPriorityPickerPosition(null);
|
setShowPriorityPicker(false);
|
||||||
setShowNodePicker((prev) => {
|
setPriorityPickerPosition(null);
|
||||||
const next = !prev;
|
setShowNodePicker((prev) => {
|
||||||
if (next) {
|
const next = !prev;
|
||||||
updateNodePickerPosition();
|
if (next) {
|
||||||
} else {
|
updateNodePickerPosition();
|
||||||
setNodePickerPosition(null);
|
} else {
|
||||||
}
|
setNodePickerPosition(null);
|
||||||
return next;
|
}
|
||||||
});
|
return next;
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
>
|
||||||
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
|
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||||
{selectedNode && (
|
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
|
||||||
<span className="quick-entry-node-status">
|
{selectedNode && (
|
||||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
<span className="quick-entry-node-status">
|
||||||
</span>
|
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||||
)}
|
</span>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNodePicker && portalRoot && nodePickerPosition && createPortal(
|
{shouldShowNodePicker && showNodePicker && portalRoot && nodePickerPosition && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={nodePickerPortalRef}
|
ref={nodePickerPortalRef}
|
||||||
className="dep-dropdown node-picker-dropdown node-picker-dropdown--portal"
|
className="dep-dropdown node-picker-dropdown node-picker-dropdown--portal"
|
||||||
|
|||||||
@@ -185,6 +185,19 @@ function createMockTask(overrides: Partial<Task> = {}): Task {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockInlineNodes(nodes: Array<{ id: string; name: string; status: "online" | "offline" | "connecting" | "error"; type: "local" | "remote" }>) {
|
||||||
|
vi.mocked(useNodes).mockReturnValue({
|
||||||
|
nodes: nodes.map((node) => ({ ...node, createdAt: "", updatedAt: "" })),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
unregister: vi.fn(),
|
||||||
|
healthCheck: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderCard(
|
function renderCard(
|
||||||
tasks: Task[] = [],
|
tasks: Task[] = [],
|
||||||
overrides: Partial<ComponentProps<typeof InlineCreateCard>> = {},
|
overrides: Partial<ComponentProps<typeof InlineCreateCard>> = {},
|
||||||
@@ -1635,6 +1648,67 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
|||||||
|
|
||||||
|
|
||||||
describe("InlineCreateCard node override", () => {
|
describe("InlineCreateCard node override", () => {
|
||||||
|
it("hides the node picker affordance for local-only inline quick-create without shells", () => {
|
||||||
|
mockInlineNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
]);
|
||||||
|
renderCard();
|
||||||
|
expandCard();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".inline-create-card .node-trigger-wrap")).toBeNull();
|
||||||
|
expect(document.querySelector(".node-picker-dropdown")).toBeNull();
|
||||||
|
expect(screen.queryByText("Select execution node")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the node picker when inline quick-create has no registered nodes", () => {
|
||||||
|
mockInlineNodes([]);
|
||||||
|
renderCard();
|
||||||
|
expandCard();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".inline-create-card .node-trigger-wrap")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the node picker when inline quick-create has a remote node choice", () => {
|
||||||
|
mockInlineNodes([
|
||||||
|
{ id: "remote", name: "Remote Only", status: "online", type: "remote" },
|
||||||
|
]);
|
||||||
|
renderCard();
|
||||||
|
expandCard();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("inline-create-node-button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stale inline node override when nodes shrink to local-only before submit", async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task);
|
||||||
|
mockInlineNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
{ id: "remote", name: "Remote", status: "online", type: "remote" },
|
||||||
|
]);
|
||||||
|
const { rerender, props } = renderCard([], { onSubmit });
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inline node shrink" } });
|
||||||
|
expandCard();
|
||||||
|
fireEvent.click(screen.getByTestId("inline-create-node-button"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Remote/i }));
|
||||||
|
expect(screen.getByTestId("inline-create-node-button")).toHaveTextContent("Remote");
|
||||||
|
|
||||||
|
mockInlineNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
]);
|
||||||
|
rerender(<InlineCreateCard {...props} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("inline-create-node-button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("save-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("opens node picker from button", () => {
|
it("opens node picker from button", () => {
|
||||||
renderCard();
|
renderCard();
|
||||||
expandCard();
|
expandCard();
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ vi.mock("../CustomModelDropdown", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function mockQuickEntryNodes(nodes: Array<{ id: string; name: string; status: "online" | "offline" | "connecting" | "error"; type: "local" | "remote" }>) {
|
||||||
|
vi.mocked(useNodes).mockReturnValue({
|
||||||
|
nodes: nodes.map((node) => ({ ...node, createdAt: "", updatedAt: "" })),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
unregister: vi.fn(),
|
||||||
|
healthCheck: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuickEntryBox(props = {}, { startExpanded = false } = {}) {
|
function renderQuickEntryBox(props = {}, { startExpanded = false } = {}) {
|
||||||
// Legacy option retained for older test call sites; disclosure now defaults expanded.
|
// Legacy option retained for older test call sites; disclosure now defaults expanded.
|
||||||
if (startExpanded) {
|
if (startExpanded) {
|
||||||
@@ -4731,6 +4744,82 @@ describe("QuickEntryBox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the node picker affordance for local-only projects without leaving shells", () => {
|
||||||
|
mockQuickEntryNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
]);
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
|
||||||
|
expandQuickEntry();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".quick-entry-box .node-trigger-wrap")).toBeNull();
|
||||||
|
expect(document.body.querySelector(".node-picker-dropdown")).toBeNull();
|
||||||
|
expect(screen.queryByText("Select execution node")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the node picker when no registered nodes are available", () => {
|
||||||
|
mockQuickEntryNodes([]);
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
|
||||||
|
expandQuickEntry();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".quick-entry-box .node-trigger-wrap")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the node picker when any remote node is available", () => {
|
||||||
|
mockQuickEntryNodes([
|
||||||
|
{ id: "remote", name: "Remote Only", status: "online", type: "remote" },
|
||||||
|
]);
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
|
||||||
|
expandQuickEntry();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("quick-entry-node-button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the node picker when multiple local nodes are registered", () => {
|
||||||
|
mockQuickEntryNodes([
|
||||||
|
{ id: "local-a", name: "Local A", status: "online", type: "local" },
|
||||||
|
{ id: "local-b", name: "Local A", status: "offline", type: "local" },
|
||||||
|
]);
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
|
||||||
|
expandQuickEntry();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("quick-entry-node-button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a stale selected node when the registry shrinks to local-only before submit", async () => {
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockQuickEntryNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
{ id: "remote", name: "Remote", status: "online", type: "remote" },
|
||||||
|
]);
|
||||||
|
const { rerender, props } = renderQuickEntryBox({ onCreate });
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route after node shrink" } });
|
||||||
|
expandQuickEntry();
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-node-button"));
|
||||||
|
fireEvent.click(screen.getByText("Remote"));
|
||||||
|
expect(screen.getByTestId("quick-entry-node-button")).toHaveTextContent("Remote");
|
||||||
|
|
||||||
|
mockQuickEntryNodes([
|
||||||
|
{ id: "local", name: "Local", status: "online", type: "local" },
|
||||||
|
]);
|
||||||
|
rerender(<QuickEntryBox {...props} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("quick-entry-node-button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
clickSave();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: undefined }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("includes nodeId in payload when execution node override is selected", async () => {
|
it("includes nodeId in payload when execution node override is selected", async () => {
|
||||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||||
renderQuickEntryBox({ onCreate });
|
renderQuickEntryBox({ onCreate });
|
||||||
|
|||||||
Reference in New Issue
Block a user