Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions templates/content/app/components/editor/database/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,4 +588,99 @@ describe("DatabaseSidebarView", () => {
expect(markup).toContain("group-hover:pointer-events-auto");
expect(markup).not.toContain("shadow-sm");
});

it("lets viewers invoke only the personal Favorite database-row action", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const onToggleFavorite = vi.fn();

await act(async () => {
root.render(
<MemoryRouter>
<TooltipProvider>
<DatabaseSidebarView
groups={[
{
id: "all",
label: "All pages",
items: [
{
...item("shared", "Shared page"),
document: {
...item("shared", "Shared page").document,
accessRole: "viewer",
canEdit: false,
canManage: false,
},
},
],
property: null,
value: "all",
},
]}
grouped={false}
isLoading={false}
hasActiveConstraints={false}
openPagesIn="full_page"
loadingLabel="Loading list"
noMatchesLabel="No rows match this view"
clearLabel="Clear"
navigationLabel="Database pages"
untitledLabel="Untitled"
onClearResultConstraints={() => {}}
onPreview={() => {}}
onCreateChildPage={() => {}}
onCreateChildDatabase={() => {}}
onDeleteItem={() => {}}
onToggleFavorite={onToggleFavorite}
/>
</TooltipProvider>
</MemoryRouter>,
);
});

const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="More actions for Shared page"]',
);
expect(trigger).toBeTruthy();
expect(
container.querySelectorAll('button[aria-haspopup="menu"]'),
).toHaveLength(1);
expect(
container.querySelector('button[aria-label="Add child to Shared page"]'),
).toBeNull();

await act(async () => {
trigger?.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
pointerType: "mouse",
}),
);
await Promise.resolve();
});

const menuItems = Array.from(
document.querySelectorAll<HTMLElement>("[role=menuitem]"),
);
expect(menuItems.map((menuItem) => menuItem.textContent?.trim())).toEqual([
"Add to favorites",
]);

await act(async () => {
menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(onToggleFavorite).toHaveBeenCalledOnce();
expect(onToggleFavorite).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.objectContaining({ id: "shared" }),
}),
);

act(() => root.unmount());
container.remove();
});
});
21 changes: 11 additions & 10 deletions templates/content/app/components/editor/database/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { Link } from "react-router";

import { documentSidebarActionAvailability } from "@/components/sidebar/document-sidebar-actions";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -506,15 +507,12 @@ function DatabaseSidebarRow({
}) {
const t = useT();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const canEdit = item.document.canEdit !== false;
const canManage =
item.document.canManage === true ||
item.document.accessRole === "owner" ||
item.document.accessRole === "admin";
const { canEdit, canManage, canFavorite, hasMenuActions } =
documentSidebarActionAvailability(item.document, {
favoriteAvailable: Boolean(onToggleFavorite),
manageAvailable: Boolean(onDeleteItem),
});
const canCreateChild = canEdit && Boolean(onCreateChildPage);
const hasMenuActions =
(canEdit && Boolean(onToggleFavorite)) ||
(canManage && Boolean(onDeleteItem));
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
if (
event.defaultPrevented ||
Expand Down Expand Up @@ -611,7 +609,7 @@ function DatabaseSidebarRow({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{canEdit && onToggleFavorite ? (
{canFavorite && onToggleFavorite ? (
<DropdownMenuItem onSelect={() => onToggleFavorite(item)}>
<IconStar
className={cn(
Expand All @@ -624,7 +622,10 @@ function DatabaseSidebarRow({
: "Add to favorites"}
</DropdownMenuItem>
) : null}
{canEdit && onToggleFavorite && canManage && onDeleteItem ? (
{canFavorite &&
onToggleFavorite &&
canManage &&
onDeleteItem ? (
<DropdownMenuSeparator />
) : null}
{canManage && onDeleteItem ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ describe("document sidebar layout", () => {
it("gates page tree actions by document capabilities", () => {
const treeItem = readSidebarSource("./DocumentTreeItem.tsx");

expect(treeItem).toContain("const canEdit = node.canEdit !== false");
expect(treeItem).toContain("const canManage =");
expect(treeItem).toContain("{canEdit && (");
expect(treeItem).toContain("favoriteAvailable: true");
expect(treeItem).toContain("{canFavorite && (");
expect(treeItem).toContain("const canCreateChild = canEdit");
expect(treeItem).toContain("{canManage && (");
});

Expand Down
227 changes: 227 additions & 0 deletions templates/content/app/components/sidebar/DocumentTreeItem.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// @vitest-environment happy-dom

import type {
Document,
DocumentAccessRole,
DocumentTreeNode,
} from "@shared/api";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";

import { TooltipProvider } from "@/components/ui/tooltip";

import { DocumentTreeItem, FavoriteDocumentItem } from "./DocumentTreeItem";

const { useSortableMock } = vi.hoisted(() => ({
useSortableMock: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
}));

vi.mock("@dnd-kit/sortable", async (importOriginal) => ({
...(await importOriginal<typeof import("@dnd-kit/sortable")>()),
useSortable: useSortableMock,
}));

vi.mock("@agent-native/creative-context/client", () => ({
CreativeContextShareSheet: () => null,
}));

function documentForRole(
accessRole: DocumentAccessRole,
isFavorite = false,
): Document {
return {
id: "shared",
parentId: null,
title: "Shared page",
content: "",
icon: null,
position: 0,
isFavorite,
hideFromSearch: false,
accessRole,
canEdit: accessRole !== "viewer",
canManage: accessRole === "owner" || accessRole === "admin",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}

async function render(node: ReactNode) {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

await act(async () => {
root.render(
<MemoryRouter>
<TooltipProvider>{node}</TooltipProvider>
</MemoryRouter>,
);
});

return { container, root };
}

function cleanup(root: Root, container: HTMLElement) {
act(() => root.unmount());
container.remove();
document.querySelectorAll("[role=menu]").forEach((menu) => menu.remove());
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false;
}

async function openActions(container: HTMLElement) {
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="More actions for Shared page"]',
);
expect(trigger).toBeTruthy();

await act(async () => {
trigger?.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
pointerType: "mouse",
}),
);
await Promise.resolve();
});

return Array.from(document.querySelectorAll<HTMLElement>("[role=menuitem]"));
}

function treeItem(
document: Document,
onToggleFavorite: (id: string, isFavorite: boolean) => void = () => {},
) {
return (
<DocumentTreeItem
node={{ ...document, children: [] } satisfies DocumentTreeNode}
depth={0}
activeId={null}
expandedIds={new Set()}
onToggleExpanded={() => {}}
onSelect={() => {}}
onCreateChildPage={() => {}}
onCreateChildDatabase={() => {}}
onDelete={() => {}}
onToggleFavorite={onToggleFavorite}
/>
);
}

describe("sidebar document permission menus", () => {
it("lets a viewer remove a page from personal Favorites and nothing else", async () => {
const onRemoveFavorite = vi.fn();
const { container, root } = await render(
<FavoriteDocumentItem
document={documentForRole("viewer", true)}
active={false}
onSelect={() => {}}
onCreateChildPage={() => {}}
onCreateChildDatabase={() => {}}
onRemoveFavorite={onRemoveFavorite}
onDelete={() => {}}
/>,
);

expect(
container.querySelector('button[aria-label="Add child to Shared page"]'),
).toBeNull();
expect(
container.querySelectorAll('button[aria-haspopup="menu"]'),
).toHaveLength(1);
const menuItems = await openActions(container);
expect(menuItems.map((item) => item.textContent?.trim())).toEqual([
"Remove from favorites",
]);

await act(async () => {
menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(onRemoveFavorite).toHaveBeenCalledOnce();

cleanup(root, container);
});

it("lets a viewer add a tree page to personal Favorites and nothing else", async () => {
const onToggleFavorite = vi.fn();
const { container, root } = await render(
treeItem(documentForRole("viewer"), onToggleFavorite),
);

expect(
container.querySelector('button[aria-label="Add child to Shared page"]'),
).toBeNull();
expect(
container.querySelectorAll('button[aria-haspopup="menu"]'),
).toHaveLength(1);
expect(
container.querySelector('[aria-label="Shared page"]')?.className,
).not.toContain("cursor-grab");
expect(useSortableMock).toHaveBeenLastCalledWith({
id: "shared",
disabled: true,
});

const menuItems = await openActions(container);
expect(menuItems.map((item) => item.textContent?.trim())).toEqual([
"Add to favorites",
]);

await act(async () => {
menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(onToggleFavorite).toHaveBeenCalledOnce();
expect(onToggleFavorite).toHaveBeenCalledWith("shared", true);

cleanup(root, container);
});

it.each([
["editor", ["Add to favorites", "Add to context"], false],
["admin", ["Add to favorites", "Add to context", "Delete"], true],
["owner", ["Add to favorites", "Add to context", "Delete"], true],
] as const)(
"preserves the existing %s tree actions",
async (role, expectedMenuItems, canManage) => {
const { container, root } = await render(treeItem(documentForRole(role)));

expect(
container.querySelectorAll('button[aria-haspopup="menu"]'),
).toHaveLength(2);
expect(
container.querySelector('[aria-label="Shared page"]')?.className,
).toContain("cursor-grab");
expect(useSortableMock).toHaveBeenLastCalledWith({
id: "shared",
disabled: false,
});

const menuItems = await openActions(container);
expect(
menuItems.map((item) => item.textContent?.trim().replace(/[.…]+$/, "")),
).toEqual(expectedMenuItems);
expect(menuItems.some((item) => item.textContent === "Delete")).toBe(
canManage,
);

cleanup(root, container);
},
);
});
Loading
Loading