feat(FN-1655): merge fusion/fn-1655

This commit is contained in:
gsxdsm
2026-04-14 09:59:00 -07:00
parent 9ef6cecf75
commit 6e8bf267a7
5 changed files with 56 additions and 14 deletions

View File

@@ -286,11 +286,8 @@ function AppInner() {
if (nodesOpen) {
return (
<div className="nodes-management-overlay">
<div className="nodes-management-overlay__header">
<button className="btn btn-sm" onClick={() => setNodesOpen(false)}>Close Nodes</button>
</div>
<PageErrorBoundary>
<NodesView addToast={addToast} />
<NodesView addToast={addToast} onClose={() => setNodesOpen(false)} />
</PageErrorBoundary>
</div>
);

View File

@@ -141,6 +141,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
placeholder="Build Machine"
disabled={isSubmitting}
aria-invalid={Boolean(errors.name)}
autoFocus
/>
{errors.name && <span className="add-node-modal__error">{errors.name}</span>}
</label>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw } from "lucide-react";
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects";
import type { NodeInfo, NodeUpdateInput } from "../api";
@@ -11,9 +11,10 @@ import type { ToastType } from "../hooks/useToast";
interface NodesViewProps {
addToast: (message: string, type?: ToastType) => void;
onClose: () => void;
}
export function NodesView({ addToast }: NodesViewProps) {
export function NodesView({ addToast, onClose }: NodesViewProps) {
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
const { projects } = useProjects();
const [addModalOpen, setAddModalOpen] = useState(false);
@@ -84,6 +85,13 @@ export function NodesView({ addToast }: NodesViewProps) {
</div>
<div className="nodes-view-actions">
<button
className="btn-icon nodes-view-close"
onClick={onClose}
aria-label="Close nodes view"
>
<X size={16} />
</button>
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading}>
<RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh

View File

@@ -87,7 +87,7 @@ describe("NodesView", () => {
],
}));
render(<NodesView addToast={vi.fn()} />);
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
// Check node cards are rendered - use the node card class to find elements
const nodeCards = document.querySelectorAll(".node-card");
@@ -106,7 +106,7 @@ describe("NodesView", () => {
it("renders empty state when there are no nodes", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<NodesView addToast={vi.fn()} />);
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByText("No nodes are registered yet.")).toBeDefined();
expect(screen.getByText("Add First Node")).toBeDefined();
@@ -119,7 +119,7 @@ describe("NodesView", () => {
it("opens Add Node modal when Add Node button is clicked", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
render(<NodesView addToast={vi.fn()} />);
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
fireEvent.click(screen.getByText("Add Node"));
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
@@ -140,7 +140,7 @@ describe("NodesView", () => {
nodes: [makeNode({ id: "node-1", name: "Detail Node" })],
}));
render(<NodesView addToast={vi.fn()} />);
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
// Click on the node card (not the topology node)
const nodeCard = document.querySelector(".node-card");
@@ -167,7 +167,7 @@ describe("NodesView", () => {
nodes: [makeNode({ id: "node-1", name: "Local Node", type: "local" })],
}));
render(<NodesView addToast={vi.fn()} />);
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
// Click on the node card to open detail modal
const nodeCard = document.querySelector(".node-card");
@@ -177,4 +177,17 @@ describe("NodesView", () => {
// Modal should show "Projects (2)" - including the unassigned project
expect(screen.getByText("Projects (2)")).toBeDefined();
});
it("renders close button and calls onClose when clicked", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
const onClose = vi.fn();
render(<NodesView addToast={vi.fn()} onClose={onClose} />);
const closeButton = screen.getByRole("button", { name: "Close nodes view" });
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -26116,6 +26116,10 @@ html .column.drag-over * {
gap: var(--space-sm);
}
.nodes-view-close {
margin-left: auto;
}
.nodes-view-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -26341,24 +26345,43 @@ html .column.drag-over * {
.add-node-modal__body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
gap: var(--space-md);
}
.add-node-modal__field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
gap: 4px;
}
.add-node-modal__field > span {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.add-node-modal__field input,
.add-node-modal__field select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 14px;
width: 100%;
}
.add-node-modal__field input:focus,
.add-node-modal__field select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
}
.add-node-modal__error {
color: var(--color-error);
font-size: 12px;
margin-top: 2px;
}
.node-detail-modal {