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:
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels } from "./api";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
|
||||
import type { ModelInfo } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -109,6 +109,27 @@ function AppInner() {
|
||||
}, []);
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
// Handle deep link to task on mount
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const taskId = params.get("task");
|
||||
if (!taskId) return;
|
||||
|
||||
// Clean URL immediately without reloading
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("task");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
||||
// Load and open the task directly
|
||||
fetchTaskDetail(taskId)
|
||||
.then((task) => {
|
||||
handleDetailOpen(task);
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Persist view preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-view", view);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
Reference in New Issue
Block a user