feat(FN-1846): add mobile responsive styles for node modals

- Add mobile responsive styles for AddNodeModal and ConnectNodeModal
- Create comprehensive AddNodeModal tests with type toggle, validation, and transitions
- Improve CSS consistency across node modals with proper spacing and transitions
- Refactor AddNodeModal TSX markup with clean structure and type toggle
- Remove duplicate CSS block in ConnectNodeModal
This commit is contained in:
Fusion
2026-04-15 19:27:53 -07:00
committed by gsxdsm
parent e4eac5c03b
commit 01a221fbe8
3 changed files with 438 additions and 97 deletions

View File

@@ -109,7 +109,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
try {
await onSubmit(input);
addToast(`Node \"${input.name}\" registered`, "success");
addToast(`Node "${input.name}" registered`, "success");
closeModal();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to register node";
@@ -132,6 +132,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
</div>
<div className="modal-body add-node-modal__body">
<p className="add-node-modal__description">Register a node to distribute task execution across machines.</p>
<label className="add-node-modal__field">
<span>Name</span>
<input
@@ -146,45 +148,54 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
{errors.name && <span className="add-node-modal__error">{errors.name}</span>}
</label>
<label className="add-node-modal__field">
<span>Type</span>
<select
value={type}
onChange={(event) => setType(event.target.value as "local" | "remote")}
<div className="add-node-modal__type-toggle">
<button
type="button"
className={`add-node-modal__type-btn ${type === "local" ? "active" : ""}`}
data-type="local"
onClick={() => setType("local")}
disabled={isSubmitting}
aria-pressed={type === "local"}
>
<option value="local">Local</option>
<option value="remote">Remote</option>
</select>
</label>
Local
</button>
<button
type="button"
className={`add-node-modal__type-btn ${type === "remote" ? "active" : ""}`}
data-type="remote"
onClick={() => setType("remote")}
disabled={isSubmitting}
aria-pressed={type === "remote"}
>
Remote
</button>
</div>
{type === "remote" && (
<>
<label className="add-node-modal__field">
<span>URL</span>
<input
type="text"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://node.example.com"
disabled={isSubmitting}
aria-invalid={Boolean(errors.url)}
/>
{errors.url && <span className="add-node-modal__error">{errors.url}</span>}
</label>
<div className="add-node-modal__remote-fields" data-testid="remote-fields-container" data-visible={type === "remote"}>
<label className="add-node-modal__field">
<span>URL</span>
<input
type="text"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://node.example.com"
disabled={isSubmitting}
aria-invalid={Boolean(errors.url)}
/>
{errors.url && <span className="add-node-modal__error">{errors.url}</span>}
</label>
<label className="add-node-modal__field">
<span>API Key</span>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
disabled={isSubmitting}
/>
</label>
</>
)}
<label className="add-node-modal__field">
<span>API Key</span>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
disabled={isSubmitting}
/>
</label>
</div>
<label className="add-node-modal__field">
<span>Max Concurrent</span>
@@ -197,13 +208,14 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
disabled={isSubmitting}
aria-invalid={Boolean(errors.maxConcurrent)}
/>
<span className="add-node-modal__hint">Max simultaneous task agents (110)</span>
{errors.maxConcurrent && <span className="add-node-modal__error">{errors.maxConcurrent}</span>}
</label>
</div>
<div className="modal-actions">
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>Cancel</button>
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={isSubmitting}>
<button className="btn btn-primary btn-sm" data-testid="add-node-submit" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Adding..." : "Add Node"}
</button>
</div>

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddNodeModal } from "../AddNodeModal";
import type { NodeInfo } from "../../api";
describe("AddNodeModal", () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders when isOpen is true", () => {
render(<AddNodeModal {...defaultProps} />);
expect(screen.getByLabelText("Add Node")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Build Machine")).toBeInTheDocument();
});
it("does not render when isOpen is false", () => {
render(<AddNodeModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("validates required name field", async () => {
render(<AddNodeModal {...defaultProps} />);
// Try to submit without filling name
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Name is required")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates URL required when type is remote", async () => {
render(<AddNodeModal {...defaultProps} />);
// Switch to remote type
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
// Fill name but not URL
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("URL is required for remote nodes")).toBeInTheDocument();
expect(defaultProps.addToast).not.toHaveBeenCalled();
});
it("validates max concurrent range", async () => {
render(<AddNodeModal {...defaultProps} />);
// Fill name
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
// Get the number input (type="number" with min/max)
const maxConcurrentInput = screen.getByRole("spinbutton");
fireEvent.change(maxConcurrentInput, {
target: { value: "15" },
});
// Try to submit
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Concurrency must be between 1 and 10")).toBeInTheDocument();
});
it("calls onSubmit with correct input on valid submission", async () => {
render(<AddNodeModal {...defaultProps} />);
// Fill form
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
name: "Test Node",
type: "local",
url: undefined,
apiKey: undefined,
maxConcurrent: 2,
});
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it("shows error toast on submission failure", async () => {
const errorOnSubmit = vi.fn().mockRejectedValue(new Error("Network error"));
render(<AddNodeModal {...defaultProps} onSubmit={errorOnSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Network error", "error");
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
it("resets form on close", () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("handles Escape key to close", () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("toggles between local and remote type", async () => {
render(<AddNodeModal {...defaultProps} />);
// Initially local is selected
const localBtn = screen.getByRole("button", { name: "Local" });
const remoteBtn = screen.getByRole("button", { name: "Remote" });
expect(localBtn).toHaveAttribute("aria-pressed", "true");
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
// Remote fields container should be hidden
const remoteFieldsContainer = screen.getByTestId("remote-fields-container");
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "false");
// Switch to remote
fireEvent.click(remoteBtn);
expect(localBtn).toHaveAttribute("aria-pressed", "false");
expect(remoteBtn).toHaveAttribute("aria-pressed", "true");
// Remote fields container should be visible
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "true");
// URL and API Key fields should be visible
expect(screen.getByPlaceholderText("https://node.example.com")).toBeInTheDocument();
// Switch back to local
fireEvent.click(localBtn);
expect(localBtn).toHaveAttribute("aria-pressed", "true");
expect(remoteBtn).toHaveAttribute("aria-pressed", "false");
// Remote fields container should be hidden again
expect(remoteFieldsContainer).toHaveAttribute("data-visible", "false");
});
it("submit button is disabled while submitting", async () => {
let resolveSubmit: () => void;
const slowSubmit = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveSubmit = resolve;
})
);
render(<AddNodeModal {...defaultProps} onSubmit={slowSubmit} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Test Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
// Button should show "Adding..." and be disabled
expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled();
// Resolve the submit
resolveSubmit!();
});
it("clicking overlay closes modal", () => {
render(<AddNodeModal {...defaultProps} />);
// Click on the overlay (not the modal itself)
const overlay = screen.getByRole("dialog").parentElement!;
fireEvent.click(overlay);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("shows hint text for max concurrent field", () => {
render(<AddNodeModal {...defaultProps} />);
expect(screen.getByText("Max simultaneous task agents (110)")).toBeInTheDocument();
});
it("shows description text", () => {
render(<AddNodeModal {...defaultProps} />);
expect(
screen.getByText("Register a node to distribute task execution across machines.")
).toBeInTheDocument();
});
it("submits remote node with URL and API key", async () => {
render(<AddNodeModal {...defaultProps} />);
// Fill name
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
// Switch to remote
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
// Fill URL and API key
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.change(screen.getByPlaceholderText("Optional"), {
target: { value: "secret-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
name: "Remote Node",
type: "remote",
url: "https://node.example.com",
apiKey: "secret-key",
maxConcurrent: 2,
});
});
});
});

View File

@@ -27577,6 +27577,13 @@ html .column.drag-over * {
gap: var(--space-md);
}
.add-node-modal__description {
font-size: 13px;
color: var(--text-muted);
margin-bottom: var(--space-sm);
padding: 0;
}
.add-node-modal__field {
display: flex;
flex-direction: column;
@@ -27584,13 +27591,12 @@ html .column.drag-over * {
}
.add-node-modal__field > span {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.add-node-modal__field input,
.add-node-modal__field select {
.add-node-modal__field input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@@ -27598,13 +27604,28 @@ html .column.drag-over * {
color: var(--text);
font-size: 14px;
width: 100%;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.add-node-modal__field input:focus,
.add-node-modal__field select:focus {
.add-node-modal__field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent);
}
.add-node-modal__field input[aria-invalid="true"] {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 5%, var(--surface));
}
.add-node-modal__field input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 15%, transparent);
}
.add-node-modal__hint {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.add-node-modal__error {
@@ -27613,6 +27634,64 @@ html .column.drag-over * {
margin-top: 2px;
}
/* Type toggle (segmented control) */
.add-node-modal__type-toggle {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.add-node-modal__type-btn {
flex: 1;
padding: 10px 16px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.add-node-modal__type-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--bg) 50%, var(--surface));
}
.add-node-modal__type-btn:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 1;
}
.add-node-modal__type-btn.active {
background: var(--accent);
color: var(--bg);
}
.add-node-modal__type-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Remote fields with animated show/hide */
.add-node-modal__remote-fields {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
}
.add-node-modal__remote-fields[data-visible="true"] {
grid-template-rows: 1fr;
}
.add-node-modal__remote-fields > * {
overflow: hidden;
}
.node-detail-modal {
max-width: 860px;
width: min(860px, calc(100vw - 32px));
@@ -27758,6 +27837,38 @@ html .column.drag-over * {
.node-detail-modal__field--full {
grid-column: span 1;
}
/* Add Node Modal mobile */
.add-node-modal {
width: calc(100vw - 16px);
}
.add-node-modal__type-toggle {
width: 100%;
}
.add-node-modal__type-btn {
flex: 1;
padding: 12px 16px;
}
.add-node-modal__field input {
min-height: 44px;
}
/* Instant transition on mobile for remote fields */
.add-node-modal__remote-fields {
transition: none;
}
/* Connect Node Modal mobile */
.connect-node-modal {
width: calc(100vw - 16px);
}
.connect-node-field input {
min-height: 44px;
}
}
/* ── Mesh Topology ─────────────────────────────────────────────── */
@@ -27833,53 +27944,6 @@ html .column.drag-over * {
padding: var(--space-xl);
}
/* ── Connect Node Modal ──────────────────────────────────────────── */
.connect-node-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.connect-node-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.connect-node-field label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.connect-node-field input {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 14px;
transition: border-color var(--transition-fast);
}
.connect-node-field input:focus {
outline: none;
border-color: var(--color-primary);
}
.connect-node-field .error-message {
font-size: 12px;
color: var(--color-error);
}
.connect-node-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
}
/* ── Quick Chat FAB ──────────────────────────────────────────────── */
/* Position set via inline style from useDraggable */
@@ -30057,9 +30121,9 @@ html .column.drag-over * {
}
.connect-node-field > span {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.connect-node-field input {
@@ -30069,12 +30133,22 @@ html .column.drag-over * {
background: var(--surface);
color: var(--text);
font-size: 14px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.connect-node-field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent);
}
.connect-node-field input[aria-invalid="true"] {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 5%, var(--surface));
}
.connect-node-field input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 15%, transparent);
}
.connect-node-url-preview {
@@ -30084,6 +30158,7 @@ html .column.drag-over * {
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--surface) 50%, var(--bg));
border-radius: var(--radius-sm);
border-left: 3px solid var(--accent);
font-size: 12px;
}
@@ -30094,13 +30169,14 @@ html .column.drag-over * {
.connect-node-url-preview code {
font-family: var(--font-mono);
color: var(--text);
font-size: 12px;
}
.connect-node-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
padding: var(--modal-padding);
border-top: 1px solid var(--border);
}