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 { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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({
|
||||
tasks,
|
||||
onSubmit,
|
||||
@@ -103,6 +111,9 @@ export function InlineCreateCard({
|
||||
const [showPresets, setShowPresets] = useState(false);
|
||||
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
||||
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 [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||||
@@ -182,6 +193,15 @@ export function InlineCreateCard({
|
||||
if (!showDeps) setDepSearch("");
|
||||
}, [showDeps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNodeId(undefined);
|
||||
setShowNodePicker(false);
|
||||
}, [nodeId, selectedNode, shouldShowNodePicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAgentPicker) return;
|
||||
|
||||
@@ -482,7 +502,7 @@ export function InlineCreateCard({
|
||||
*/
|
||||
enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
nodeId: effectiveNodeId,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -497,7 +517,7 @@ export function InlineCreateCard({
|
||||
}
|
||||
|
||||
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 matches = duplicateMatches;
|
||||
@@ -627,7 +647,6 @@ export function InlineCreateCard({
|
||||
|
||||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||||
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||||
|
||||
const handleExecutorChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
@@ -937,59 +956,61 @@ export function InlineCreateCard({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
data-testid="inline-create-node-button"
|
||||
onClick={() => {
|
||||
setShowNodePicker((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
setShowAgentPicker(false);
|
||||
setIsModelModalOpen(false);
|
||||
setShowPresets(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
|
||||
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
||||
</button>
|
||||
{showNodePicker && (
|
||||
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
|
||||
<button
|
||||
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) => (
|
||||
{shouldShowNodePicker && (
|
||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
data-testid="inline-create-node-button"
|
||||
onClick={() => {
|
||||
setShowNodePicker((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
setShowAgentPicker(false);
|
||||
setIsModelModalOpen(false);
|
||||
setShowPresets(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
|
||||
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
||||
</button>
|
||||
{showNodePicker && (
|
||||
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
|
||||
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
|
||||
onClick={() => {
|
||||
setNodeId(node.id);
|
||||
setNodeId(undefined);
|
||||
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>
|
||||
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
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}>
|
||||
<button
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } 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 { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const { t } = useTranslation("app");
|
||||
const [description, setDescription] = useState(() => {
|
||||
@@ -217,6 +225,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const submitInFlightRef = useRef(false);
|
||||
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
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
@@ -224,6 +235,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const refineMenuPortalRef = useRef<HTMLDivElement>(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
|
||||
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
|
||||
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
|
||||
@@ -684,7 +705,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
...(isFastMode ? { executionMode: "fast" } : {}),
|
||||
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
nodeId: effectiveNodeId,
|
||||
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
|
||||
});
|
||||
if (createdTask && pendingImages.length > 0) {
|
||||
@@ -730,7 +751,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
settings,
|
||||
githubTrackingOverride,
|
||||
priority,
|
||||
nodeId,
|
||||
effectiveNodeId,
|
||||
pendingImages,
|
||||
projectId,
|
||||
addToast,
|
||||
@@ -1582,7 +1603,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
|
||||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||||
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||||
const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
|
||||
const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault;
|
||||
const githubToggleLabel = effectiveGithubTracking
|
||||
@@ -2069,43 +2089,45 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
{modelMenuLabel}
|
||||
</button>
|
||||
|
||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="btn btn-sm dep-trigger"
|
||||
data-testid="quick-entry-node-button"
|
||||
onClick={() => {
|
||||
setShowDeps(false);
|
||||
setShowAgentPicker(false);
|
||||
setAgentPickerPosition(null);
|
||||
setIsModelMenuOpen(false);
|
||||
setModelMenuPosition(null);
|
||||
setActiveModelSubmenu(null);
|
||||
setShowPriorityPicker(false);
|
||||
setPriorityPickerPosition(null);
|
||||
setShowNodePicker((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
updateNodePickerPosition();
|
||||
} else {
|
||||
setNodePickerPosition(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
|
||||
{selectedNode && (
|
||||
<span className="quick-entry-node-status">
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{shouldShowNodePicker && (
|
||||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="btn btn-sm dep-trigger"
|
||||
data-testid="quick-entry-node-button"
|
||||
onClick={() => {
|
||||
setShowDeps(false);
|
||||
setShowAgentPicker(false);
|
||||
setAgentPickerPosition(null);
|
||||
setIsModelMenuOpen(false);
|
||||
setModelMenuPosition(null);
|
||||
setActiveModelSubmenu(null);
|
||||
setShowPriorityPicker(false);
|
||||
setPriorityPickerPosition(null);
|
||||
setShowNodePicker((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
updateNodePickerPosition();
|
||||
} else {
|
||||
setNodePickerPosition(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{` ${selectedNode?.name ?? t("tasks.node", "Node")}`}
|
||||
{selectedNode && (
|
||||
<span className="quick-entry-node-status">
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNodePicker && portalRoot && nodePickerPosition && createPortal(
|
||||
{shouldShowNodePicker && showNodePicker && portalRoot && nodePickerPosition && createPortal(
|
||||
<div
|
||||
ref={nodePickerPortalRef}
|
||||
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(
|
||||
tasks: Task[] = [],
|
||||
overrides: Partial<ComponentProps<typeof InlineCreateCard>> = {},
|
||||
@@ -1635,6 +1648,67 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
||||
|
||||
|
||||
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", () => {
|
||||
renderCard();
|
||||
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 } = {}) {
|
||||
// Legacy option retained for older test call sites; disclosure now defaults expanded.
|
||||
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 () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
|
||||
Reference in New Issue
Block a user