feat(FN-669): add ntfy deep link support with dashboard hostname config

- Add ntfyDashboardHost setting to GlobalSettings for dashboard URL\n- Generate deep links in ntfy notifications with Click header\n- Handle ?task= query param in dashboard for direct task navigation\n- Add hostname configuration UI in Settings modal\n- Add comprehensive tests for deep link generation and handling
This commit is contained in:
gsxdsm
2026-04-01 07:21:43 -07:00
parent 0559962685
commit a9ccd86ece
7 changed files with 393 additions and 44 deletions

View File

@@ -1378,6 +1378,37 @@ export function SettingsModal({
</button>
</div>
)}
{form.ntfyEnabled && (
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
<small>
Base URL for deep links in notifications. When set, clicking a notification
will open the dashboard directly to the task. Example: http://localhost:3000
or https://fusion.example.com
</small>
{form.ntfyDashboardHost && (
!/^https?:\/\/.+/.test(form.ntfyDashboardHost) ? (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
) : form.ntfyDashboardHost.includes("?") || form.ntfyDashboardHost.includes("#") ? (
<small className="field-error">
URL should not include query parameters or fragments
</small>
) : null
)}
</div>
)}
</>
);
case "authentication":

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { App } from "../../App";
import type { Settings } from "@fusion/core";
@@ -35,6 +35,19 @@ vi.mock("../../api", async (importOriginal) => {
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([])),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({
id,
title: `Task ${id}`,
description: "Deep linked task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
prompt: "",
})),
};
});
@@ -49,12 +62,85 @@ vi.mock("../../hooks/useTasks", () => ({
}),
}));
import { fetchAuthStatus, fetchSettings, updateSettings } from "../../api";
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from "../../api";
beforeEach(() => {
vi.clearAllMocks();
});
describe("App deep link handling", () => {
const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;
beforeEach(() => {
window.history.replaceState = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/"),
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
window.history.replaceState = originalReplaceState;
});
it("fetches and opens the task modal when task query param is present", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-123"),
});
render(<App />);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
});
await waitFor(() => {
expect(screen.getByText("Task FN-123")).toBeTruthy();
});
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
"",
"http://localhost:3000/",
);
});
it("shows an error toast when the deep-linked task cannot be loaded", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-404"),
});
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Not found"));
render(<App />);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-404");
});
await waitFor(() => {
expect(screen.getByText("Task FN-404 not found")).toBeTruthy();
});
});
it("does nothing when no task query param is present", async () => {
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
expect(fetchTaskDetail).not.toHaveBeenCalled();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
});
describe("App auto-open Settings on unauthenticated", () => {
it("auto-opens Settings to Authentication tab when all providers are unauthenticated", async () => {
render(<App />);