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 && (
- {canEdit && (
+ {canFavorite && (
{
event.stopPropagation();
@@ -178,7 +177,7 @@ export function FavoriteDocumentItem({
{t("sidebar.removeFromFavorites")}
)}
- {canEdit && canManage && }
+ {canFavorite && canManage && }
{canManage && (
- {canEdit && (
+ {canFavorite && (
{
e.stopPropagation();
@@ -423,7 +418,7 @@ export function DocumentTreeItem({
: "Add to favorites"}
)}
- {canEdit && canManage && }
+ {canFavorite && canManage && }
{canEdit && !isLocalFileNode && (
{
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..5193cabd74
--- /dev/null
+++ b/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md
@@ -0,0 +1,5 @@
+---
+type: fixed
+date: 2026-07-26
+---
+View-only collaborators can add or remove shared pages from their personal Favorites in the sidebar.
From d8ccf03bc63ee856b19b23e50d0b29ec159f4bb0 Mon Sep 17 00:00:00 2001
From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com>
Date: Sun, 26 Jul 2026 09:44:59 -0400
Subject: [PATCH 2/2] chore(content): format viewer favorite changelog
---
.../changelog/2026-07-26-viewers-can-favorite-shared-pages.md | 1 +
1 file changed, 1 insertion(+)
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
index 5193cabd74..b1956d42f6 100644
--- 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
@@ -2,4 +2,5 @@
type: fixed
date: 2026-07-26
---
+
View-only collaborators can add or remove shared pages from their personal Favorites in the sidebar.