fix(review): address PR #230 review — payload type, breadcrumb helper, tests
Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192): - export/utils: correct the misleading getInternalLinkPageName comment — a bare `v1.2` loses its last dot-segment (`v1`); dots survive only in multi-segment names like `v1.2.md` -> `v1.2`. - share: extract toPublicSharePayload(page, share): PublicSharePayload, an explicit allowlist type+mapper replacing the inline literal in the /shares/page-info anonymous path (#218). Add share.controller.spec.ts that stubs getSharedPage returning internal fields and asserts the response key set EXACTLY equals the whitelist (page + share), so any `...shareData` regression or new leaking field fails. Also key-tests the extracted mapper. - breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId) (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode, dropping the as-any casts; else null) and unit-test all three branches. - share-modal: RTL test asserting enabling a share calls mutateAsync with includeSubPages: false (#216 security default). - share.service: one-line note at getSharedPage on the deferred consolidation of the ancestor-aware match into resolveReadableSharePage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import { resolveBreadcrumbNodes } from "./breadcrumb.utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -15,6 +15,7 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import {
|
||||
usePageQuery,
|
||||
@@ -50,32 +51,16 @@ export default function Breadcrumb() {
|
||||
useEffect(() => {
|
||||
if (!currentPage) return;
|
||||
|
||||
// Prefer the sidebar tree once it actually contains this page's ancestor
|
||||
// chain — it stays live with renames/moves happening in the sidebar.
|
||||
if (treeData?.length > 0) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
if (breadcrumb) {
|
||||
setBreadcrumbNodes(breadcrumb);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise fall back to the page's own ancestor data so the breadcrumb
|
||||
// resolves immediately instead of staying blank.
|
||||
if (ancestors?.length) {
|
||||
setBreadcrumbNodes(
|
||||
(ancestors as any[]).map((node) => ({
|
||||
id: node.id,
|
||||
slugId: node.slugId,
|
||||
name: node.title,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
spaceId: node.spaceId,
|
||||
parentPageId: node.parentPageId,
|
||||
hasChildren: node.hasChildren ?? false,
|
||||
children: [],
|
||||
})) as SpaceTreeNode[],
|
||||
);
|
||||
// Selection/mapping lives in a pure, unit-tested helper (#218). Only update
|
||||
// when it resolves nodes so a transient miss keeps the prior breadcrumb
|
||||
// rather than blanking it.
|
||||
const nodes = resolveBreadcrumbNodes(
|
||||
treeData,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
);
|
||||
if (nodes) {
|
||||
setBreadcrumbNodes(nodes);
|
||||
}
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveBreadcrumbNodes } from "./breadcrumb.utils";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
|
||||
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
|
||||
// null so the component keeps its prior state.
|
||||
|
||||
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: `node-${id}`,
|
||||
icon: null,
|
||||
position: "a",
|
||||
hasChildren: false,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
children: [],
|
||||
...over,
|
||||
} as SpaceTreeNode;
|
||||
}
|
||||
|
||||
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `title-${id}`,
|
||||
icon: "📄",
|
||||
position: "m",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: true,
|
||||
...over,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe("resolveBreadcrumbNodes", () => {
|
||||
it("tree-hit: returns the path found in the live sidebar tree", () => {
|
||||
const child = treeNode("child");
|
||||
const root = treeNode("root", { hasChildren: true, children: [child] });
|
||||
// findBreadcrumbPath walks the tree; the chain ends at the target page.
|
||||
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
|
||||
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
|
||||
expect(result![result!.length - 1].icon).toBeNull();
|
||||
});
|
||||
|
||||
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
|
||||
// Tree has no node for the target page -> findBreadcrumbPath misses.
|
||||
const unrelated = treeNode("unrelated");
|
||||
const ancestors = [
|
||||
ancestorPage("a", { hasChildren: true }),
|
||||
ancestorPage("b", { hasChildren: undefined as any }),
|
||||
];
|
||||
|
||||
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
// Non-trivial field transform: title -> name.
|
||||
expect(result![0].name).toBe("title-a");
|
||||
// hasChildren defaults to false when the ancestor row omits it.
|
||||
expect(result![1].hasChildren).toBe(false);
|
||||
expect(result![0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to ancestors when the tree is empty", () => {
|
||||
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
|
||||
expect(result!.map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns null when there is no tree hit and no ancestor data", () => {
|
||||
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { findBreadcrumbPath, pageToTreeNode } from "@/features/page/tree/utils";
|
||||
|
||||
/**
|
||||
* Pure selection/mapping for the breadcrumb nodes (#218). Three branches:
|
||||
* 1. tree-hit — the lazily-built sidebar tree already contains this page's
|
||||
* ancestor chain, so prefer it (stays live with sidebar renames/moves).
|
||||
* 2. tree-miss — fall back to the page's own ancestor data so a deep page
|
||||
* resolves immediately instead of rendering a blank breadcrumb for seconds
|
||||
* while the tree backfills. Mapped through the canonical `pageToTreeNode`
|
||||
* (title -> name, hasChildren defaulted to false).
|
||||
* 3. neither — no data yet, return null so the caller keeps its prior state.
|
||||
*/
|
||||
export function resolveBreadcrumbNodes(
|
||||
treeData: SpaceTreeNode[] | null | undefined,
|
||||
ancestors: IPage[] | null | undefined,
|
||||
pageId: string,
|
||||
): SpaceTreeNode[] | null {
|
||||
if (treeData && treeData.length > 0) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) {
|
||||
return breadcrumb;
|
||||
}
|
||||
}
|
||||
|
||||
if (ancestors && ancestors.length > 0) {
|
||||
return ancestors.map((page) =>
|
||||
pageToTreeNode(page, { hasChildren: page.hasChildren ?? false }),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Enabling a public share must NOT silently expose the whole sub-tree (#216):
|
||||
// the create call defaults includeSubPages to false. This was a one-literal,
|
||||
// security-relevant default with no test — lock it.
|
||||
|
||||
const createMutateAsync = vi.fn(async () => ({}));
|
||||
const deleteMutateAsync = vi.fn(async () => ({}));
|
||||
|
||||
// No existing share for this page (toggle starts OFF).
|
||||
let shareData: any = undefined;
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useCreateShareMutation: () => ({ mutateAsync: createMutateAsync }),
|
||||
useDeleteShareMutation: () => ({ mutateAsync: deleteMutateAsync }),
|
||||
useUpdateShareMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useShareForPageQuery: () => ({ data: shareData }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useSpaceQuery: () => ({ data: { settings: {} } }),
|
||||
}));
|
||||
|
||||
import ShareModal from "./share-modal";
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MantineProvider>
|
||||
<ShareModal readOnly={false} />
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ShareModal — enabling a share defaults includeSubPages to false (#216)", () => {
|
||||
beforeEach(() => {
|
||||
createMutateAsync.mockClear();
|
||||
deleteMutateAsync.mockClear();
|
||||
shareData = undefined;
|
||||
});
|
||||
|
||||
it("creates the share with includeSubPages: false when the user turns it on", async () => {
|
||||
renderModal();
|
||||
|
||||
// Open the share popover.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share" }));
|
||||
|
||||
// The "Share to web" toggle is the only switch in the not-yet-shared state.
|
||||
const toggle = await screen.findByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(createMutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(createMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageId: "page-1",
|
||||
includeSubPages: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user