feat(FN-3329): refine dashboard TUI system panel and setup wizard UX
- Expand dashboard TUI state/controller wiring for system panel interactions and mouse toggle behavior - Update dashboard TUI app rendering and tests to cover the new panel controls - Improve SetupWizardModal layout and interaction behavior with matching CSS and test updates - Restore docs/research.md wording and add a changeset for the published CLI package Fusion-Task-Id: FN-3329
This commit is contained in:
@@ -292,6 +292,28 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.setup-wizard-auth-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.setup-wizard-auth-step-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-wizard-auth-step code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SetupWizardModalProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type WizardStep = "manual" | "complete";
|
||||
type WizardStep = "auth" | "manual" | "complete";
|
||||
type ManualSetupMode = "existing" | "clone";
|
||||
|
||||
interface WizardState {
|
||||
@@ -42,8 +42,8 @@ export function SetupWizardModal({
|
||||
}: SetupWizardModalProps) {
|
||||
const helpUrl = "https://github.com/runfusion/fusion/discussions";
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [state, setState] = useState<WizardState>({
|
||||
step: "manual",
|
||||
const [state, setState] = useState<WizardState>(() => ({
|
||||
step: getAuthToken() ? "manual" : "auth",
|
||||
manualMode: "existing",
|
||||
manualPath: "",
|
||||
manualCloneUrl: "",
|
||||
@@ -52,7 +52,7 @@ export function SetupWizardModal({
|
||||
manualNodeId: "",
|
||||
isRegistering: false,
|
||||
error: null,
|
||||
});
|
||||
}));
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [authTokenInput, setAuthTokenInput] = useState("");
|
||||
const [storedAuthToken, setStoredAuthToken] = useState(() => getAuthToken());
|
||||
@@ -116,14 +116,20 @@ export function SetupWizardModal({
|
||||
const token = authTokenInput.trim();
|
||||
if (!token) return;
|
||||
setAuthToken(token);
|
||||
window.location.reload();
|
||||
setStoredAuthToken(token);
|
||||
setAuthTokenInput("");
|
||||
// If we're on the auth step, advance to the manual step
|
||||
setState((prev) => prev.step === "auth" ? { ...prev, step: "manual" } : prev);
|
||||
}, [authTokenInput]);
|
||||
|
||||
const handleResetAuthToken = useCallback(() => {
|
||||
clearAuthToken();
|
||||
setStoredAuthToken(undefined);
|
||||
setAuthTokenInput("");
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleSkipAuth = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, step: "manual" }));
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -169,6 +175,7 @@ export function SetupWizardModal({
|
||||
<span className="setup-wizard-brand-name">Fusion</span>
|
||||
</div>
|
||||
<h2 id="wizard-title" className="setup-wizard-title">
|
||||
{state.step === "auth" && "Set Auth Token"}
|
||||
{state.step === "manual" && "Welcome to Fusion"}
|
||||
{state.step === "complete" && "Setup Complete!"}
|
||||
</h2>
|
||||
@@ -186,6 +193,37 @@ export function SetupWizardModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="setup-wizard-content">
|
||||
{/* Auth Step */}
|
||||
{state.step === "auth" && (
|
||||
<div className="setup-wizard-auth-step">
|
||||
<p className="setup-wizard-auth-step-description">
|
||||
This dashboard requires an auth token to communicate with the Fusion daemon.
|
||||
Paste the token below to continue.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="setup-auth-token">Auth Token</label>
|
||||
<input
|
||||
id="setup-auth-token"
|
||||
type="password"
|
||||
value={authTokenInput}
|
||||
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||
placeholder="Paste the daemon auth token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="form-hint">
|
||||
The token was set via the <code>FUSION_DAEMON_TOKEN</code> environment variable when starting the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
{state.error && (
|
||||
<div className="wizard-error" role="alert">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Step */}
|
||||
{state.step === "manual" && (
|
||||
<div className="setup-wizard-manual">
|
||||
@@ -339,10 +377,10 @@ export function SetupWizardModal({
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="setup-auth-token">Browser Auth Token</label>
|
||||
<label htmlFor="advanced-auth-token">Browser Auth Token</label>
|
||||
<div className="setup-wizard-auth-token">
|
||||
<input
|
||||
id="setup-auth-token"
|
||||
id="advanced-auth-token"
|
||||
type="password"
|
||||
value={authTokenInput}
|
||||
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||
@@ -372,8 +410,8 @@ export function SetupWizardModal({
|
||||
</div>
|
||||
<p className="form-hint">
|
||||
{storedAuthToken
|
||||
? "A token is already stored in this browser. Updating or resetting it will reload the page."
|
||||
: "Store a token in this browser for authenticated dashboard requests, then reload the page."}
|
||||
? "A token is already stored in this browser. You can update or reset it below."
|
||||
: "No token is stored. Use the auth prompt at the top of the wizard, or set one here."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,6 +451,23 @@ export function SetupWizardModal({
|
||||
>
|
||||
Need help?
|
||||
</a>
|
||||
{state.step === "auth" && (
|
||||
<>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleSkipAuth}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSetAuthToken}
|
||||
disabled={authTokenInput.trim().length === 0}
|
||||
>
|
||||
<span>Set Token & Continue</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{state.step === "manual" && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
|
||||
@@ -66,12 +66,36 @@ describe("SetupWizardModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
// Default: auth token is stored so wizard starts on the project form step.
|
||||
// Tests for the auth step explicitly unset this.
|
||||
mockGetAuthToken.mockReturnValue("stored-token");
|
||||
reloadMock = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
|
||||
});
|
||||
|
||||
it("renders with welcome message", () => {
|
||||
it("renders with auth step when no token is stored", () => {
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// No token stored → shows auth step first
|
||||
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||
expect(screen.getByText("Skip")).toBeDefined();
|
||||
expect(screen.getByText("Set Token & Continue")).toBeDefined();
|
||||
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/runfusion/fusion/discussions"
|
||||
);
|
||||
});
|
||||
|
||||
it("skips auth step and shows project form when auth token is already stored", () => {
|
||||
mockGetAuthToken.mockReturnValue("stored-token");
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
@@ -83,10 +107,46 @@ describe("SetupWizardModal", () => {
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
|
||||
expect(screen.getByText("Advanced settings")).toBeDefined();
|
||||
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/runfusion/fusion/discussions"
|
||||
});
|
||||
|
||||
it("auth step advances to project form after setting token", () => {
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// On auth step initially
|
||||
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||
|
||||
// Set a token
|
||||
fireEvent.change(screen.getByPlaceholderText("Paste the daemon auth token"), {
|
||||
target: { value: "my-daemon-token" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Set Token & Continue"));
|
||||
|
||||
expect(mockSetAuthToken).toHaveBeenCalledWith("my-daemon-token");
|
||||
// Should now show the project form
|
||||
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auth step can be skipped", () => {
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||
fireEvent.click(screen.getByText("Skip"));
|
||||
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
|
||||
});
|
||||
|
||||
it("has DirectoryPicker for path selection", () => {
|
||||
@@ -409,6 +469,8 @@ describe("SetupWizardModal", () => {
|
||||
});
|
||||
|
||||
it("shows a set token action when no browser auth token is stored", () => {
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
@@ -416,6 +478,9 @@ describe("SetupWizardModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Skip auth step to get to the project form
|
||||
fireEvent.click(screen.getByText("Skip"));
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced settings"));
|
||||
|
||||
expect(screen.getByLabelText("Browser Auth Token")).toBeDefined();
|
||||
@@ -423,7 +488,7 @@ describe("SetupWizardModal", () => {
|
||||
expect(screen.queryByRole("button", { name: "Reset token" })).toBeNull();
|
||||
});
|
||||
|
||||
it("stores a browser auth token and reloads the page", () => {
|
||||
it("stores a browser auth token without reloading", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
@@ -435,10 +500,10 @@ describe("SetupWizardModal", () => {
|
||||
fireEvent.change(screen.getByLabelText("Browser Auth Token"), {
|
||||
target: { value: "daemon-token" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set token" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update token" }));
|
||||
|
||||
expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token");
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows reset when a browser auth token is already stored", () => {
|
||||
@@ -457,7 +522,7 @@ describe("SetupWizardModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Reset token" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("resets the stored browser auth token and reloads the page", () => {
|
||||
it("resets the stored browser auth token without reloading", () => {
|
||||
mockGetAuthToken.mockReturnValue("stored-token");
|
||||
|
||||
render(
|
||||
@@ -471,7 +536,7 @@ describe("SetupWizardModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset token" }));
|
||||
|
||||
expect(mockClearAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("node selector", () => {
|
||||
|
||||
Reference in New Issue
Block a user