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:
@@ -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