Fix company switch remembered routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocation, useNavigate } from "@/lib/router";
|
import { useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||||
|
import {
|
||||||
|
getRememberedPathOwnerCompanyId,
|
||||||
|
isRememberableCompanyPath,
|
||||||
|
sanitizeRememberedPathForCompany,
|
||||||
|
} from "../lib/company-page-memory";
|
||||||
|
|
||||||
const STORAGE_KEY = "paperclip.companyPaths";
|
const STORAGE_KEY = "paperclip.companyPaths";
|
||||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
|
||||||
|
|
||||||
function getCompanyPaths(): Record<string, string> {
|
function getCompanyPaths(): Record<string, string> {
|
||||||
try {
|
try {
|
||||||
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
|
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.
|
* 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.
|
* Falls back to /dashboard if no page was previously visited for a company.
|
||||||
*/
|
*/
|
||||||
export function useCompanyPageMemory() {
|
export function useCompanyPageMemory() {
|
||||||
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const prevCompanyId = useRef<string | null>(selectedCompanyId);
|
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.
|
// Save current path for current company on every location change.
|
||||||
// Uses prevCompanyId ref so we save under the correct company even
|
// Uses prevCompanyId ref so we save under the correct company even
|
||||||
// during the render where selectedCompanyId has already changed.
|
// during the render where selectedCompanyId has already changed.
|
||||||
const fullPath = location.pathname + location.search;
|
const fullPath = location.pathname + location.search;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const companyId = prevCompanyId.current;
|
const companyId = rememberedPathOwnerCompanyId;
|
||||||
const relativePath = toCompanyRelativePath(fullPath);
|
const relativePath = toCompanyRelativePath(fullPath);
|
||||||
if (companyId && isRememberableCompanyPath(relativePath)) {
|
if (companyId && isRememberableCompanyPath(relativePath)) {
|
||||||
saveCompanyPath(companyId, relativePath);
|
saveCompanyPath(companyId, relativePath);
|
||||||
}
|
}
|
||||||
}, [fullPath]);
|
}, [fullPath, rememberedPathOwnerCompanyId]);
|
||||||
|
|
||||||
// Navigate to saved path when company changes
|
// Navigate to saved path when company changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
|
|||||||
) {
|
) {
|
||||||
if (selectionSource !== "route_sync" && selectedCompany) {
|
if (selectionSource !== "route_sync" && selectedCompany) {
|
||||||
const paths = getCompanyPaths();
|
const paths = getCompanyPaths();
|
||||||
const savedPath = paths[selectedCompanyId];
|
const targetPath = sanitizeRememberedPathForCompany({
|
||||||
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
|
path: paths[selectedCompanyId],
|
||||||
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
|
companyPrefix: selectedCompany.issuePrefix,
|
||||||
|
});
|
||||||
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
ui/src/lib/company-page-memory.ts
Normal file
65
ui/src/lib/company-page-memory.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user