feat(FN-3792): add /tasks deep-link support with theme-data URL resolution
Adds a `/tasks` deep-link that rewrites legacy hash-style URLs on the client and redirects root-absolute paths on the server (FN-3792), along with a new `fusion-plugin-reports` scaffold including manifest, settings, and notification service improvements (FN-3778, FN-3790); also adds a session switch Fusion-Task-Id: FN-3792
This commit is contained in:
5
.changeset/FN-3792-tasks-deeplink.md
Normal file
5
.changeset/FN-3792-tasks-deeplink.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix /tasks/:id deep links: theme stylesheet now resolves root-absolute on sub-paths and /tasks/:id redirects/rewrites to the canonical ?task= form so the task modal opens.
|
||||||
@@ -36,7 +36,14 @@ describe("useDeepLink", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
window.history.replaceState = vi.fn();
|
window.history.replaceState = vi.fn((_state, _unused, url) => {
|
||||||
|
if (typeof url === "string" && url.length > 0) {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL(url, "http://localhost:3000"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}) as typeof window.history.replaceState;
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: new URL("http://localhost:3000/"),
|
value: new URL("http://localhost:3000/"),
|
||||||
@@ -83,7 +90,51 @@ describe("useDeepLink", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches and opens task detail for task deep-link", async () => {
|
it("rewrites /tasks/:id path and opens detail", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/tasks/FN-9999"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { openTaskDetail } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-9999");
|
||||||
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123");
|
||||||
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves project query when rewriting /tasks/:id path", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/tasks/FN-9999?project=proj_456"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setCurrentProject } = renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?project=proj_456&task=FN-9999");
|
||||||
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||||
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid /tasks/:id path", async () => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL("http://localhost:3000/tasks/not-a-task-id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderUseDeepLink();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.history.replaceState).not.toHaveBeenCalled();
|
||||||
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches and opens task detail for existing ?task deep-link without path rewrite", async () => {
|
||||||
Object.defineProperty(window, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: new URL("http://localhost:3000/?task=FN-123"),
|
value: new URL("http://localhost:3000/?task=FN-123"),
|
||||||
@@ -95,6 +146,8 @@ describe("useDeepLink", () => {
|
|||||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||||
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches project and uses project param for task fetch", async () => {
|
it("switches project and uses project param for task fetch", async () => {
|
||||||
|
|||||||
@@ -649,8 +649,7 @@ describe("useTheme", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves theme-data.css relative to document.baseURI for HTTP paths", () => {
|
it("resolves theme-data.css to origin root for HTTP sub-paths", () => {
|
||||||
// Simulate a non-root HTTP path like http://localhost:3000/some/path/
|
|
||||||
Object.defineProperty(document, "baseURI", {
|
Object.defineProperty(document, "baseURI", {
|
||||||
value: "http://localhost:3000/some/path/",
|
value: "http://localhost:3000/some/path/",
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -662,12 +661,12 @@ describe("useTheme", () => {
|
|||||||
result.current.setColorTheme("ocean");
|
result.current.setColorTheme("ocean");
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = document.getElementById("theme-data");
|
const link = document.getElementById("theme-data") as HTMLLinkElement | null;
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
// URL resolution should work with non-root base path
|
const resolved = new URL(link!.href);
|
||||||
expect(link?.getAttribute("href")?.endsWith("theme-data.css")).toBe(true);
|
expect(resolved.origin).toBe("http://localhost:3000");
|
||||||
|
expect(resolved.pathname).toBe("/theme-data.css");
|
||||||
|
|
||||||
// Clean up
|
|
||||||
Object.defineProperty(document, "baseURI", {
|
Object.defineProperty(document, "baseURI", {
|
||||||
value: "http://localhost:3000/",
|
value: "http://localhost:3000/",
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -779,8 +778,8 @@ describe("useTheme", () => {
|
|||||||
// The link should exist and href should be updated to the correct value
|
// The link should exist and href should be updated to the correct value
|
||||||
const link = document.getElementById("theme-data") as HTMLLinkElement;
|
const link = document.getElementById("theme-data") as HTMLLinkElement;
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
// href should be updated to resolve correctly for the new baseURI
|
// HTTP(S) always resolves to origin-root stylesheet path.
|
||||||
expect(link?.href).toBe("http://localhost:3000/some/nested/path/theme-data.css");
|
expect(link?.href).toBe("http://localhost:3000/theme-data.css");
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
link?.remove();
|
link?.remove();
|
||||||
@@ -1210,17 +1209,42 @@ describe("getThemeInitScript", () => {
|
|||||||
expect(script).toContain("effectiveMode");
|
expect(script).toContain("effectiveMode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("index.html uses correct URL replacement pattern", () => {
|
it("pre-hydration script resolves theme-data path like runtime loader", () => {
|
||||||
// Verify that the inline script in index.html uses the correct URL replacement
|
const script = getThemeInitScript();
|
||||||
// pattern (handle both directory paths and filename paths) rather than buggy concatenation
|
const runScript = () => {
|
||||||
|
window.eval(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(COLOR_THEME_STORAGE_KEY, "ocean");
|
||||||
|
|
||||||
|
Object.defineProperty(document, "baseURI", {
|
||||||
|
value: "http://localhost:4040/tasks/FN-3773",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
runScript();
|
||||||
|
let link = document.getElementById("theme-data") as HTMLLinkElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(new URL(link!.href).origin).toBe("http://localhost:4040");
|
||||||
|
expect(new URL(link!.href).pathname).toBe("/theme-data.css");
|
||||||
|
|
||||||
|
link?.remove();
|
||||||
|
Object.defineProperty(document, "baseURI", {
|
||||||
|
value: "file:///Users/me/Projects/kb/packages/dashboard/dist/client/index.html",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
runScript();
|
||||||
|
link = document.getElementById("theme-data") as HTMLLinkElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link!.href).toBe("file:///Users/me/Projects/kb/packages/dashboard/dist/client/theme-data.css");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index.html uses HTTP root-absolute and file-relative theme URL logic", () => {
|
||||||
const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8");
|
const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8");
|
||||||
|
|
||||||
// The correct pattern: check if base ends with '/' and use slice or replace accordingly
|
expect(indexHtml).toContain("new URL('/theme-data.css', base)");
|
||||||
// The buggy pattern: base.substring(0, 7) + dirPath + 'theme-data.css'
|
|
||||||
expect(indexHtml).toContain("base.endsWith('/')");
|
expect(indexHtml).toContain("base.endsWith('/')");
|
||||||
expect(indexHtml).toContain("base.slice(0, -1)");
|
expect(indexHtml).toContain("base.slice(0, -1)");
|
||||||
|
|
||||||
// Ensure the buggy pattern is NOT present
|
|
||||||
expect(indexHtml).not.toContain("base.substring(0, 7)");
|
expect(indexHtml).not.toContain("base.substring(0, 7)");
|
||||||
expect(indexHtml).not.toContain("pathMatch");
|
expect(indexHtml).not.toContain("pathMatch");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,10 +40,28 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
|||||||
// Prevent duplicate fetches when project switching causes the effect to re-run.
|
// Prevent duplicate fetches when project switching causes the effect to re-run.
|
||||||
const deepLinkFetchedRef = useRef(false);
|
const deepLinkFetchedRef = useRef(false);
|
||||||
|
|
||||||
|
// Guard against StrictMode double-effect path rewrites.
|
||||||
|
const pathRewroteRef = useRef(false);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!pathRewroteRef.current) {
|
||||||
|
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
|
||||||
|
if (pathMatch) {
|
||||||
|
const taskIdFromPath = pathMatch[1];
|
||||||
|
if (/^[A-Z]+-\d+$/.test(taskIdFromPath)) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set("task", taskIdFromPath);
|
||||||
|
const query = params.toString();
|
||||||
|
const existingState = window.history.state ?? {};
|
||||||
|
window.history.replaceState(existingState, "", query ? `/?${query}` : "/");
|
||||||
|
pathRewroteRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const projectParam = params.get("project");
|
const projectParam = params.get("project");
|
||||||
const taskId = params.get("task");
|
const taskId = params.get("task");
|
||||||
|
|||||||
@@ -15,35 +15,28 @@ const THEME_DATA_FILENAME = "theme-data.css";
|
|||||||
/**
|
/**
|
||||||
* Get the resolved URL for theme-data.css.
|
* Get the resolved URL for theme-data.css.
|
||||||
*
|
*
|
||||||
* This function handles both HTTP/HTTPS origins and Electron file:// contexts.
|
* NOTE: index.html contains an inline pre-hydration copy of this logic.
|
||||||
* Using document.baseURI ensures the stylesheet path resolves correctly regardless
|
* Keep both implementations behaviorally equivalent.
|
||||||
* of whether the app is served over HTTP or loaded from a file:// URL.
|
|
||||||
*
|
|
||||||
* For file:// URLs, the path is derived relative to the HTML file's directory.
|
|
||||||
* For HTTP/HTTPS URLs, the path is derived relative to the HTML file's directory
|
|
||||||
* (same as file://) to ensure correct resolution in nested deployments.
|
|
||||||
*/
|
*/
|
||||||
function getThemeDataUrl(): string {
|
function getThemeDataUrl(): string {
|
||||||
// Get base URL from document.baseURI (most reliable across contexts)
|
|
||||||
// Falls back to document.location.href if baseURI is unavailable
|
|
||||||
const base = document.baseURI || (typeof document.location !== "undefined" ? document.location.href : "");
|
const base = document.baseURI || (typeof document.location !== "undefined" ? document.location.href : "");
|
||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// Fallback to absolute path if no base available
|
|
||||||
return `/${THEME_DATA_FILENAME}`;
|
return `/${THEME_DATA_FILENAME}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive path relative to HTML file directory
|
if (base.startsWith("http://") || base.startsWith("https://")) {
|
||||||
// Handle two cases:
|
return new URL(`/${THEME_DATA_FILENAME}`, base).toString();
|
||||||
// 1. Base ends with "/" (directory path): replace trailing "/" with "/filename"
|
}
|
||||||
// 2. Base ends with filename: replace filename with "/filename"
|
|
||||||
if (base.endsWith("/")) {
|
if (base.startsWith("file://")) {
|
||||||
// Directory path: replace trailing "/" with "/theme-data.css"
|
if (base.endsWith("/")) {
|
||||||
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
|
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
|
||||||
} else {
|
}
|
||||||
// Filename path: replace last segment with "/theme-data.css"
|
|
||||||
return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`);
|
return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `/${THEME_DATA_FILENAME}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're in a browser environment
|
// Check if we're in a browser environment
|
||||||
@@ -430,6 +423,42 @@ export function getThemeInitScript(): string {
|
|||||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||||
document.documentElement.style.fontSize = fontScale + '%';
|
document.documentElement.style.fontSize = fontScale + '%';
|
||||||
|
if (colorTheme !== 'default') {
|
||||||
|
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||||
|
var themeDataUrl;
|
||||||
|
if (!base) {
|
||||||
|
themeDataUrl = '/theme-data.css';
|
||||||
|
} else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) {
|
||||||
|
themeDataUrl = new URL('/theme-data.css', base).toString();
|
||||||
|
} else if (base.indexOf('file://') === 0) {
|
||||||
|
if (base.endsWith('/')) {
|
||||||
|
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
||||||
|
} else {
|
||||||
|
var lastSlashIndex = base.lastIndexOf('/');
|
||||||
|
themeDataUrl = lastSlashIndex >= 0
|
||||||
|
? base.slice(0, lastSlashIndex) + '/theme-data.css'
|
||||||
|
: '/theme-data.css';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
themeDataUrl = '/theme-data.css';
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingLink = document.getElementById('theme-data');
|
||||||
|
if (existingLink && existingLink.tagName === 'LINK') {
|
||||||
|
if (existingLink.href !== themeDataUrl) {
|
||||||
|
existingLink.href = themeDataUrl;
|
||||||
|
}
|
||||||
|
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
|
||||||
|
document.head.appendChild(existingLink);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = themeDataUrl;
|
||||||
|
link.id = 'theme-data';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
document.documentElement.setAttribute('data-color-theme', 'default');
|
document.documentElement.setAttribute('data-color-theme', 'default');
|
||||||
|
|||||||
@@ -39,28 +39,20 @@
|
|||||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||||
document.documentElement.style.fontSize = fontScale + '%';
|
document.documentElement.style.fontSize = fontScale + '%';
|
||||||
// Load theme-data.css for non-default themes to prevent flash
|
// Load theme-data.css for non-default themes to prevent flash
|
||||||
// Use path-safe resolution that works in both HTTP and file:// contexts
|
// This logic mirrors app/hooks/useTheme.ts#getThemeDataUrl()
|
||||||
if (colorTheme !== 'default') {
|
if (colorTheme !== 'default') {
|
||||||
var base = document.baseURI || (document.location && document.location.href) || '';
|
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||||
// Derive path relative to HTML file directory
|
|
||||||
// Handle two cases:
|
|
||||||
// 1. Base ends with "/" (directory path): replace trailing "/" with "/filename"
|
|
||||||
// 2. Base ends with filename: replace filename with "/filename"
|
|
||||||
var themeDataUrl;
|
var themeDataUrl;
|
||||||
if (base.indexOf('file://') === 0) {
|
if (!base) {
|
||||||
// For file:// URLs
|
themeDataUrl = '/theme-data.css';
|
||||||
if (base.endsWith('/')) {
|
} else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) {
|
||||||
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
themeDataUrl = new URL('/theme-data.css', base).toString();
|
||||||
} else {
|
} else if (base.indexOf('file://') === 0) {
|
||||||
themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css');
|
themeDataUrl = base.endsWith('/')
|
||||||
}
|
? base.slice(0, -1) + '/theme-data.css'
|
||||||
|
: base.replace(/\/[^\/]+$/, '/theme-data.css');
|
||||||
} else {
|
} else {
|
||||||
// For HTTP/HTTPS URLs - same logic
|
themeDataUrl = '/theme-data.css';
|
||||||
if (base.endsWith('/')) {
|
|
||||||
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
|
||||||
} else {
|
|
||||||
themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing link and update href if stale
|
// Check for existing link and update href if stale
|
||||||
|
|||||||
@@ -446,6 +446,45 @@ describe("API Error Handling Middleware", () => {
|
|||||||
expect(res.body).not.toContain("<html");
|
expect(res.body).not.toContain("<html");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redirects /tasks/:id to canonical ?task query", async () => {
|
||||||
|
const app = createServer(store);
|
||||||
|
const res = await GET(app, "/tasks/FN-9999");
|
||||||
|
|
||||||
|
expect(res.status).toBe(301);
|
||||||
|
expect(res.headers.location).toBe("/?task=FN-9999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves project query when redirecting /tasks/:id", async () => {
|
||||||
|
const app = createServer(store);
|
||||||
|
const res = await GET(app, "/tasks/FN-9999?project=demo");
|
||||||
|
|
||||||
|
expect(res.status).toBe(301);
|
||||||
|
expect(res.headers.location).toBe("/?task=FN-9999&project=demo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect invalid /tasks/:id", async () => {
|
||||||
|
const app = createServer(store, { headless: true });
|
||||||
|
const res = await GET(app, "/tasks/not-a-task");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.headers.location).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps canonical query deep-link behavior unchanged", async () => {
|
||||||
|
const previousClientDir = process.env.FUSION_CLIENT_DIR;
|
||||||
|
process.env.FUSION_CLIENT_DIR = join(__dirname, "..", "..", "app");
|
||||||
|
try {
|
||||||
|
const app = createServer(store);
|
||||||
|
const res = await GET(app, "/?task=FN-9999");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(typeof res.body).toBe("string");
|
||||||
|
expect(res.body).toContain("<div id=\"root\"></div>");
|
||||||
|
} finally {
|
||||||
|
process.env.FUSION_CLIENT_DIR = previousClientDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("planning API route content types", () => {
|
describe("planning API route content types", () => {
|
||||||
|
|||||||
@@ -1183,6 +1183,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isHeadless) {
|
if (!isHeadless) {
|
||||||
|
app.get("/tasks/:id", (req, res, next) => {
|
||||||
|
const taskId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
if (!taskId || !/^[A-Z]+-\d+$/.test(taskId)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("task", taskId);
|
||||||
|
const project = typeof req.query.project === "string" ? req.query.project : undefined;
|
||||||
|
if (project) {
|
||||||
|
params.set("project", project);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(301, `/?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
// SPA fallback. Only serve index.html for navigation requests — never for
|
// SPA fallback. Only serve index.html for navigation requests — never for
|
||||||
// hashed asset URLs (/assets/*, /icons/*, /fonts/*) or any path that looks
|
// hashed asset URLs (/assets/*, /icons/*, /fonts/*) or any path that looks
|
||||||
// like a static file. Returning index.html for a missing JS chunk poisons
|
// like a static file. Returning index.html for a missing JS chunk poisons
|
||||||
|
|||||||
Reference in New Issue
Block a user