feat(FN-5583): handle notification deep links in useDeepLink hook
Adds support for project-only notification deep links in the dashboard hook, with comprehensive test coverage across all deep-link URL shapes and a changeset for release. Fusion-Task-Id: FN-5583 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5583
This commit is contained in:
5
.changeset/FN-5583-notification-deeplinks.md
Normal file
5
.changeset/FN-5583-notification-deeplinks.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix ntfy notification deep links: project-only links now switch projects, and task links to non-current projects resolve against the correct project before opening the modal.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { StrictMode, createElement, type PropsWithChildren } from "react";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { useDeepLink } from "../useDeepLink";
|
import { useDeepLink } from "../useDeepLink";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
@@ -150,17 +151,65 @@ describe("useDeepLink", () => {
|
|||||||
expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123");
|
expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches project for project-only deep links without opening task detail", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/?project=proj_456"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setCurrentProject, openTaskDetail, addToast } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(addToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unknown project toast only once under StrictMode", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/?project=missing"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToast = vi.fn();
|
||||||
|
const strictWrapper = ({ children }: PropsWithChildren) => createElement(StrictMode, null, children);
|
||||||
|
|
||||||
|
renderHook(() => useDeepLink({
|
||||||
|
projectId: defaultProject.id,
|
||||||
|
projects: [defaultProject, otherProject],
|
||||||
|
projectsLoading: false,
|
||||||
|
currentProject: defaultProject,
|
||||||
|
setCurrentProject: vi.fn(),
|
||||||
|
addToast,
|
||||||
|
openTaskDetail: vi.fn(),
|
||||||
|
closeTaskDetail: vi.fn(),
|
||||||
|
}), { wrapper: strictWrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("switches project and uses project param for task fetch", async () => {
|
it("switches project and uses project param for task fetch", async () => {
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-999"),
|
value: new URL("http://localhost:3000/?project=proj_456&task=FN-999"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { setCurrentProject } = renderUseDeepLink();
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
||||||
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-999", "proj_456");
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-999", "proj_456");
|
||||||
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,15 +219,74 @@ describe("useDeepLink", () => {
|
|||||||
value: new URL("http://localhost:3000/?project=missing&task=FN-123"),
|
value: new URL("http://localhost:3000/?project=missing&task=FN-123"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { addToast } = renderUseDeepLink();
|
const { addToast, setCurrentProject } = renderUseDeepLink();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(addToast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setCurrentProject).not.toHaveBeenCalled();
|
||||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps task-only deep-link behavior and strips task on detail close", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/?task=FN-9999"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, setCurrentProject, closeTaskDetail } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setCurrentProject).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
result.current.handleDetailClose();
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/");
|
||||||
|
expect(closeTaskDetail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves mailbox view deep-link params intact after project switch", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/?project=proj_456&view=mailbox&mailbox-message=msg-1#message-msg-1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.search).toContain("view=mailbox");
|
||||||
|
expect(window.location.search).toContain("mailbox-message=msg-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches project for rooms view deep links without consuming room params", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/?project=proj_456&view=rooms&room=room-1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(window.location.search).toContain("view=rooms");
|
||||||
|
expect(window.location.search).toContain("room=room-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("waits for projects to load before resolving deep links", async () => {
|
it("waits for projects to load before resolving deep links", async () => {
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
|||||||
// Track whether the currently open detail modal came from a deep-link.
|
// Track whether the currently open detail modal came from a deep-link.
|
||||||
const deepLinkTaskIdRef = useRef<string | null>(null);
|
const deepLinkTaskIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Avoid duplicate not-found toasts in StrictMode double-effect runs.
|
||||||
|
const projectNotFoundToastRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Ensure project switching from ?project= only happens once per project value.
|
||||||
|
const projectSwitchAppliedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathRewroteRef.current) {
|
if (!pathRewroteRef.current) {
|
||||||
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
|
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
|
||||||
@@ -66,25 +72,40 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
|||||||
const projectParam = params.get("project");
|
const projectParam = params.get("project");
|
||||||
const taskId = params.get("task");
|
const taskId = params.get("task");
|
||||||
|
|
||||||
if (!taskId) return;
|
|
||||||
if (projectsLoading) return;
|
if (projectsLoading) return;
|
||||||
|
|
||||||
|
let taskProjectId = projectId;
|
||||||
|
|
||||||
if (projectParam) {
|
if (projectParam) {
|
||||||
const matchingProject = projects.find((project) => project.id === projectParam);
|
const matchingProject = projects.find((project) => project.id === projectParam);
|
||||||
if (!matchingProject) {
|
if (!matchingProject) {
|
||||||
addToast(`Project '${projectParam}' not found`, "error");
|
if (projectNotFoundToastRef.current !== projectParam) {
|
||||||
|
addToast(`Project '${projectParam}' not found`, "error");
|
||||||
|
projectNotFoundToastRef.current = projectParam;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentProject?.id !== matchingProject.id) {
|
projectNotFoundToastRef.current = null;
|
||||||
|
taskProjectId = matchingProject.id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentProject?.id !== matchingProject.id
|
||||||
|
&& projectSwitchAppliedRef.current !== matchingProject.id
|
||||||
|
) {
|
||||||
setCurrentProject(matchingProject);
|
setCurrentProject(matchingProject);
|
||||||
|
projectSwitchAppliedRef.current = matchingProject.id;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
projectNotFoundToastRef.current = null;
|
||||||
|
projectSwitchAppliedRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
if (deepLinkFetchedRef.current) return;
|
if (deepLinkFetchedRef.current) return;
|
||||||
deepLinkFetchedRef.current = true;
|
deepLinkFetchedRef.current = true;
|
||||||
|
|
||||||
const taskProjectId = projectParam ?? projectId;
|
|
||||||
fetchTaskDetail(taskId, taskProjectId)
|
fetchTaskDetail(taskId, taskProjectId)
|
||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
openTaskDetail(detail);
|
openTaskDetail(detail);
|
||||||
|
|||||||
Reference in New Issue
Block a user