diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 4e48347927..f76877c6ac 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -840,4 +840,121 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain("group-hover:pointer-events-auto"); expect(markup).not.toContain("shadow-sm"); }); + + it("keeps the viewer add-child slot disabled beside the personal pin action", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onToggleFavorite = vi.fn(); + const onCreateChildPage = vi.fn(); + const onCreateChildDatabase = vi.fn(); + + await act(async () => { + root.render( + + + {}} + onPreview={() => {}} + onCreateChildPage={onCreateChildPage} + onCreateChildDatabase={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); + const addChild = container.querySelector( + 'button[aria-label="Add child to Shared page"]', + ); + if (!trigger || !addChild) { + throw new Error("Expected aligned viewer sidebar controls"); + } + expect(addChild.disabled).toBe(true); + expect(addChild.className).toContain("size-6"); + expect(addChild.className).toContain("text-muted-foreground/50"); + expect(trigger.compareDocumentPosition(addChild)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + addChild.focus(); + addChild.click(); + addChild.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + addChild.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: " " }), + ); + expect(document.activeElement).not.toBe(addChild); + expect(onCreateChildPage).not.toHaveBeenCalled(); + expect(onCreateChildDatabase).not.toHaveBeenCalled(); + + 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([ + "Pin to sidebar", + ]); + + 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 7632325553..097495a24b 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -21,6 +21,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 { Button } from "@/components/ui/button"; import { Collapsible, @@ -717,14 +718,12 @@ function DatabaseSidebarRow({ }; }) { const t = useT(); - 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 = - Boolean(onToggleFavorite) || (canManage && Boolean(onDeleteItem)); function handleClick(event: MouseEvent) { if ( event.defaultPrevented || @@ -828,7 +827,7 @@ function DatabaseSidebarRow({ - {onToggleFavorite ? ( + {canFavorite && onToggleFavorite ? ( onToggleFavorite(item)}> ) : null} - {onToggleFavorite && canManage && onDeleteItem ? ( + {canFavorite && + onToggleFavorite && + canManage && + onDeleteItem ? ( ) : null} {canManage && onDeleteItem ? ( @@ -857,7 +859,7 @@ function DatabaseSidebarRow({ )} - {canCreateChild && ( + {canCreateChild ? ( @@ -866,6 +868,7 @@ function DatabaseSidebarRow({ type="button" className="flex size-6 items-center justify-center rounded text-foreground hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={t("sidebar.addChildTo", { title })} + data-sidebar-add-child > @@ -888,6 +891,16 @@ function DatabaseSidebarRow({ ) : null} + ) : ( + )} )} diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 48e93c472f..a4705859bb 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..f5c1b6f9fb --- /dev/null +++ b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom + +import { AgentNativeI18nProvider } from "@agent-native/core/client/i18n"; +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 } 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 = () => {}, + onCreateChildPage: (id: string) => void = () => {}, + onCreateChildDatabase: (id: string) => void = () => {}, +) { + return ( + {}} + onSelect={() => {}} + onCreateChildPage={onCreateChildPage} + onCreateChildDatabase={onCreateChildDatabase} + onDelete={() => {}} + onToggleFavorite={onToggleFavorite} + /> + ); +} + +describe("sidebar document permission menus", () => { + it("keeps the tree-row add-child slot disabled beside Pin", async () => { + const onToggleFavorite = vi.fn(); + const onCreateChildPage = vi.fn(); + const onCreateChildDatabase = vi.fn(); + const { container, root } = await render( + treeItem( + documentForRole("viewer"), + onToggleFavorite, + onCreateChildPage, + onCreateChildDatabase, + ), + ); + + const moreActions = container.querySelector( + 'button[aria-label="More actions for Shared page"]', + ); + const addChild = container.querySelector( + 'button[aria-label="Add child to Shared page"]', + ); + if (!moreActions || !addChild) { + throw new Error("Expected aligned viewer sidebar controls"); + } + expect(addChild.disabled).toBe(true); + expect(addChild.className).toContain("h-7 w-7"); + expect(addChild.className).toContain("text-muted-foreground/50"); + expect(moreActions.compareDocumentPosition(addChild)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + 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, + }); + addChild.focus(); + addChild.click(); + addChild.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }), + ); + addChild.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, key: " " }), + ); + expect(document.activeElement).not.toBe(addChild); + expect(onCreateChildPage).not.toHaveBeenCalled(); + expect(onCreateChildDatabase).not.toHaveBeenCalled(); + + const menuItems = await openActions(container); + expect(menuItems.map((item) => item.textContent?.trim())).toEqual([ + "Pin to sidebar", + ]); + + 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", ["Pin to sidebar", "Add to context"], false], + ["admin", ["Pin to sidebar", "Add to context", "Delete"], true], + ["owner", ["Pin to sidebar", "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 5f803ddcc7..f175f2a0b3 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -33,6 +33,8 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { documentSidebarActionAvailability } from "./document-sidebar-actions"; + interface DocumentTreeItemProps { node: DocumentTreeNode; depth: number; @@ -97,12 +99,8 @@ export function DocumentTreeItem({ const isActive = node.id === activeId; const isLocalFileNode = node.source?.mode === "local-files"; const isLocalFolder = isLocalFileNode && node.source?.kind === "folder"; - const canEdit = node.canEdit !== false; - const canManage = - node.canManage === true || - node.accessRole === "owner" || - node.accessRole === "admin"; - const hasMenuActions = canEdit || canManage; + const { canEdit, canManage, canFavorite, hasMenuActions } = + documentSidebarActionAvailability(node, { favoriteAvailable: true }); const canCreateChild = canEdit && !isLocalFileNode; const [contextSheetOpen, setContextSheetOpen] = useState(false); const indent = depth * 12 + 12; @@ -224,7 +222,7 @@ export function DocumentTreeItem({ - {canEdit && ( + {canFavorite && ( { e.stopPropagation(); @@ -240,7 +238,7 @@ export function DocumentTreeItem({ : t("sidebar.pinToSidebar")} )} - {canEdit && canManage && } + {canFavorite && canManage && } {canEdit && !isLocalFileNode && ( { @@ -269,7 +267,7 @@ export function DocumentTreeItem({ )} - {canCreateChild && ( + {canCreateChild ? ( @@ -280,6 +278,7 @@ export function DocumentTreeItem({ aria-label={t("sidebar.addChildTo", { title: node.title || t("sidebar.untitled"), })} + data-sidebar-add-child onClick={(e) => e.stopPropagation()} > @@ -309,6 +308,18 @@ export function DocumentTreeItem({ + ) : ( + )} diff --git a/templates/content/app/components/sidebar/document-sidebar-actions.test.ts b/templates/content/app/components/sidebar/document-sidebar-actions.test.ts new file mode 100644 index 0000000000..7ee7376b40 --- /dev/null +++ b/templates/content/app/components/sidebar/document-sidebar-actions.test.ts @@ -0,0 +1,68 @@ +import type { Document } from "@shared/api"; +import { describe, expect, it } from "vitest"; + +import { documentSidebarActionAvailability } from "./document-sidebar-actions"; + +function access( + accessRole: NonNullable, +): Pick { + return { + accessRole, + canEdit: accessRole !== "viewer", + canManage: accessRole === "owner" || accessRole === "admin", + }; +} + +describe("document sidebar action availability", () => { + it("gives viewers only the personal Favorite menu action", () => { + expect( + documentSidebarActionAvailability(access("viewer"), { + favoriteAvailable: true, + }), + ).toEqual({ + canEdit: false, + canManage: false, + canFavorite: true, + hasMenuActions: true, + }); + }); + + it.each(["editor", "admin", "owner"] as const)( + "preserves the existing %s capabilities", + (role) => { + expect( + documentSidebarActionAvailability(access(role), { + favoriteAvailable: true, + }), + ).toEqual({ + canEdit: true, + canManage: role === "admin" || role === "owner", + canFavorite: true, + hasMenuActions: true, + }); + }, + ); + + it("does not invent a menu when no Favorite callback or shared action exists", () => { + expect( + documentSidebarActionAvailability(access("viewer"), { + favoriteAvailable: false, + }), + ).toMatchObject({ + canFavorite: false, + hasMenuActions: false, + }); + }); + + it("does not show an empty management menu without a delete callback", () => { + expect( + documentSidebarActionAvailability(access("owner"), { + favoriteAvailable: false, + manageAvailable: false, + }), + ).toMatchObject({ + canManage: true, + hasMenuActions: false, + }); + }); +}); diff --git a/templates/content/app/components/sidebar/document-sidebar-actions.ts b/templates/content/app/components/sidebar/document-sidebar-actions.ts new file mode 100644 index 0000000000..5433ac0e8c --- /dev/null +++ b/templates/content/app/components/sidebar/document-sidebar-actions.ts @@ -0,0 +1,31 @@ +import type { Document } from "@shared/api"; + +type SidebarDocumentAccess = Pick< + Document, + "accessRole" | "canEdit" | "canManage" +>; + +export function documentSidebarActionAvailability( + document: SidebarDocumentAccess, + { + favoriteAvailable, + manageAvailable = true, + }: { + favoriteAvailable: boolean; + manageAvailable?: boolean; + }, +) { + const canEdit = document.canEdit !== false; + const canManage = + document.canManage === true || + document.accessRole === "owner" || + document.accessRole === "admin"; + const canFavorite = favoriteAvailable; + + return { + canEdit, + canManage, + canFavorite, + hasMenuActions: canFavorite || (canManage && manageAvailable), + }; +} diff --git a/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md b/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md new file mode 100644 index 0000000000..d54f47232f --- /dev/null +++ b/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-26 +--- + +View-only collaborators can pin or unpin shared pages while the unavailable add-child control stays aligned and visibly disabled.