Fix company switch remembered routes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-13 09:58:26 -05:00
parent 32ab4f8e47
commit 41eb8e51e3
3 changed files with 158 additions and 17 deletions

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
getRememberedPathOwnerCompanyId,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const companies = [
{ id: "for", issuePrefix: "FOR" },
{ id: "pap", issuePrefix: "PAP" },
];
describe("getRememberedPathOwnerCompanyId", () => {
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBe("for");
});
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
expect(
getRememberedPathOwnerCompanyId({
companies: [],
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBeNull();
});
it("falls back to the previous company for unprefixed board routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/dashboard",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
});
describe("sanitizeRememberedPathForCompany", () => {
it("keeps remembered issue paths that belong to the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/PAP-12",
companyPrefix: "PAP",
}),
).toBe("/issues/PAP-12");
});
it("falls back to dashboard for remembered issue identifiers from another company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/FOR-1",
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
it("falls back to dashboard when no remembered path exists", () => {
expect(
sanitizeRememberedPathForCompany({
path: null,
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
});

View File

@@ -1,10 +1,14 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { toCompanyRelativePath } from "../lib/company-routes";
import {
getRememberedPathOwnerCompanyId,
isRememberableCompanyPath,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const STORAGE_KEY = "paperclip.companyPaths";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
function getCompanyPaths(): Record<string, string> {
try {
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
}
function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return true;
const [root] = segments;
if (GLOBAL_SEGMENTS.has(root!)) return false;
return true;
}
/**
* Remembers the last visited page per company and navigates to it on company switch.
* Falls back to /dashboard if no page was previously visited for a company.
*/
export function useCompanyPageMemory() {
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation();
const navigate = useNavigate();
const prevCompanyId = useRef<string | null>(selectedCompanyId);
const rememberedPathOwnerCompanyId = useMemo(
() =>
getRememberedPathOwnerCompanyId({
companies,
pathname: location.pathname,
fallbackCompanyId: prevCompanyId.current,
}),
[companies, location.pathname],
);
// Save current path for current company on every location change.
// Uses prevCompanyId ref so we save under the correct company even
// during the render where selectedCompanyId has already changed.
const fullPath = location.pathname + location.search;
useEffect(() => {
const companyId = prevCompanyId.current;
const companyId = rememberedPathOwnerCompanyId;
const relativePath = toCompanyRelativePath(fullPath);
if (companyId && isRememberableCompanyPath(relativePath)) {
saveCompanyPath(companyId, relativePath);
}
}, [fullPath]);
}, [fullPath, rememberedPathOwnerCompanyId]);
// Navigate to saved path when company changes
useEffect(() => {
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
) {
if (selectionSource !== "route_sync" && selectedCompany) {
const paths = getCompanyPaths();
const savedPath = paths[selectedCompanyId];
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
const targetPath = sanitizeRememberedPathForCompany({
path: paths[selectedCompanyId],
companyPrefix: selectedCompany.issuePrefix,
});
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
}
}

View File

@@ -0,0 +1,65 @@
import {
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
toCompanyRelativePath,
} from "./company-routes";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
export function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return true;
const [root] = segments;
if (GLOBAL_SEGMENTS.has(root!)) return false;
return true;
}
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
companyPrefix: string;
}): T | null {
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
}
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
pathname: string;
fallbackCompanyId: string | null;
}): string | null {
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
if (!routeCompanyPrefix) {
return params.fallbackCompanyId;
}
return findCompanyByPrefix({
companies: params.companies,
companyPrefix: routeCompanyPrefix,
})?.id ?? null;
}
export function sanitizeRememberedPathForCompany(params: {
path: string | null | undefined;
companyPrefix: string;
}): string {
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
if (!isRememberableCompanyPath(relativePath)) {
return "/dashboard";
}
const pathname = relativePath.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
const [root, entityId] = segments;
if (root === "issues" && entityId) {
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
if (
identifierMatch &&
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
) {
return "/dashboard";
}
}
return relativePath;
}