diff --git a/playwright/e2e/page/shared-private-page-subpage.spec.ts b/playwright/e2e/page/shared-private-page-subpage.spec.ts new file mode 100644 index 000000000..e740f779f --- /dev/null +++ b/playwright/e2e/page/shared-private-page-subpage.spec.ts @@ -0,0 +1,513 @@ +import { randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'; + +import { signInWithPasswordViaUi } from '../../support/auth-flow-helpers'; +import { createConfirmedPasswordUser } from '../../support/auth-utils'; +import { grantTestWorkspaceProSubscription } from '../../support/subscription-test-helpers'; +import { TestConfig } from '../../support/test-config'; + +type ViewNode = { + view_id: string; + parent_view_id?: string; + access_level?: number; + name?: string; + children: ViewNode[]; +}; + +async function successful(response: Pick, operation: string): Promise { + expect(response.ok(), `${operation}: HTTP ${response.status()}`).toBe(true); + const body = await response.json(); + + // Do not include auth responses or tokens in test artifacts. + expect(body.code, `${operation}: ${body.message ?? ''}`).toBe(0); + return body.data as T; +} + +async function createAccount(request: APIRequestContext, email: string, password: string) { + await createConfirmedPasswordUser(request, email, password); + const login = async () => { + const response = await request.post(`${TestConfig.gotrueUrl}/token?grant_type=password`, { + data: { email, password }, + }); + + expect(response.ok(), `Password sign-in: HTTP ${response.status()}`).toBe(true); + const body = await response.json(); + + expect(typeof body.access_token).toBe('string'); + return body.access_token as string; + }; + + const token = await login(); + + await successful(await request.get(`${TestConfig.apiUrl}/api/user/verify/${token}`), 'Register local test user'); + // Verification may consume the login session; use a new one for fixture requests. + return login(); +} + +function findView(root: ViewNode, id: string): ViewNode | undefined { + if (root.view_id === id) return root; + for (const child of root.children ?? []) { + const found = findView(child, id); + + if (found) return found; + } +} + +for (const scenario of [ + { label: 'Full Access', accessLevel: 50, canWrite: true, canDelete: true }, + { label: 'Can edit', accessLevel: 30, canWrite: true, canDelete: false }, + { label: 'Read Only', accessLevel: 10, canWrite: false, canDelete: false }, +]) { + test(`member with ${scenario.label} to a private-space document ${scenario.canWrite ? 'can' : 'cannot'} add and ${ + scenario.canDelete ? 'can' : 'cannot' + } trash a subpage from Shared with me`, async ({ page, request }, testInfo) => { + test.setTimeout(180_000); + const runId = randomUUID(); + const password = `C12-repro-${randomUUID()}!`; + const ownerEmail = `c12-owner-${runId}@example.test`; + const memberEmail = `c12-member-${runId}@example.test`; + const ownerToken = await createAccount(request, ownerEmail, password); + const memberToken = await createAccount(request, memberEmail, password); + const ownerHeaders = { Authorization: `Bearer ${ownerToken}` }; + const memberHeaders = { Authorization: `Bearer ${memberToken}` }; + const ownerInfo = await successful<{ visiting_workspace: { workspace_id: string } }>( + await request.get(`${TestConfig.apiUrl}/api/user/workspace`, { headers: ownerHeaders }), + 'Read owner workspace' + ); + const workspaceId = ownerInfo.visiting_workspace.workspace_id; + const workspaceUrl = `${TestConfig.apiUrl}/api/workspace/${workspaceId}`; + + // Use the existing CI fixture entitlement so the hosted Free-plan member + // limit cannot block setup. Only this newly created workspace is changed. + grantTestWorkspaceProSubscription(workspaceId); + + await successful( + await request.post(`${workspaceUrl}/invite`, { + headers: ownerHeaders, + data: [{ email: memberEmail, role: 'Member', auto_accept: true, skip_email_send: true }], + }), + 'Invite regular workspace member' + ); + const memberInfo = await successful<{ workspaces: { workspace_id: string; role: string }[] }>( + await request.get(`${TestConfig.apiUrl}/api/user/workspace`, { headers: memberHeaders }), + 'Verify workspace membership' + ); + + expect(memberInfo.workspaces.find((workspace) => workspace.workspace_id === workspaceId)?.role).toBe('Member'); + const privateSpace = await successful<{ view_id: string }>( + await request.post(`${workspaceUrl}/spaces`, { + headers: ownerHeaders, + data: { + name: 'C12 private space', + space_icon: '', + space_icon_color: '', + permission: { visibility: 'private' }, + }, + }), + 'Create owner-only private space' + ); + const createDocument = async (token: string, parentId: string, name: string) => { + const viewId = randomUUID(); + const requestId = randomUUID(); + // Use the same atomic default-document endpoint as current PageService.add. + const data = { + schema_version: 'v1', + request_id: requestId, + view_id: viewId, + parent_view_id: parentId, + position: { kind: 'start' }, + name, + }; + + for (let attempt = 0; attempt < 16; attempt++) { + const response = await request.post(`${workspaceUrl}/v2/page-view/document/default`, { + headers: { Authorization: `Bearer ${token}` }, + data, + }); + + // Current Web retries explicit RetryLater with the exact same request. + // Permission failures and other errors must fail the fixture immediately. + if (response.ok() && (await response.json()).code === -5 && attempt < 15) { + await delay(500); + continue; + } + + const receipt = await successful<{ request_id: string; result: { view_id: string } }>( + response, + `Create ${name}` + ); + + expect(receipt.request_id).toBe(requestId); + expect(receipt.result.view_id).toBe(viewId); + return receipt.result; + } + + throw new Error('Document creation exhausted its retry budget'); + }; + + const parent = await createDocument(ownerToken, privateSpace.view_id, 'C12 shared document'); + const sibling = await createDocument(ownerToken, privateSpace.view_id, 'Owner-only sibling'); + + await successful( + await request.put(`${TestConfig.apiUrl}/api/sharing/workspace/${workspaceId}/view`, { + headers: ownerHeaders, + data: { view_id: parent.view_id, emails: [memberEmail], access_level: scenario.accessLevel, auto_confirm: true }, + }), + `Share only the document with ${scenario.label}` + ); + const permission = await successful<{ can_read: boolean; can_write: boolean }>( + await request.get(`${workspaceUrl}/collab/${parent.view_id}/permission?collab_type=0`, { + headers: memberHeaders, + }), + 'Read canonical document permission' + ); + + expect(permission).toMatchObject({ can_read: true, can_write: scenario.canWrite }); + + // The write case proves page-only permission is sufficient at the real API. + // The read case gets an owner-created child so both projections have the same shape. + const child = await createDocument( + scenario.canWrite ? memberToken : ownerToken, + parent.view_id, + scenario.canWrite ? 'Member-created API control' : 'Owner-created read-only child' + ); + const canonicalParent = await successful( + await request.get(`${workspaceUrl}/view/${parent.view_id}?depth=2`, { headers: ownerHeaders }), + 'Read canonical parent as owner' + ); + + expect(canonicalParent.parent_view_id).toBe(privateSpace.view_id); + expect(findView(canonicalParent, child.view_id)?.parent_view_id).toBe(parent.view_id); + const sharedTree = await successful( + await request.get(`${TestConfig.apiUrl}/api/sharing/workspace/${workspaceId}/view/${workspaceId}?depth=50`, { + headers: memberHeaders, + }), + 'Read member Shared with me projection' + ); + + expect(findView(sharedTree, parent.view_id)).toMatchObject({ + access_level: scenario.accessLevel, + parent_view_id: workspaceId, + }); + expect(findView(sharedTree, child.view_id)?.parent_view_id).toBe(parent.view_id); + expect(findView(sharedTree, privateSpace.view_id)).toBeUndefined(); + expect(findView(sharedTree, sibling.view_id)).toBeUndefined(); + for (const viewId of [privateSpace.view_id, sibling.view_id]) { + const response = await request.get(`${workspaceUrl}/view/${viewId}`, { headers: memberHeaders }); + const body = await response.json(); + + expect(body.code, 'Creating a child must not grant access to the private space or its unrelated pages').toBe(1012); + } + + const evidencePath = testInfo.outputPath('fixture-and-permissions.json'); + + await writeFile( + evidencePath, + JSON.stringify({ workspaceId, privateSpace, parent, child, sibling, permission, sharedTree }, null, 2) + ); + await testInfo.attach('fixture-and-permissions', { + path: evidencePath, + contentType: 'application/json', + }); + + await signInWithPasswordViaUi(page, memberEmail, password); + await page.goto(`/app/${workspaceId}/${parent.view_id}`); + await expect(page.locator(`#editor-${parent.view_id}`)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Shared with me', { exact: true })).toBeVisible(); + await expect(page.getByTestId(`space-${privateSpace.view_id}`)).toHaveCount(0); + await expect(page.getByTestId(`page-${sibling.view_id}`)).toHaveCount(0); + const sharedRow = page.getByTestId(`page-${parent.view_id}`); + const expandSidebarRow = async (viewId: string) => { + const row = page.getByTestId(`page-${viewId}`); + + await expect(row).toBeVisible(); + const expand = row.getByTestId('outline-toggle-expand'); + + if (await expand.isVisible()) await expand.click(); + await expect(row.getByTestId('outline-toggle-collapse')).toBeVisible(); + }; + + await expect(sharedRow).toBeVisible(); + await expandSidebarRow(parent.view_id); + await expect(page.getByTestId(`page-${child.view_id}`)).toBeVisible(); + await sharedRow.hover(); + const saveScreenshot = async (name: string) => { + const path = testInfo.outputPath(`${name}.png`); + + await writeFile(path, await page.screenshot({ animations: 'disabled' })); + await testInfo.attach(name, { path, contentType: 'image/png' }); + }; + + const openSharedMenu = async (viewId: string) => { + // Navigation can replace a shared row after the pointer enters it. Retry + // the non-mutating hover/menu action so a replaced row receives mouseenter. + await expect(async () => { + const row = page.getByTestId(`page-${viewId}`); + + await row.hover(); + await row.getByTestId('shared-page-more-actions').click({ timeout: 2_000 }); + await expect(page.getByRole('menu')).toBeVisible(); + }).toPass({ timeout: 15_000 }); + }; + + const verifyTrashDenied = async () => { + await openSharedMenu(child.view_id); + await expect(page.getByTestId('shared-move-to-trash')).toHaveCount(0); + await saveScreenshot(`${scenario.accessLevel}-shared-subpage-without-trash-action`); + await page.keyboard.press('Escape'); + const denied = await request.post(`${workspaceUrl}/page-view/${child.view_id}/move-to-trash`, { + headers: memberHeaders, + }); + + expect((await denied.json()).code, `${scenario.label} sharing must not authorize trashing a subpage`).toBe(1012); + const unchanged = await successful( + await request.get(`${workspaceUrl}/view/${parent.view_id}?depth=2`, { headers: ownerHeaders }), + 'Verify rejected deletion preserves the canonical child' + ); + + expect(unchanged.parent_view_id).toBe(privateSpace.view_id); + expect(findView(unchanged, child.view_id)?.parent_view_id).toBe(parent.view_id); + }; + + if (!scenario.canWrite) { + await expect(sharedRow.getByTestId('inline-add-page')).toHaveCount(0); + await saveScreenshot('read-only-shared-document-without-add-action'); + const deniedViewId = randomUUID(); + const response = await request.post(`${workspaceUrl}/v2/page-view/document/default`, { + headers: memberHeaders, + data: { + schema_version: 'v1', + request_id: randomUUID(), + view_id: deniedViewId, + parent_view_id: parent.view_id, + position: { kind: 'start' }, + name: 'Must not be created by read-only member', + }, + }); + + expect((await response.json()).code, 'Read-only sharing must not authorize direct child creation').toBe(1012); + const unchangedParent = await successful( + await request.get(`${workspaceUrl}/view/${parent.view_id}?depth=2`, { headers: ownerHeaders }), + 'Verify rejected creation did not add a child' + ); + + expect(findView(unchangedParent, deniedViewId)).toBeUndefined(); + expect(unchangedParent.children.map((view) => view.view_id)).toEqual([child.view_id]); + await verifyTrashDenied(); + return; + } + + const addButton = sharedRow.getByTestId('inline-add-page'); + + await expect(addButton, 'Write access to the shared document should expose its add-subpage action').toBeVisible(); + if (!scenario.canDelete) { + await verifyTrashDenied(); + return; + } + + await saveScreenshot('full-access-shared-document-with-add-action'); + const createFromSidebar = async (parentId: string, title: string) => { + const targetRow = page.getByTestId(`page-${parentId}`); + const addDocumentButton = page.getByTestId('add-document-button'); + + // Expanding the tree can move a descendant away from the pointer after + // hover, hiding its Add button. Re-hover while opening the menu settles; + // the page-creation click stays outside this retry so it runs only once. + await expect(async () => { + if (await addDocumentButton.isVisible()) return; + + await targetRow.hover(); + await targetRow.getByTestId('inline-add-page').click({ timeout: 2_000 }); + await expect(addDocumentButton).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + const created = page.waitForResponse(async (response) => { + if ( + response.request().method() !== 'POST' || + !response.url().endsWith('/v2/page-view/document/default') || + response.request().postDataJSON().parent_view_id !== parentId + ) + return false; + // The current client can retry RetryLater; wait for its committed receipt. + return response.ok() && (await response.json()).code === 0; + }); + + await addDocumentButton.click(); + const createResponse = await created; + const createCommand = createResponse.request().postDataJSON(); + const receipt = await successful<{ request_id: string; result: { view_id: string } }>( + createResponse, + 'Create subpage through the Shared with me menu' + ); + const viewId = receipt.result.view_id; + + expect(receipt.request_id).toBe(createCommand.request_id); + expect(viewId).toBe(createCommand.view_id); + expect(createCommand.parent_view_id).toBe(parentId); + const viewModal = page.getByRole('dialog').filter({ has: page.getByTestId('view-modal-close') }); + const titleInput = viewModal.locator(`[id="editor-title-${viewId}"]`); + + await expect(viewModal.locator(`[id="editor-${viewId}"]`)).toBeVisible({ timeout: 30_000 }); + await expect(titleInput).toBeVisible(); + const renamed = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().endsWith(`/page-view/${viewId}/update-name`) && + response.request().postDataJSON().name === title + ); + + await titleInput.fill(title); + await titleInput.press('Enter'); + await successful(await renamed, 'Persist member-created subpage title'); + await expect(titleInput).toHaveText(title); + await viewModal.getByTestId('view-modal-close').click(); + await expect(page.getByTestId(`page-${viewId}`).getByTestId('page-name')).toHaveText(title); + return { viewId, createCommand, receipt }; + }; + + const createdTitle = `C12 member subpage ${runId.slice(0, 8)}`; + const created = await createFromSidebar(parent.view_id, createdTitle); + const createdViewId = created.viewId; + const createdRow = page.getByTestId(`page-${createdViewId}`); + + expect(createdViewId).not.toBe(child.view_id); + await createdRow.getByTestId('page-name').click(); + await expect(page).toHaveURL(new RegExp(`/app/${workspaceId}/${createdViewId}`)); + await expect(page.locator(`[id="editor-title-${createdViewId}"]`)).toHaveText(createdTitle); + await page.reload(); + await expect(page.locator(`[id="editor-${createdViewId}"]`)).toBeVisible({ timeout: 30_000 }); + await expect(page.locator(`[id="editor-title-${createdViewId}"]`)).toHaveText(createdTitle); + await expect(page.getByTestId(`space-${privateSpace.view_id}`)).toHaveCount(0); + await expect(page.getByTestId(`page-${sibling.view_id}`)).toHaveCount(0); + // Shared tree expansion is local UI state and resets on a full reload. + await expandSidebarRow(parent.view_id); + + // A shared descendant must use its own ID as the parent, not the virtual + // Shared-with-me root or the top-level explicitly shared document. + const grandchildTitle = `C12 nested member subpage ${runId.slice(0, 8)}`; + const grandchild = await createFromSidebar(createdViewId, grandchildTitle); + const grandchildRow = page.getByTestId(`page-${grandchild.viewId}`); + + expect(grandchild.viewId).not.toBe(createdViewId); + await grandchildRow.getByTestId('page-name').click(); + await expect(page).toHaveURL(new RegExp(`/app/${workspaceId}/${grandchild.viewId}`)); + await page.reload(); + await expect(page.locator(`[id="editor-${grandchild.viewId}"]`)).toBeVisible({ timeout: 30_000 }); + await expect(page.locator(`[id="editor-title-${grandchild.viewId}"]`)).toHaveText(grandchildTitle); + await expandSidebarRow(parent.view_id); + await expandSidebarRow(createdViewId); + await expect(createdRow.getByTestId('page-name')).toHaveText(createdTitle); + await expect(grandchildRow.getByTestId('page-name')).toHaveText(grandchildTitle); + + const persistedParent = await successful( + await request.get(`${workspaceUrl}/view/${parent.view_id}?depth=3`, { headers: ownerHeaders }), + 'Read canonical parent after browser creation and reload' + ); + const persistedSharedTree = await successful( + await request.get(`${TestConfig.apiUrl}/api/sharing/workspace/${workspaceId}/view/${workspaceId}?depth=50`, { + headers: memberHeaders, + }), + 'Read persisted Shared with me projection' + ); + + expect(persistedParent.parent_view_id).toBe(privateSpace.view_id); + expect(findView(persistedParent, createdViewId)).toMatchObject({ + parent_view_id: parent.view_id, + name: createdTitle, + }); + expect(findView(persistedSharedTree, parent.view_id)?.parent_view_id).toBe(workspaceId); + expect(findView(persistedSharedTree, createdViewId)).toMatchObject({ + parent_view_id: parent.view_id, + name: createdTitle, + }); + for (const tree of [persistedParent, persistedSharedTree]) { + expect(findView(tree, grandchild.viewId)).toMatchObject({ + parent_view_id: createdViewId, + name: grandchildTitle, + }); + } + + expect(findView(persistedSharedTree, privateSpace.view_id)).toBeUndefined(); + expect(findView(persistedSharedTree, sibling.view_id)).toBeUndefined(); + for (const viewId of [privateSpace.view_id, sibling.view_id]) { + const response = await request.get(`${workspaceUrl}/view/${viewId}`, { headers: memberHeaders }); + + expect((await response.json()).code, 'Browser child creation must preserve private-space isolation').toBe(1012); + } + + await saveScreenshot('member-created-child-and-grandchild-after-reload'); + await testInfo.attach('browser-creation-and-canonical-parent', { + body: JSON.stringify({ created, grandchild, persistedParent, persistedSharedTree }, null, 2), + contentType: 'application/json', + }); + + // Delete through the exact nested row's menu. The unrelated shared parent + // stays open while its child and grandchild move to Trash together. + await sharedRow.getByTestId('page-name').click(); + await expect(page.locator(`[id="editor-${parent.view_id}"]`)).toBeVisible(); + await openSharedMenu(createdViewId); + await expect(page.getByTestId('shared-move-to-trash')).toBeVisible(); + await saveScreenshot('full-access-shared-subpage-with-trash-action'); + const trashed = page.waitForResponse( + (response) => + response.request().method() === 'POST' && response.url().endsWith(`/page-view/${createdViewId}/move-to-trash`) + ); + + // Unpublished documents use the existing confirmation flow's immediate path. + await page.getByTestId('shared-move-to-trash').click(); + await successful(await trashed, 'Move the member-created subpage to Trash through its shared menu'); + await expect(createdRow).toHaveCount(0); + await expect(grandchildRow).toHaveCount(0); + const parentAfterTrash = await successful( + await request.get(`${workspaceUrl}/view/${parent.view_id}?depth=3`, { headers: ownerHeaders }), + 'Verify canonical owner hierarchy after member deletion' + ); + const sharedAfterTrash = await successful( + await request.get(`${TestConfig.apiUrl}/api/sharing/workspace/${workspaceId}/view/${workspaceId}?depth=50`, { + headers: memberHeaders, + }), + 'Verify member hierarchy after deletion' + ); + const trash = await successful<{ views: ViewNode[] }>( + await request.get(`${workspaceUrl}/trash`, { headers: ownerHeaders }), + 'Verify deleted subtree is present in server Trash' + ); + + expect(parentAfterTrash.parent_view_id).toBe(privateSpace.view_id); + for (const tree of [parentAfterTrash, sharedAfterTrash]) { + expect(findView(tree, createdViewId)).toBeUndefined(); + expect(findView(tree, grandchild.viewId)).toBeUndefined(); + expect(findView(tree, child.view_id)?.parent_view_id).toBe(parent.view_id); + } + + expect(trash.views.some((view) => view.view_id === createdViewId)).toBe(true); + expect(trash.views.some((view) => view.view_id === parent.view_id || view.view_id === child.view_id)).toBe(false); + for (const viewId of [privateSpace.view_id, sibling.view_id]) { + const response = await request.get(`${workspaceUrl}/view/${viewId}`, { headers: memberHeaders }); + + expect((await response.json()).code, 'Trashing a shared child must preserve private-space isolation').toBe(1012); + } + + await page.reload(); + await expect(page.locator(`[id="editor-${parent.view_id}"]`)).toBeVisible({ timeout: 30_000 }); + await expandSidebarRow(parent.view_id); + await expect(page.getByTestId(`page-${child.view_id}`)).toBeVisible(); + await expect(createdRow).toHaveCount(0); + await expect(grandchildRow).toHaveCount(0); + await expect(page.getByTestId(`space-${privateSpace.view_id}`)).toHaveCount(0); + await expect(page.getByTestId(`page-${sibling.view_id}`)).toHaveCount(0); + await saveScreenshot('member-deleted-subpage-after-reload'); + const trashEvidencePath = testInfo.outputPath('shared-subpage-trash-and-canonical-parent.json'); + + await writeFile( + trashEvidencePath, + JSON.stringify({ created, grandchild, parentAfterTrash, sharedAfterTrash, trash }, null, 2) + ); + await testInfo.attach('shared-subpage-trash-and-canonical-parent', { + path: trashEvidencePath, + contentType: 'application/json', + }); + }); +} diff --git a/src/components/app/hooks/__tests__/usePageOperations.sharedSubpageCreation.test.tsx b/src/components/app/hooks/__tests__/usePageOperations.sharedSubpageCreation.test.tsx new file mode 100644 index 000000000..3ab7c2056 --- /dev/null +++ b/src/components/app/hooks/__tests__/usePageOperations.sharedSubpageCreation.test.tsx @@ -0,0 +1,355 @@ +import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { type MutableRefObject, type ReactNode } from 'react'; +import { toast } from 'sonner'; + +import { PageService, ViewService } from '@/application/services/domains'; +import { AccessLevel, Role, View, ViewIconType, ViewLayout } from '@/application/types'; +import { findView, findViewInShareWithMe } from '@/components/_shared/outline/utils'; +import { AuthInternalContext, AuthInternalContextType } from '@/components/app/contexts/AuthInternalContext'; +import RenameModal from '@/components/app/view-actions/RenameModal'; +import { Log } from '@/utils/log'; + +import { usePageOperations } from '../usePageOperations'; + +jest.mock('sonner', () => ({ toast: { error: jest.fn(), warning: jest.fn() } })); + +jest.mock('@/application/services/domains', () => ({ + BillingService: {}, + FileService: {}, + PageService: { add: jest.fn(), updateName: jest.fn(), update: jest.fn(), updateIcon: jest.fn() }, + PublishService: {}, + ViewService: { invalidateCache: jest.fn() }, +})); + +jest.mock('@/application/services/js-services/cache', () => ({ deleteView: jest.fn() })); +jest.mock('@/application/services/js-services/cached-api', () => ({ clearPublishViewInfoCache: jest.fn() })); +jest.mock('@/application/services/js-services/http/publish-api', () => ({ publishCollabs: jest.fn() })); +jest.mock('@/application/services/js-services/publish-database-data', () => ({ gatherDatabasePublishData: jest.fn() })); + +// Synthetic identities model the same page in canonical and shared navigation. +const WORKSPACE_ID = 'c2b2e8a3-1df1-49d1-a451-deee5b7d0b7c'; +const SHARED_PAGE_ID = '7f5cd6d8-c6e8-4355-9427-a0b746f02826'; +const PRIVATE_SPACE_ID = '11111111-1111-4111-8111-111111111111'; +const CHILD_PAGE_ID = '33333333-3333-4333-8333-333333333333'; + +function createView(overrides: Partial): View { + return { + view_id: SHARED_PAGE_ID, + name: 'FullAccess shared document', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: true, + ...overrides, + }; +} + +function renderWithPage( + sharedWithMe: boolean, + { role = Role.Member, accessLevel = AccessLevel.FullAccess }: { role?: Role; accessLevel?: AccessLevel } = {} +) { + const parent = createView({ + access_level: accessLevel, + parent_view_id: sharedWithMe ? WORKSPACE_ID : PRIVATE_SPACE_ID, + }); + // With page-only access, the private space is absent from the member's + // navigation. The directly shared page is a child of the synthetic shared root. + const root = createView({ + view_id: sharedWithMe ? WORKSPACE_ID : PRIVATE_SPACE_ID, + name: sharedWithMe ? 'Shared with me' : 'Accessible space', + extra: { is_space: true, is_hidden_space: sharedWithMe }, + children: [parent], + }); + const outlineRef: MutableRefObject = { current: [root] }; + const loadOutline = jest.fn(async () => undefined); + const ensureViewVisibleInOutline = jest.fn(async (viewId: string) => { + parent.children = [createView({ view_id: viewId, name: 'Member-created subpage' })]; + return [root.view_id, parent.view_id, viewId]; + }); + const auth: AuthInternalContextType = { + currentWorkspaceId: WORKSPACE_ID, + isAuthenticated: true, + onChangeWorkspace: jest.fn(), + userWorkspaceInfo: { + userId: '44444444-4444-4444-8444-444444444444', + selectedWorkspace: { + id: WORKSPACE_ID, + databaseStorageId: '55555555-5555-4555-8555-555555555555', + role, + }, + } as AuthInternalContextType['userWorkspaceInfo'], + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const rendered = renderHook(() => usePageOperations({ outlineRef, ensureViewVisibleInOutline, loadOutline }), { + wrapper, + }); + + return { ...rendered, outlineRef, ensureViewVisibleInOutline, loadOutline, parent }; +} + +describe('workspace member creating a subpage under a shared page', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(PageService.add).mockImplementation(async (_workspaceId, _parentId, _payload, activation) => { + const response = { view_id: CHILD_PAGE_ID }; + + await activation?.activate?.(response); + return response; + }); + }); + + it.each([ + { + description: 'with FullAccess in the normal outline (positive control)', + sharedWithMe: false, + accessLevel: AccessLevel.FullAccess, + }, + { + description: 'with FullAccess on a page-only share without access to its private space', + sharedWithMe: true, + accessLevel: AccessLevel.FullAccess, + }, + { + description: 'with write access on a page-only share without access to its private space', + sharedWithMe: true, + accessLevel: AccessLevel.ReadAndWrite, + }, + ])('allows creation $description', async ({ sharedWithMe, accessLevel }) => { + const { result, outlineRef, ensureViewVisibleInOutline, parent } = renderWithPage(sharedWithMe, { accessLevel }); + const payload = { layout: ViewLayout.Document, name: 'Member-created subpage' }; + + expect(parent.access_level).toBe(accessLevel); + expect(Boolean(findViewInShareWithMe(outlineRef.current ?? [], parent.view_id))).toBe(sharedWithMe); + if (sharedWithMe) { + expect(findView(outlineRef.current ?? [], PRIVATE_SPACE_ID)).toBeNull(); + } + + await expect(result.current.addPage(parent.view_id, payload)).resolves.toEqual({ view_id: CHILD_PAGE_ID }); + + expect(PageService.add).toHaveBeenCalledTimes(1); + expect(PageService.add).toHaveBeenCalledWith(WORKSPACE_ID, parent.view_id, payload, expect.any(Object)); + expect(ensureViewVisibleInOutline).toHaveBeenCalledWith(CHILD_PAGE_ID); + expect(findView(outlineRef.current ?? [], CHILD_PAGE_ID)?.name).toBe('Member-created subpage'); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, parent.view_id); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('keeps the guest creation restriction even when the shared page has FullAccess', async () => { + const { result, ensureViewVisibleInOutline, parent } = renderWithPage(true, { role: Role.Guest }); + + await expect(result.current.addPage(parent.view_id, { layout: ViewLayout.Document })).rejects.toThrow( + 'No permission to create pages' + ); + + expect(PageService.add).not.toHaveBeenCalled(); + expect(ensureViewVisibleInOutline).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + }); + + it.each([ + { description: 'read-only access', accessLevel: AccessLevel.ReadOnly }, + { description: 'revoked access while the outline still says FullAccess', accessLevel: AccessLevel.FullAccess }, + ])('preserves the server denial for $description', async ({ accessLevel }) => { + const serverDenial = Object.assign(new Error('Not enough permissions to create a child page'), { code: 1012 }); + + jest.mocked(PageService.add).mockRejectedValueOnce(serverDenial); + const { result, ensureViewVisibleInOutline, parent } = renderWithPage(true, { accessLevel }); + + await expect(result.current.addPage(parent.view_id, { layout: ViewLayout.Document })).rejects.toBe(serverDenial); + + expect(PageService.add).toHaveBeenCalledTimes(1); + expect(ensureViewVisibleInOutline).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + expect(parent.children).toEqual([]); + }); + + it('preserves a service failure without activating a child or invalidating the parent', async () => { + const serviceFailure = new Error('Service unavailable'); + + jest.mocked(PageService.add).mockRejectedValueOnce(serviceFailure); + const { result, ensureViewVisibleInOutline, parent } = renderWithPage(true); + + await expect(result.current.addPage(parent.view_id, { layout: ViewLayout.Document })).rejects.toBe(serviceFailure); + + expect(PageService.add).toHaveBeenCalledTimes(1); + expect(ensureViewVisibleInOutline).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + expect(parent.children).toEqual([]); + }); +}); + +describe('renaming a page in Shared with me', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(PageService.updateName).mockResolvedValue(undefined); + }); + + it.each([false, true])('refreshes the shared projection after renaming a descendant: %s', async (isDescendant) => { + const { result, loadOutline, parent } = renderWithPage(true); + const viewId = isDescendant ? CHILD_PAGE_ID : parent.view_id; + + if (isDescendant) { + parent.children = [createView({ view_id: viewId, parent_view_id: parent.view_id, name: 'Untitled' })]; + } + + await expect(result.current.updatePageName(viewId, 'Named shared page')).resolves.toBeUndefined(); + + expect(PageService.updateName).toHaveBeenCalledWith(WORKSPACE_ID, viewId, 'Named shared page'); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, viewId); + expect(loadOutline).toHaveBeenCalledTimes(1); + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + }); + + it('keeps the normal outline on its existing realtime refresh path', async () => { + const { result, loadOutline, parent } = renderWithPage(false); + + await expect(result.current.updatePageName(parent.view_id, 'Named page')).resolves.toBeUndefined(); + + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, parent.view_id); + expect(loadOutline).not.toHaveBeenCalled(); + }); + + it('does not refresh or invalidate metadata when the rename is denied', async () => { + const serverDenial = Object.assign(new Error('Not enough permissions to rename the page'), { code: 1012 }); + + jest.mocked(PageService.updateName).mockRejectedValueOnce(serverDenial); + const { result, loadOutline, parent } = renderWithPage(true); + + await expect(result.current.updatePageName(parent.view_id, 'Rejected name')).rejects.toBe(serverDenial); + + expect(loadOutline).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + }); + + it('does not report a committed rename as failed when the projection refresh fails', async () => { + const refreshError = new Error('Outline unavailable'); + const warn = jest.spyOn(Log, 'warn').mockImplementation(() => undefined); + + try { + const { result, loadOutline, parent } = renderWithPage(true); + + loadOutline.mockRejectedValueOnce(refreshError); + await expect(result.current.updatePageName(parent.view_id, 'Saved name')).resolves.toBeUndefined(); + + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + expect(warn).toHaveBeenCalledWith( + '[Outline] Failed to refresh Shared with me after updating a page', + refreshError + ); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('editing shared page metadata', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(PageService.update).mockResolvedValue(undefined); + jest.mocked(PageService.updateIcon).mockResolvedValue(undefined); + }); + + it.each([false, true])( + 'refreshes after saving the sidebar rename dialog for a descendant: %s', + async (isDescendant) => { + const { result, loadOutline, parent } = renderWithPage(true); + const target = isDescendant + ? createView({ view_id: CHILD_PAGE_ID, parent_view_id: parent.view_id, name: 'Untitled' }) + : parent; + + if (isDescendant) parent.children = [target]; + const onClose = jest.fn(); + + render( + + ); + fireEvent.change(screen.getByTestId('rename-modal-input'), { target: { value: 'Renamed from sidebar' } }); + fireEvent.click(screen.getByTestId('rename-modal-save')); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(PageService.update).toHaveBeenCalledWith(WORKSPACE_ID, target.view_id, { name: 'Renamed from sidebar' }); + expect(PageService.updateName).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, target.view_id); + expect(loadOutline).toHaveBeenCalledTimes(1); + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + } + ); + + it.each(['🌱', ''])('refreshes after setting the descendant sidebar icon to "%s"', async (value) => { + const { result, loadOutline, parent } = renderWithPage(true); + const child = createView({ view_id: CHILD_PAGE_ID, parent_view_id: parent.view_id }); + + parent.children = [child]; + const payload = { name: child.name, icon: { ty: ViewIconType.Emoji, value }, extra: child.extra || {} }; + + await expect(result.current.updatePage(child.view_id, payload)).resolves.toBeUndefined(); + + expect(PageService.update).toHaveBeenCalledWith(WORKSPACE_ID, child.view_id, payload); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, child.view_id); + expect(loadOutline).toHaveBeenCalledTimes(1); + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + }); + + it('also refreshes shared metadata when the dedicated icon operation is used', async () => { + const { result, loadOutline, parent } = renderWithPage(true); + const icon = { ty: ViewIconType.Emoji, value: '🌱' }; + + await expect(result.current.updatePageIcon(parent.view_id, icon)).resolves.toBeUndefined(); + + expect(PageService.updateIcon).toHaveBeenCalledWith(WORKSPACE_ID, parent.view_id, icon); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(WORKSPACE_ID, parent.view_id); + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + }); + + it('keeps ordinary sidebar edits on the realtime refresh path', async () => { + const { result, loadOutline, parent } = renderWithPage(false); + + await result.current.updatePage(parent.view_id, { name: 'Renamed page' }); + await result.current.updatePageIcon(parent.view_id, { ty: ViewIconType.Emoji, value: '🌱' }); + + expect(ViewService.invalidateCache).toHaveBeenCalledTimes(2); + expect(loadOutline).not.toHaveBeenCalled(); + }); + + it('does not invalidate or refresh a rejected sidebar edit', async () => { + const denial = new Error('Write access was revoked'); + + jest.mocked(PageService.update).mockRejectedValueOnce(denial); + const { result, loadOutline, parent } = renderWithPage(true); + + await expect(result.current.updatePage(parent.view_id, { name: 'Rejected edit' })).rejects.toBe(denial); + + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + expect(loadOutline).not.toHaveBeenCalled(); + }); + + it('does not report a saved sidebar edit as failed when the background refresh fails', async () => { + const refreshError = new Error('Outline unavailable'); + const warn = jest.spyOn(Log, 'warn').mockImplementation(() => undefined); + + try { + const { result, loadOutline, parent } = renderWithPage(true); + + loadOutline.mockRejectedValueOnce(refreshError); + await expect(result.current.updatePage(parent.view_id, { name: 'Saved edit' })).resolves.toBeUndefined(); + + expect(loadOutline).toHaveBeenCalledWith(WORKSPACE_ID, false); + expect(warn).toHaveBeenCalledWith( + '[Outline] Failed to refresh Shared with me after updating a page', + refreshError + ); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/components/app/hooks/__tests__/usePageOperations.trash.test.tsx b/src/components/app/hooks/__tests__/usePageOperations.trash.test.tsx index 5c7d64030..8ea20a4de 100644 --- a/src/components/app/hooks/__tests__/usePageOperations.trash.test.tsx +++ b/src/components/app/hooks/__tests__/usePageOperations.trash.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react'; import { type ReactNode } from 'react'; import { PageService, ViewService } from '@/application/services/domains'; -import { Role, View, ViewLayout } from '@/application/types'; +import { AccessLevel, Role, View, ViewLayout } from '@/application/types'; import { AuthInternalContext, AuthInternalContextType } from '@/components/app/contexts/AuthInternalContext'; import { Log } from '@/utils/log'; @@ -37,6 +37,7 @@ jest.mock('@/application/services/js-services/publish-database-data', () => ({ })); const workspaceId = 'workspace-id'; +let mockRole = Role.Owner; function createView(viewId: string, children: View[] = []): View { return { @@ -61,7 +62,7 @@ function Wrapper({ children }: { children: ReactNode }) { selectedWorkspace: { id: workspaceId, databaseStorageId: 'database-storage-id', - role: Role.Owner, + role: mockRole, }, } as AuthInternalContextType['userWorkspaceInfo'], }; @@ -69,9 +70,30 @@ function Wrapper({ children }: { children: ReactNode }) { return {children}; } +function renderSharedDeletion(role = Role.Member) { + mockRole = role; + const child = { ...createView('shared-child'), parent_view_id: 'shared-page', access_level: AccessLevel.FullAccess }; + const sharedPage = { + ...createView('shared-page', [child]), + parent_view_id: workspaceId, + access_level: AccessLevel.FullAccess, + }; + // The inaccessible private space is absent; the page is projected beneath + // the synthetic Shared with me root without changing its actual identity. + const outlineRef = { + current: [{ ...createView(workspaceId, [sharedPage]), extra: { is_space: true, is_hidden_space: true } }], + }; + const loadOutline = jest.fn().mockResolvedValue(undefined); + const loadTrash = jest.fn().mockResolvedValue(undefined); + const rendered = renderHook(() => usePageOperations({ outlineRef, loadOutline, loadTrash }), { wrapper: Wrapper }); + + return { ...rendered, loadOutline, loadTrash }; +} + describe('usePageOperations trash refresh', () => { beforeEach(() => { jest.clearAllMocks(); + mockRole = Role.Owner; (PageService.moveToTrash as jest.Mock).mockResolvedValue(undefined); }); @@ -103,9 +125,57 @@ describe('usePageOperations trash refresh', () => { expect(loadTrash).toHaveBeenCalledWith(workspaceId, { ensureFreshAfterInFlight: true }); }); - it('does not turn a background trash refresh failure into a delete failure', async () => { + it.each([ + ['shared-page', workspaceId], + ['shared-child', 'shared-page'], + ])('allows a member to trash %s and refreshes the shared outline and trash', async (targetId, parentId) => { + const { result, loadOutline, loadTrash } = renderSharedDeletion(); + + await act(async () => { + await result.current.deletePage(targetId); + }); + + expect(PageService.moveToTrash).toHaveBeenCalledTimes(1); + expect(PageService.moveToTrash).toHaveBeenCalledWith(workspaceId, targetId); + expect(ViewService.invalidateCache).toHaveBeenCalledTimes(2); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(workspaceId, targetId); + expect(ViewService.invalidateCache).toHaveBeenCalledWith(workspaceId, parentId); + expect(loadOutline).toHaveBeenCalledTimes(1); + expect(loadOutline).toHaveBeenCalledWith(workspaceId, false); + expect(loadTrash).toHaveBeenCalledTimes(1); + expect(loadTrash).toHaveBeenCalledWith(workspaceId, { ensureFreshAfterInFlight: true }); + }); + + it('keeps guest deletion denied even for a FullAccess shared page', async () => { + const { result, loadOutline, loadTrash } = renderSharedDeletion(Role.Guest); + + await expect(result.current.deletePage('shared-page')).rejects.toThrow('Guest cannot delete pages'); + + expect(PageService.moveToTrash).not.toHaveBeenCalled(); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + expect(loadOutline).not.toHaveBeenCalled(); + expect(loadTrash).not.toHaveBeenCalled(); + }); + + it('preserves a server permission denial without invalidating or refreshing shared content', async () => { + const denial = Object.assign(new Error('Full access required to delete this page'), { code: 1012 }); + + jest.mocked(PageService.moveToTrash).mockRejectedValueOnce(denial); + const { result, loadOutline, loadTrash } = renderSharedDeletion(); + + await expect(result.current.deletePage('shared-child')).rejects.toBe(denial); + + expect(PageService.moveToTrash).toHaveBeenCalledWith(workspaceId, 'shared-child'); + expect(ViewService.invalidateCache).not.toHaveBeenCalled(); + expect(loadOutline).not.toHaveBeenCalled(); + expect(loadTrash).not.toHaveBeenCalled(); + }); + + it('does not turn background trash or outline refresh failures into a delete failure', async () => { const refreshError = new Error('refresh unavailable'); + const outlineError = new Error('outline unavailable'); const loadTrash = jest.fn().mockRejectedValue(refreshError); + const loadOutline = jest.fn().mockRejectedValue(outlineError); const warn = jest.spyOn(Log, 'warn').mockImplementation(() => undefined); const outlineRef = { current: [createView('space-id', [createView('page-id')])], @@ -115,20 +185,16 @@ describe('usePageOperations trash refresh', () => { usePageOperations({ outlineRef, loadTrash, + loadOutline, }), { wrapper: Wrapper } ); - await expect( - act(async () => { - await result.current.deletePage('page-id'); - }) - ).resolves.toBeUndefined(); - await act(async () => { - await Promise.resolve(); + await expect(result.current.deletePage('page-id')).resolves.toBeUndefined(); }); expect(warn).toHaveBeenCalledWith('[Trash] Failed to refresh after moving a page to trash', refreshError); + expect(warn).toHaveBeenCalledWith(expect.any(String), outlineError); warn.mockRestore(); }); }); diff --git a/src/components/app/hooks/usePageOperations.ts b/src/components/app/hooks/usePageOperations.ts index ddd5067cb..361493501 100644 --- a/src/components/app/hooks/usePageOperations.ts +++ b/src/components/app/hooks/usePageOperations.ts @@ -142,9 +142,9 @@ export function usePageOperations({ throw new Error('No workspace or service found'); } - const shareWithMeView = findViewInShareWithMe(outlineRef.current || [], parentViewId); - - if (role === Role.Guest || shareWithMeView) { + // Shared-with-me placement does not remove a member's write access to the + // parent page. The creation request enforces its current server permissions. + if (role === Role.Guest) { toast.error('No permission to create pages'); throw new Error('No permission to create pages'); } @@ -168,7 +168,7 @@ export function usePageOperations({ return Promise.reject(e); } }, - [currentWorkspaceId, outlineRef, role, loadOutline, capturePageActivation] + [currentWorkspaceId, role, loadOutline, capturePageActivation] ); // Delete a page (move to trash) @@ -178,9 +178,9 @@ export function usePageOperations({ throw new Error('No workspace or service found'); } - const shareWithMeView = findViewInShareWithMe(outlineRef.current || [], id); - - if (role === Role.Guest || shareWithMeView) { + // Shared navigation does not remove Full Access to the target page. + // The trash endpoint checks its current permissions before deleting it. + if (role === Role.Guest) { throw new Error('Guest cannot delete pages'); } @@ -201,7 +201,9 @@ export function usePageOperations({ void loadTrash?.(currentWorkspaceId, { ensureFreshAfterInFlight: true }).catch((error) => { Log.warn('[Trash] Failed to refresh after moving a page to trash', error); }); - void loadOutline?.(currentWorkspaceId, false); + void loadOutline?.(currentWorkspaceId, false).catch((error) => { + Log.warn('[Outline] Failed to refresh after moving a page to trash', error); + }); return; } catch (e) { return Promise.reject(e); @@ -210,7 +212,20 @@ export function usePageOperations({ [currentWorkspaceId, outlineRef, role, loadOutline, loadTrash] ); - // Update page (rename) - uses WebSocket notification for sidebar refresh + // Normal folder notifications exclude another user's private subtree. + // Every successful shared-page metadata edit must refresh its projection. + const refreshSharedPageMetadata = useCallback( + (viewId: string) => { + if (!currentWorkspaceId || !findViewInShareWithMe(outlineRef.current || [], viewId)) return; + + void loadOutline?.(currentWorkspaceId, false).catch((error) => { + Log.warn('[Outline] Failed to refresh Shared with me after updating a page', error); + }); + }, + [currentWorkspaceId, outlineRef, loadOutline] + ); + + // Update page metadata, including sidebar renames and icon changes. const updatePage = useCallback( async (viewId: string, payload: UpdatePagePayload) => { if (!currentWorkspaceId) { @@ -222,13 +237,13 @@ export function usePageOperations({ // Drop the cached view metadata (memory + disk) so a stale name/icon // can't be re-served if the WebSocket notification is dropped. ViewService.invalidateCache(currentWorkspaceId, viewId); - // Sidebar refresh is handled by WebSocket notification (FOLDER_OUTLINE_CHANGED) + refreshSharedPageMetadata(viewId); return; } catch (e) { return Promise.reject(e); } }, - [currentWorkspaceId] + [currentWorkspaceId, refreshSharedPageMetadata] ); // Update page icon @@ -241,15 +256,16 @@ export function usePageOperations({ try { await PageService.updateIcon(currentWorkspaceId, viewId, icon); ViewService.invalidateCache(currentWorkspaceId, viewId); + refreshSharedPageMetadata(viewId); return; } catch (e) { return Promise.reject(e); } }, - [currentWorkspaceId] + [currentWorkspaceId, refreshSharedPageMetadata] ); - // Update page name (rename) - uses WebSocket notification for sidebar refresh + // Update the page title through the dedicated name endpoint. const updatePageName = useCallback( async (viewId: string, name: string) => { if (!currentWorkspaceId) { @@ -259,13 +275,14 @@ export function usePageOperations({ try { await PageService.updateName(currentWorkspaceId, viewId, name); ViewService.invalidateCache(currentWorkspaceId, viewId); - // Sidebar refresh is handled by WebSocket notification (FOLDER_OUTLINE_CHANGED) + refreshSharedPageMetadata(viewId); + return; } catch (e) { return Promise.reject(e); } }, - [currentWorkspaceId] + [currentWorkspaceId, refreshSharedPageMetadata] ); const duplicatePage = useCallback( diff --git a/src/components/app/outline/Outline.tsx b/src/components/app/outline/Outline.tsx index f1f9fca34..5cc0dffe2 100644 --- a/src/components/app/outline/Outline.tsx +++ b/src/components/app/outline/Outline.tsx @@ -671,7 +671,7 @@ export function Outline({ width }: { width: number }) { <>
- + {!outline || outline.length === 0 ? (
void; navigateToView: (viewId: string) => void; onDataRefresh?: () => void; + onImportClick?: (view: View) => void; +}) { + const renderExtra = useCallback( + ({ hovered, view: rowView }: { hovered: boolean; view: View }) => ( + toggleExpand(rowView.view_id, true)} + /> + ), + [onDataRefresh, onImportClick, toggleExpand] + ); + + return ( + + ); +} + +// ViewItem reuses renderExtra for descendants. Each row owns its action target +// and menu state so creating a grandchild never targets the top shared page. +function SharedViewActions({ + view, + visible, + onDataRefresh, + onImportClick, + onExpand, +}: { + view: View; + visible: boolean; + onDataRefresh?: () => void; + onImportClick?: (view: View) => void; + onExpand: () => void; }) { const { t } = useTranslation(); const [popoverOpen, setPopoverOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); const [showRemoveDialog, setShowRemoveDialog] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const viewId = useAppViewId(); @@ -53,9 +101,32 @@ export function ShareViewItem({ const currentUser = useCurrentUser(); const currentWorkspaceId = useCurrentWorkspaceId(); const { updatePage, uploadFile } = useAppOperations(); - const { openRenameModal } = useAppOverlayContext(); + const { openRenameModal, openDeleteModal } = useAppOverlayContext(); + const role = useUserWorkspaceInfo()?.selectedWorkspace.role; + const isWorkspaceMember = role === Role.Member || role === Role.Owner; + const canCreateChildren = + isWorkspaceMember && view.layout === ViewLayout.Document && !isSpaceView(view) && !isDatabaseContainer(view); + const canDeletePage = isWorkspaceMember && !isSpaceView(view); + // Resolve only the hovered/open row, never every shared page on sidebar load. + // Opening the menu also resolves edit permission for guests and shared spaces. + const viewActionPermissions = useViewActionPermissions( + view, + popoverOpen || ((canCreateChildren || canDeletePage) && (visible || addOpen)) + ); + const { + canWrite, + canCreateViewActions, + canManageViewActions, + hasLoadedViewActionPermissions, + isLoadingViewActionPermissions, + } = viewActionPermissions; + const showAdd = + canCreateChildren && hasLoadedViewActionPermissions && !isLoadingViewActionPermissions && canCreateViewActions; + const showDelete = + canDeletePage && hasLoadedViewActionPermissions && !isLoadingViewActionPermissions && canManageViewActions; - const canEdit = view.access_level && view.access_level > AccessLevel.ReadAndComment; + // Descendants can inherit write access without their own access_level metadata. + const canEdit = hasLoadedViewActionPermissions && !isLoadingViewActionPermissions && canWrite; const onUploadFile = useCallback( async (file: File, viewId: string) => { @@ -169,6 +240,23 @@ export function ShareViewItem({ )} + {showDelete && ( + <> + + { + setPopoverOpen(false); + openDeleteModal(view.view_id); + }} + > + + {t('button.delete')} + + + )} + ); - }, [view, canEdit, handleChangeIcon, handleRemoveIcon, onUploadFile, handleRename, currentWorkspaceId, t]); + }, [ + view, + canEdit, + showDelete, + openDeleteModal, + handleChangeIcon, + handleRemoveIcon, + onUploadFile, + handleRename, + currentWorkspaceId, + t, + ]); - const renderExtra = useCallback( - ({ hovered }: { hovered: boolean; view: View }) => { - return ( -
- {/* Button - only visible on hover */} - {hovered && ( + return ( + <> + {(visible || popoverOpen || addOpen || showRemoveDialog) && ( +
event.stopPropagation()}> + - + + + {t('menuAppHeader.moreButtonToolTip')} + {renderDropdownMenu()} + + {showAdd && ( + { + setAddOpen(open); + if (open) onExpand(); + }} + onImportClick={onImportClick} + > + + )} -
- ); - }, - [t] - ); - - return ( -
- - - - - {/* Dropdown Menu - always present with absolute positioned trigger */} - - -
- - {renderDropdownMenu()} - -
+
+ )} + ); } diff --git a/src/components/app/share-with-me/ShareWithMe.tsx b/src/components/app/share-with-me/ShareWithMe.tsx index f4d0d4687..3bddc64f8 100644 --- a/src/components/app/share-with-me/ShareWithMe.tsx +++ b/src/components/app/share-with-me/ShareWithMe.tsx @@ -2,16 +2,16 @@ import { Collapse } from '@mui/material'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { View, ViewLayout } from '@/application/types'; import { ReactComponent as DownIcon } from '@/assets/icons/alt_arrow_down.svg'; import { ReactComponent as FillUsersIcon } from '@/assets/icons/fill_users.svg'; -import { ViewLayout } from '@/application/types'; import { findShareWithMeSpace } from '@/components/_shared/outline/utils'; import { useAIEnabled, useToView, useRefreshOutline, useAppOutline } from '@/components/app/app.hooks'; import { ShareViewItem } from '@/components/app/share-with-me/ShareViewItem'; const LOCAL_STORAGE_KEY = 'share_with_me_expanded'; -export function ShareWithMe({ width }: { width: number }) { +export function ShareWithMe({ width, onImportClick }: { width: number; onImportClick?: (view: View) => void }) { const { t } = useTranslation(); const navigateToView = useToView(); const refreshOutline = useRefreshOutline(); @@ -26,7 +26,7 @@ export function ShareWithMe({ width }: { width: number }) { const toggleExpand = (id: string, isExpand: boolean) => { setExpandIds((prev) => { if (isExpand) { - return [...prev, id]; + return prev.includes(id) ? prev : [...prev, id]; } return prev.filter((expandId) => expandId !== id); @@ -83,6 +83,7 @@ export function ShareWithMe({ width }: { width: number }) { toggleExpand={toggleExpand} navigateToView={navigateToView} onDataRefresh={handleDataRefresh} + onImportClick={onImportClick} /> ))} diff --git a/src/components/app/share-with-me/__tests__/ShareViewItem.test.tsx b/src/components/app/share-with-me/__tests__/ShareViewItem.test.tsx new file mode 100644 index 000000000..3f320b862 --- /dev/null +++ b/src/components/app/share-with-me/__tests__/ShareViewItem.test.tsx @@ -0,0 +1,781 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { AccessService } from '@/application/services/domains'; +import { AccessLevel, Role, Types, View, ViewIconType, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { ShareViewItem } from '@/components/app/share-with-me/ShareViewItem'; + +import type { ReactNode } from 'react'; + +const mockUseViewActionPermissions = jest.fn(); +const mockAddPage = jest.fn(); +const mockUpdatePage = jest.fn(); +const mockUploadFile = jest.fn(); +const mockOpenPageModal = jest.fn(); +const mockOpenRenameModal = jest.fn(); +const mockOpenDeleteModal = jest.fn(); +const mockNavigate = jest.fn(); +let mockRole = Role.Member; +let mockViewId = 'root-page'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate })); + +jest.mock('sonner', () => ({ + toast: { error: jest.fn(), loading: jest.fn(() => 'loading'), dismiss: jest.fn() }, +})); + +jest.mock('@/application/services/domains', () => ({ + AccessService: { revokeAccess: jest.fn(), getObjectPermission: jest.fn() }, +})); +jest.mock('@/components/_shared/notify', () => ({ notify: { error: jest.fn(), success: jest.fn() } })); +jest.mock('@/components/main/app.hooks', () => ({ useCurrentUser: () => ({ email: 'member@example.com' }) })); +jest.mock('@/components/app/app-overlay/AppOverlayContext', () => ({ + useAppOverlayContext: () => ({ openRenameModal: mockOpenRenameModal, openDeleteModal: mockOpenDeleteModal }), +})); +jest.mock('@/components/app/app.hooks', () => ({ + useAppOperations: () => ({ addPage: mockAddPage, updatePage: mockUpdatePage, uploadFile: mockUploadFile }), + useAppViewId: () => mockViewId, + useCurrentWorkspaceId: () => 'workspace', + useUserWorkspaceInfo: () => ({ selectedWorkspace: { role: mockRole } }), + useAIEnabled: () => false, + useOpenPageModal: () => mockOpenPageModal, + useToView: () => mockNavigate, +})); +jest.mock('@/components/app/hooks/useDatabaseViewCreation', () => ({ + useDatabaseViewCreation: () => ({ getAction: () => ({ type: 'create' }), checkCreation: () => true }), +})); +jest.mock('@/components/app/view-actions/useViewActionPermissions', () => ({ + useViewActionPermissions: (...args: unknown[]) => mockUseViewActionPermissions(...args), +})); +jest.mock('@/components/app/view-actions/useSpaceActionPermissions', () => ({ + useSpaceActionPermissions: () => ({ + canOpenManageSpace: false, + hasLoadedSpaceActionPermissions: true, + isLoadingSpaceActionPermissions: false, + }), +})); +jest.mock('@/components/app/view-actions/MorePageActions', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/app/view-actions/MoreSpaceActions', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/_shared/cutsom-icon', () => { + const React = jest.requireActual('react'); + const { createPortal } = jest.requireActual('react-dom'); + + return { + CustomIconPopover: ({ + children, + onSelectIcon, + removeIcon, + onUploadFile, + }: { + children: ReactNode; + onSelectIcon: (icon: { ty: ViewIconType; value: string; color?: string }) => void; + removeIcon: () => void; + onUploadFile: (file: File) => Promise; + }) => { + const [open, setOpen] = React.useState(false); + + return ( + <> +
setOpen(true)}>{children}
+ {open && + createPortal( +
+ + + + { + const file = event.target.files?.[0]; + + if (file) void onUploadFile(file); + }} + /> +
, + document.body + )} + + ); + }, + }; +}); +jest.mock('@/components/_shared/view-icon', () => ({ ViewIcon: () => null })); +jest.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, +})); +jest.mock('@/components/ui/progress', () => ({ Progress: () => Loading })); + +// Simplify dropdown focus mechanics while preserving portal click bubbling. +// ViewActionsPopover, AddPageActions, and RemoveAccessConfirmDialog stay real. +jest.mock('@/components/ui/dropdown-menu', () => { + const React = jest.requireActual('react'); + const { createPortal } = jest.requireActual('react-dom'); + const Context = React.createContext({ open: false, onOpenChange: (_open: boolean) => undefined }); + + return { + DropdownMenu: ({ + children, + open = false, + onOpenChange = () => undefined, + }: { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => {children}, + DropdownMenuTrigger: ({ + children, + }: { + children: React.ReactElement<{ onClick?: (event: React.MouseEvent) => void }>; + }) => { + const menu = React.useContext(Context); + + return React.cloneElement(children, { + onClick: (event: React.MouseEvent) => { + children.props.onClick?.(event); + menu.onOpenChange(!menu.open); + }, + }); + }, + DropdownMenuContent: ({ children }: { children: ReactNode }) => { + const menu = React.useContext(Context); + + return menu.open ? createPortal(
{children}
, document.body) : null; + }, + DropdownMenuGroup: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + ...props + }: { + children: ReactNode; + disabled?: boolean; + onSelect?: (event: { preventDefault: () => void }) => void; + 'data-testid'?: string; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +jest.mock('@/components/_shared/DatabaseViewCreationItem', () => ({ + DatabaseViewCreationItem: ({ + children, + onSelect, + ...props + }: { + children: ReactNode; + onSelect?: () => void; + 'data-testid'?: string; + }) => ( + + ), +})); + +jest.mock('@/components/app/outline/ViewItem', () => { + function Row({ + view, + renderExtra, + onClickView, + }: { + view: View; + renderExtra: (props: { hovered: boolean; view: View }) => ReactNode; + onClickView: (viewId: string) => void; + }) { + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => onClickView(view.view_id)} + > + {view.name} + {renderExtra({ hovered, view })} +
+ ); + } + + return { + __esModule: true, + default: ({ + view, + renderExtra, + onClickView, + }: { + view: View; + renderExtra: (props: { hovered: boolean; view: View }) => ReactNode; + onClickView: (viewId: string) => void; + }) => ( + <> + {[view, ...view.children].map((row) => ( + + ))} + + ), + }; +}); + +function view(id: string, overrides: Partial = {}): View { + return { + view_id: id, + name: id, + parent_view_id: 'private-space', + layout: ViewLayout.Document, + children: [], + icon: null, + extra: null, + is_published: false, + is_private: true, + access_level: AccessLevel.FullAccess, + ...overrides, + }; +} + +function permissions(overrides: Record = {}) { + return { + canWrite: true, + canCreateViewActions: true, + canManageViewActions: true, + hasLoadedViewActionPermissions: true, + isLoadingViewActionPermissions: false, + ...overrides, + }; +} + +function renderShared(root = view('root-page'), onImportClick = jest.fn()) { + const toggleExpand = jest.fn(); + const navigateToView = jest.fn(); + const onDataRefresh = jest.fn(); + const result = render( + + ); + + return { ...result, toggleExpand, navigateToView, onImportClick, onDataRefresh }; +} + +function openMoreActions(targetId = 'root-page') { + const row = screen.getByTestId(`row-${targetId}`); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + return row; +} + +function openRemoveDialog(targetId = 'nested-child') { + const row = openMoreActions(targetId); + + fireEvent.click(screen.getByText('shareAction.removeYourAccess')); + fireEvent.mouseLeave(row); + const dialog = screen.getByRole('alertdialog'); + + // The actual Radix dialog is outside the row's DOM but inside its React tree. + expect(row.contains(dialog)).toBe(false); + return dialog; +} + +function configureCanonicalPermissions(accessLevel = AccessLevel.FullAccess) { + mockUseViewActionPermissions.mockImplementation( + jest.requireActual( + '@/components/app/view-actions/useViewActionPermissions' + ).useViewActionPermissions + ); + jest.mocked(AccessService.getObjectPermission).mockImplementation(async (_workspaceId, objectId, collabType) => ({ + object_id: objectId, + collab_type: collabType, + governing_view_id: objectId, + access_level: accessLevel, + can_read: true, + can_write: accessLevel >= AccessLevel.ReadAndWrite, + can_comment: accessLevel >= AccessLevel.ReadAndComment, + can_share: accessLevel === AccessLevel.FullAccess, + })); +} + +describe('shared page sidebar actions', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockRole = Role.Member; + mockViewId = 'root-page'; + mockAddPage.mockResolvedValue({ view_id: 'created-child' }); + mockUpdatePage.mockResolvedValue(undefined); + mockUploadFile.mockResolvedValue('https://example.test/icon.png'); + jest.mocked(AccessService.revokeAccess).mockResolvedValue(undefined); + mockUseViewActionPermissions.mockReturnValue(permissions()); + }); + + it('lets a FullAccess member create under the shared document and opens the new child', async () => { + const { toggleExpand, navigateToView } = renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('inline-add-page')); + expect(toggleExpand).toHaveBeenCalledWith('root-page', true); + expect(navigateToView).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTestId('add-document-button')); + + await waitFor(() => + expect(mockAddPage).toHaveBeenCalledWith('root-page', expect.objectContaining({ layout: ViewLayout.Document })) + ); + await waitFor(() => expect(mockOpenPageModal).toHaveBeenCalledWith('created-child')); + }); + + it('creates a grandchild under the clicked descendant, not the top shared page', async () => { + const child = view('nested-child', { parent_view_id: 'root-page' }); + const { toggleExpand } = renderShared(view('root-page', { children: [child] })); + const row = screen.getByTestId('row-nested-child'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('inline-add-page')); + fireEvent.click(screen.getByTestId('add-document-button')); + + expect(toggleExpand).toHaveBeenCalledWith('nested-child', true); + await waitFor(() => + expect(mockAddPage).toHaveBeenCalledWith('nested-child', expect.objectContaining({ layout: ViewLayout.Document })) + ); + expect(mockAddPage.mock.calls.some(([id]) => id === 'root-page')).toBe(false); + }); + + it.each([AccessLevel.FullAccess, AccessLevel.ReadAndWrite])( + 'reuses the hovered descendant permission when opening Add with access %s', + async (accessLevel) => { + configureCanonicalPermissions(accessLevel); + renderShared( + view('root-page', { + children: [view('nested-child', { parent_view_id: 'root-page', access_level: undefined })], + }) + ); + const row = screen.getByTestId('row-nested-child'); + + expect(AccessService.getObjectPermission).not.toHaveBeenCalled(); + fireEvent.mouseEnter(row); + const addButton = await within(row).findByTestId('inline-add-page'); + + expect(AccessService.getObjectPermission).toHaveBeenCalledTimes(1); + fireEvent.click(addButton); + + expect(screen.queryByTestId('add-page-permission-loading')).toBeNull(); + fireEvent.click(screen.getByTestId('add-document-button')); + await waitFor(() => expect(mockOpenPageModal).toHaveBeenCalledWith('created-child')); + + expect(mockAddPage).toHaveBeenCalledWith('nested-child', expect.objectContaining({ layout: ViewLayout.Document })); + expect(AccessService.getObjectPermission).toHaveBeenCalledTimes(1); + expect(AccessService.getObjectPermission).toHaveBeenCalledWith('workspace', 'nested-child', Types.Document); + } + ); + + it('revalidates on the next hover and removes Add when write access has been revoked', async () => { + configureCanonicalPermissions(); + renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + const addButton = await within(row).findByTestId('inline-add-page'); + + fireEvent.click(addButton); + expect(screen.getByTestId('add-document-button')).toBeTruthy(); + fireEvent.click(addButton); + fireEvent.mouseLeave(row); + configureCanonicalPermissions(AccessLevel.ReadOnly); + fireEvent.mouseEnter(row); + + await waitFor(() => + expect(mockUseViewActionPermissions.mock.results.at(-1)?.value).toMatchObject({ + canWrite: false, + hasLoadedViewActionPermissions: true, + isLoadingViewActionPermissions: false, + }) + ); + expect(AccessService.getObjectPermission).toHaveBeenCalledTimes(2); + expect(screen.queryByTestId('inline-add-page')).toBeNull(); + expect(screen.queryByTestId('add-document-button')).toBeNull(); + }); + + it.each([ + ['read-only', { canCreateViewActions: false }], + ['no permission', { canCreateViewActions: false, canManageViewActions: false }], + ['unresolved', { hasLoadedViewActionPermissions: false }], + ['loading', { isLoadingViewActionPermissions: true }], + ])('hides Add while canonical permission is %s even when sidebar metadata says FullAccess', (_name, result) => { + mockUseViewActionPermissions.mockReturnValue(permissions(result)); + renderShared(); + fireEvent.mouseEnter(screen.getByTestId('row-root-page')); + + expect(screen.queryByTestId('inline-add-page')).toBeNull(); + expect(mockAddPage).not.toHaveBeenCalled(); + }); + + it('keeps guest creation hidden and does not start a creation-permission probe', () => { + mockRole = Role.Guest; + renderShared(); + fireEvent.mouseEnter(screen.getByTestId('row-root-page')); + + expect(screen.queryByTestId('inline-add-page')).toBeNull(); + expect(mockUseViewActionPermissions.mock.calls.every(([, opened]) => opened === false)).toBe(true); + }); + + it('does not probe the whole tree on mount and keeps the active menu open after hover leaves', () => { + renderShared(view('root-page', { children: [view('nested-child')] })); + expect(mockUseViewActionPermissions.mock.calls.every(([, opened]) => opened === false)).toBe(true); + + mockUseViewActionPermissions.mockClear(); + const childRow = screen.getByTestId('row-nested-child'); + + fireEvent.mouseEnter(childRow); + expect( + mockUseViewActionPermissions.mock.calls.some(([target, opened]) => target.view_id === 'nested-child' && opened) + ).toBe(true); + expect( + mockUseViewActionPermissions.mock.calls.some(([target, opened]) => target.view_id === 'root-page' && opened) + ).toBe(false); + fireEvent.click(within(childRow).getByTestId('inline-add-page')); + fireEvent.mouseLeave(childRow); + expect(screen.getByTestId('add-document-button')).toBeTruthy(); + }); + + it('keeps a readonly descendant locked even when its shared root is writable', () => { + mockUseViewActionPermissions.mockImplementation((target: View) => + permissions({ canCreateViewActions: target.view_id === 'root-page' }) + ); + renderShared(view('root-page', { children: [view('nested-child', { access_level: AccessLevel.ReadOnly })] })); + fireEvent.mouseEnter(screen.getByTestId('row-root-page')); + fireEvent.mouseEnter(screen.getByTestId('row-nested-child')); + + expect(within(screen.getByTestId('row-root-page')).getByTestId('inline-add-page')).toBeTruthy(); + expect(within(screen.getByTestId('row-nested-child')).queryByTestId('inline-add-page')).toBeNull(); + }); + + it('routes descendant rename and Import actions to the same descendant', () => { + const child = view('nested-child', { parent_view_id: 'root-page' }); + const { onImportClick } = renderShared(view('root-page', { children: [child] })); + const row = screen.getByTestId('row-nested-child'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + fireEvent.click(screen.getByText('button.rename')); + expect(mockOpenRenameModal).toHaveBeenCalledWith('nested-child'); + fireEvent.click(within(row).getByTestId('inline-add-page')); + fireEvent.click(screen.getByTestId('add-import-button')); + expect(onImportClick).toHaveBeenCalledWith(child); + }); + + it.each([ + ['shared root', 'root-page', Role.Member], + ['shared descendant', 'nested-child', Role.Member], + ['shared descendant as workspace owner', 'nested-child', Role.Owner], + ])('opens the trash confirmation for the clicked %s', (_name, targetId, role) => { + mockRole = role as Role; + // A descendant's management permission must be checked independently of + // its shared root; stale sidebar access_level metadata is not authority. + mockUseViewActionPermissions.mockImplementation((target: View) => + permissions({ + canManageViewActions: target.view_id === targetId, + }) + ); + const { navigateToView } = renderShared(view('root-page', { children: [view('nested-child')] })); + const row = screen.getByTestId(`row-${targetId}`); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + fireEvent.mouseLeave(row); + fireEvent.click(screen.getByTestId('shared-move-to-trash')); + + expect(mockOpenDeleteModal).toHaveBeenCalledTimes(1); + expect(mockOpenDeleteModal).toHaveBeenCalledWith(targetId); + expect(navigateToView).not.toHaveBeenCalled(); + }); + + it.each([ + ['read-only', { canCreateViewActions: false, canManageViewActions: false }], + ['edit without FullAccess', { canCreateViewActions: true, canManageViewActions: false }], + ['unresolved', { hasLoadedViewActionPermissions: false }], + ['loading', { isLoadingViewActionPermissions: true }], + ])('hides trash while canonical permission is %s despite FullAccess sidebar metadata', (_name, result) => { + mockUseViewActionPermissions.mockReturnValue(permissions(result)); + renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + + expect(screen.queryByTestId('shared-move-to-trash')).toBeNull(); + expect(mockOpenDeleteModal).not.toHaveBeenCalled(); + }); + + it('keeps trash hidden for guests even with canonical FullAccess', () => { + mockRole = Role.Guest; + renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + + expect(screen.queryByTestId('shared-move-to-trash')).toBeNull(); + expect(mockOpenDeleteModal).not.toHaveBeenCalled(); + }); + + it.each([AccessLevel.FullAccess, AccessLevel.ReadAndWrite])( + 'resolves inherited edit actions for a descendant with canonical access %s', + async (accessLevel) => { + configureCanonicalPermissions(accessLevel); + const child = view('nested-child', { parent_view_id: 'root-page', access_level: undefined }); + const { navigateToView } = renderShared(view('root-page', { children: [child], access_level: accessLevel })); + + expect(AccessService.getObjectPermission).not.toHaveBeenCalled(); + const row = openMoreActions('nested-child'); + + await waitFor(() => expect(within(row).getByTestId('inline-add-page')).toBeTruthy()); + expect(AccessService.getObjectPermission).toHaveBeenCalledWith('workspace', 'nested-child', Types.Document); + expect(jest.mocked(AccessService.getObjectPermission).mock.calls.every(([, id]) => id === 'nested-child')).toBe( + true + ); + expect(screen.getByText('disclosureAction.changeIcon')).toBeTruthy(); + expect(Boolean(screen.queryByTestId('shared-move-to-trash'))).toBe(accessLevel === AccessLevel.FullAccess); + + fireEvent.click(screen.getByText('button.rename')); + expect(mockOpenRenameModal).toHaveBeenCalledWith('nested-child'); + expect(navigateToView).not.toHaveBeenCalled(); + } + ); + + it.each([AccessLevel.ReadOnly, AccessLevel.ReadAndComment])( + 'hides descendant mutations when canonical access is %s despite FullAccess metadata', + async (accessLevel) => { + configureCanonicalPermissions(accessLevel); + renderShared(view('root-page', { children: [view('nested-child')] })); + const row = openMoreActions('nested-child'); + + await waitFor(() => + expect(mockUseViewActionPermissions.mock.results.at(-1)?.value).toMatchObject({ + hasLoadedViewActionPermissions: true, + isLoadingViewActionPermissions: false, + }) + ); + expect(within(row).queryByTestId('inline-add-page')).toBeNull(); + expect(screen.queryByTestId('shared-move-to-trash')).toBeNull(); + expect(screen.queryByText('button.rename')).toBeNull(); + expect(screen.queryByText('disclosureAction.changeIcon')).toBeNull(); + expect(screen.getByText('shareAction.removeYourAccess')).toBeTruthy(); + } + ); + + it.each([ + ['denied', { canWrite: false }], + ['unresolved', { hasLoadedViewActionPermissions: false }], + ['loading', { isLoadingViewActionPermissions: true }], + ])('hides Rename and Change icon while write permission is %s', (_name, result) => { + mockUseViewActionPermissions.mockReturnValue(permissions(result)); + renderShared(); + openMoreActions(); + + expect(screen.queryByText('button.rename')).toBeNull(); + expect(screen.queryByText('disclosureAction.changeIcon')).toBeNull(); + expect(mockOpenRenameModal).not.toHaveBeenCalled(); + expect(mockUpdatePage).not.toHaveBeenCalled(); + }); + + it('resolves guest editing when the menu opens while keeping creation and trash unavailable', async () => { + mockRole = Role.Guest; + configureCanonicalPermissions(AccessLevel.ReadAndWrite); + renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + expect(AccessService.getObjectPermission).not.toHaveBeenCalled(); + fireEvent.click(within(row).getByTestId('shared-page-more-actions')); + + await waitFor(() => + expect(AccessService.getObjectPermission).toHaveBeenCalledWith('workspace', 'root-page', Types.Document) + ); + expect(await screen.findByText('button.rename')).toBeTruthy(); + expect(screen.getByText('disclosureAction.changeIcon')).toBeTruthy(); + expect(within(row).queryByTestId('inline-add-page')).toBeNull(); + expect(screen.queryByTestId('shared-move-to-trash')).toBeNull(); + }); + + it.each([ + ['space', { extra: { is_space: true } }], + ['database container', { extra: { is_space: false, is_database_container: true } }], + ['grid', { layout: ViewLayout.Grid }], + ['board', { layout: ViewLayout.Board }], + ['calendar', { layout: ViewLayout.Calendar }], + ] as const)('does not offer document children under a %s', (_name, overrides) => { + renderShared(view('root-page', overrides)); + fireEvent.mouseEnter(screen.getByTestId('row-root-page')); + + expect(screen.queryByTestId('inline-add-page')).toBeNull(); + expect(mockAddPage).not.toHaveBeenCalled(); + }); + + it('reports a rejected child creation without opening or navigating to a new page', async () => { + mockAddPage.mockRejectedValueOnce(new Error('Write access was revoked')); + const { navigateToView } = renderShared(); + const row = screen.getByTestId('row-root-page'); + + fireEvent.mouseEnter(row); + fireEvent.click(within(row).getByTestId('inline-add-page')); + fireEvent.click(screen.getByTestId('add-document-button')); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to create page')); + expect(mockOpenPageModal).not.toHaveBeenCalled(); + expect(navigateToView).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it.each([ + ['Select emoji', { ty: ViewIconType.Emoji, value: '🌱' }], + [ + 'Select icon', + { ty: ViewIconType.Icon, value: JSON.stringify({ color: 'green', groupName: 'nature', iconName: 'leaf' }) }, + ], + ['Remove icon', { ty: ViewIconType.Emoji, value: '' }], + ])('targets the descendant when choosing %s from its icon picker', async (action, icon) => { + const child = view('nested-child', { parent_view_id: 'root-page', access_level: undefined }); + const { navigateToView } = renderShared(view('root-page', { children: [child] })); + + openMoreActions('nested-child'); + fireEvent.click(screen.getByText('disclosureAction.changeIcon')); + fireEvent.click(screen.getByRole('button', { name: action as string })); + + await waitFor(() => + expect(mockUpdatePage).toHaveBeenCalledWith('nested-child', { + icon, + name: child.name, + extra: {}, + }) + ); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + expect(mockUpdatePage).toHaveBeenCalledTimes(1); + expect(navigateToView).not.toHaveBeenCalled(); + }); + + it('uploads an icon to the descendant selected in the shared tree', async () => { + renderShared(view('root-page', { children: [view('nested-child', { access_level: undefined })] })); + openMoreActions('nested-child'); + fireEvent.click(screen.getByText('disclosureAction.changeIcon')); + const file = new File(['icon'], 'icon.png', { type: 'image/png' }); + + fireEvent.change(screen.getByLabelText('Upload icon'), { target: { files: [file] } }); + + await waitFor(() => expect(mockUploadFile).toHaveBeenCalledWith('nested-child', file)); + }); + + it.each(['cancel', 'close', 'escape'])('dismisses removal via %s without navigating or revoking access', (action) => { + const { navigateToView, onDataRefresh } = renderShared(view('root-page', { children: [view('nested-child')] })); + const dialog = openRemoveDialog(); + + if (action === 'escape') { + fireEvent.keyDown(dialog, { key: 'Escape' }); + } else { + fireEvent.click(within(dialog).getByRole('button', { name: action === 'cancel' ? 'button.cancel' : '' })); + } + + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(navigateToView).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(AccessService.revokeAccess).not.toHaveBeenCalled(); + expect(onDataRefresh).not.toHaveBeenCalled(); + + // Normal row navigation must still work after the dialog is dismissed. + fireEvent.click(screen.getByTestId('row-nested-child')); + expect(navigateToView).toHaveBeenCalledWith('nested-child'); + }); + + it('isolates removal dialog content and overlay clicks from row navigation', () => { + const { navigateToView } = renderShared(view('root-page', { children: [view('nested-child')] })); + const dialog = openRemoveDialog(); + + fireEvent.click(within(dialog).getByRole('heading')); + const overlay = document.querySelector('[data-slot="alert-dialog-overlay"]'); + + expect(overlay).not.toBeNull(); + fireEvent.click(overlay!); + + expect(screen.getByRole('alertdialog')).toBeTruthy(); + expect(navigateToView).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(AccessService.revokeAccess).not.toHaveBeenCalled(); + }); + + it.each(['root-page', 'nested-child'])( + 'revokes only the selected descendant and navigates after success only if it is the current page (%s)', + async (currentViewId) => { + mockViewId = currentViewId; + let finishRemoval!: () => void; + + jest.mocked(AccessService.revokeAccess).mockImplementationOnce( + () => + new Promise((resolve) => { + finishRemoval = resolve; + }) + ); + const { navigateToView, onDataRefresh } = renderShared(view('root-page', { children: [view('nested-child')] })); + const dialog = openRemoveDialog(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'button.remove' })); + + expect(AccessService.revokeAccess).toHaveBeenCalledTimes(1); + expect(AccessService.revokeAccess).toHaveBeenCalledWith('workspace', 'nested-child', ['member@example.com']); + expect(navigateToView).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onDataRefresh).not.toHaveBeenCalled(); + + await act(async () => finishRemoval()); + + expect(onDataRefresh).toHaveBeenCalledTimes(1); + expect(notify.success).toHaveBeenCalledWith('shareAction.removeAccessSuccess'); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(navigateToView).not.toHaveBeenCalled(); + if (currentViewId === 'nested-child') { + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/app'); + } else { + expect(mockNavigate).not.toHaveBeenCalled(); + } + } + ); + + it('reports a rejected removal without refreshing or navigating away', async () => { + jest.mocked(AccessService.revokeAccess).mockRejectedValueOnce(new Error('Removal failed')); + const { navigateToView, onDataRefresh } = renderShared(view('root-page', { children: [view('nested-child')] })); + const dialog = openRemoveDialog(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'button.remove' })); + + await waitFor(() => expect(notify.error).toHaveBeenCalledWith('Removal failed')); + expect(navigateToView).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onDataRefresh).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/app/view-actions/ViewActionsPopover.tsx b/src/components/app/view-actions/ViewActionsPopover.tsx index 4066190c4..10d287627 100644 --- a/src/components/app/view-actions/ViewActionsPopover.tsx +++ b/src/components/app/view-actions/ViewActionsPopover.tsx @@ -34,6 +34,7 @@ function ViewActionsPopover({ open, onOpenChange, onImportClick, + viewActionPermissions, }: { view?: View; popoverType?: { @@ -45,9 +46,18 @@ function ViewActionsPopover({ // ancestor (e.g. Outline) since this popover is unmounted as soon as the // dropdown closes. onImportClick?: (view: View) => void; + // A row that already resolves canonical permissions can share that result. + viewActionPermissions?: Pick< + ReturnType, + 'canCreateViewActions' | 'canManageViewActions' | 'hasLoadedViewActionPermissions' | 'isLoadingViewActionPermissions' + >; } & ComponentProps) { + const ownViewActionPermissions = useViewActionPermissions( + view, + Boolean(open && popoverType && !viewActionPermissions) + ); const { canCreateViewActions, canManageViewActions, hasLoadedViewActionPermissions, isLoadingViewActionPermissions } = - useViewActionPermissions(view, Boolean(open && popoverType)); + viewActionPermissions ?? ownViewActionPermissions; const shouldLoadSpaceActionPermissions = Boolean( open && popoverType?.category === 'space' && popoverType.type === 'more' ); diff --git a/src/components/app/view-actions/__tests__/actionPermissionGates.test.tsx b/src/components/app/view-actions/__tests__/actionPermissionGates.test.tsx index 987dfcb45..c82628bf9 100644 --- a/src/components/app/view-actions/__tests__/actionPermissionGates.test.tsx +++ b/src/components/app/view-actions/__tests__/actionPermissionGates.test.tsx @@ -458,4 +458,43 @@ describe('view action permission gates', () => { expect(screen.getByTestId('add-page-actions')).toBeTruthy(); }); + + it.each([ + ['denied', false, true, false], + ['unresolved', true, false, false], + ['loading', true, true, true], + ] as const)( + 'honors %s permissions supplied by the row instead of independently granting Add', + (_name, canCreateViewActions, hasLoadedViewActionPermissions, isLoadingViewActionPermissions) => { + mockUseViewActionPermissions.mockReturnValue({ + canCreateViewActions: true, + canManageViewActions: true, + hasLoadedViewActionPermissions: true, + isLoadingViewActionPermissions: false, + }); + + render( + + + + ); + + expect(mockUseViewActionPermissions).toHaveBeenCalledWith(mockDocumentView, false); + expect(screen.queryByTestId('add-page-actions')).toBeNull(); + expect(Boolean(screen.queryByTestId('add-page-permission-loading'))).toBe( + !hasLoadedViewActionPermissions || isLoadingViewActionPermissions + ); + } + ); });