From fa4d60644ef6d7eced8dcc9509c2b78362dfde74 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:42:20 -0400 Subject: [PATCH 1/2] fix(content): let viewers favorite sidebar pages --- .../editor/database/sidebar.test.tsx | 95 ++++++++ .../components/editor/database/sidebar.tsx | 21 +- .../sidebar/DocumentSidebar.layout.test.ts | 6 +- .../sidebar/DocumentTreeItem.test.tsx | 227 ++++++++++++++++++ .../components/sidebar/DocumentTreeItem.tsx | 27 +-- .../sidebar/document-sidebar-actions.test.ts | 68 ++++++ .../sidebar/document-sidebar-actions.ts | 31 +++ ...07-26-viewers-can-favorite-shared-pages.md | 5 + 8 files changed, 451 insertions(+), 29 deletions(-) create mode 100644 templates/content/app/components/sidebar/DocumentTreeItem.test.tsx create mode 100644 templates/content/app/components/sidebar/document-sidebar-actions.test.ts create mode 100644 templates/content/app/components/sidebar/document-sidebar-actions.ts create mode 100644 templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 48b5320cae..bef28b860c 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -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( + + + {}} + onPreview={() => {}} + onCreateChildPage={() => {}} + onCreateChildDatabase={() => {}} + onDeleteItem={() => {}} + onToggleFavorite={onToggleFavorite} + /> + + , + ); + }); + + const trigger = container.querySelector( + '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("[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(); + }); }); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 82a047fc21..3ac86b51da 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -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, @@ -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) { if ( event.defaultPrevented || @@ -611,7 +609,7 @@ function DatabaseSidebarRow({ - {canEdit && onToggleFavorite ? ( + {canFavorite && onToggleFavorite ? ( onToggleFavorite(item)}> ) : null} - {canEdit && onToggleFavorite && canManage && onDeleteItem ? ( + {canFavorite && + onToggleFavorite && + canManage && + onDeleteItem ? ( ) : null} {canManage && onDeleteItem ? ( diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 515d8ec80e..937ac4c82f 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -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 && ("); }); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx new file mode 100644 index 0000000000..f21a03172c --- /dev/null +++ b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx @@ -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()), + 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( + + {node} + , + ); + }); + + 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( + '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("[role=menuitem]")); +} + +function treeItem( + document: Document, + onToggleFavorite: (id: string, isFavorite: boolean) => void = () => {}, +) { + return ( + {}} + 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( + {}} + 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); + }, + ); +}); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index 17f07a7d35..69ef8b9b7c 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -43,6 +43,8 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { documentSidebarActionAvailability } from "./document-sidebar-actions"; + interface DocumentTreeItemProps { node: DocumentTreeNode; depth: number; @@ -109,11 +111,8 @@ export function FavoriteDocumentItem({ }) { const t = useT(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const canEdit = document.canEdit !== false; - const canManage = - document.canManage === true || - document.accessRole === "owner" || - document.accessRole === "admin"; + const { canEdit, canManage, canFavorite, hasMenuActions } = + documentSidebarActionAvailability(document, { favoriteAvailable: true }); const canCreateChild = canEdit && document.source?.mode !== "local-files"; const title = document.title || t("sidebar.untitled"); @@ -154,7 +153,7 @@ export function FavoriteDocumentItem({ onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - {(canEdit || canManage) && ( + {hasMenuActions && (