From 7917d8b887fe213423116158ac0feb1a3c0b73c7 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 11 May 2026 20:17:01 +0200 Subject: [PATCH 1/3] fix(links): register global collectives link handler and pass it to Text Ensures that link navigation always uses the collectives link handler, regardless of whether clicking the preview, the "Open link" button in the link bubble, or Ctrl-clicking the link directly. Requires https://github.com/nextcloud/text/pull/8571 Fixes: #2378 Signed-off-by: Jonas --- playwright/e2e/page-links-handler.spec.ts | 124 +++++++++++++++++++ playwright/support/helpers/links.ts | 23 +++- playwright/support/sections/EditorSection.ts | 32 +++-- src/components/PagePreview.vue | 12 +- src/composables/useCollectivesLinkHandler.ts | 104 ++++++++++++++++ src/composables/useEditor.ts | 1 + src/composables/useReader.ts | 1 + src/main.ts | 6 +- 8 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 playwright/e2e/page-links-handler.spec.ts create mode 100644 src/composables/useCollectivesLinkHandler.ts diff --git a/playwright/e2e/page-links-handler.spec.ts b/playwright/e2e/page-links-handler.spec.ts new file mode 100644 index 0000000000..519b7e4601 --- /dev/null +++ b/playwright/e2e/page-links-handler.spec.ts @@ -0,0 +1,124 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + GetCollectiveUrlParameters, + SameTabLinkTestCaseData, +} from '../support/helpers/links.ts' + +import { mergeTests } from '@playwright/test' +import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { testLinkOpensInSameTab } from '../support/helpers/links.ts' +import { randomString } from '../support/helpers/randomString.ts' + +const triggers = ['preview', 'openLinkButton', 'ctrlClick'] as const + +const collectiveTest = createCollectiveTest.extend({ + // eslint-disable-next-line no-empty-pattern + collectiveConfigs: async ({}, use) => use([ + { + name: randomString(), + pages: [ + { title: 'Link Target', content: 'Some content' }, + { title: 'Link Source' }, + ], + }, + ]), +}) + +const test = mergeTests(collectiveTest, editorTest) + +test.describe('Link handler: authenticated → public share URL', () => { + for (const editMode of [false, true]) { + const modeLabel = editMode ? 'edit' : 'preview' + for (const trigger of triggers) { + test(`Opens public share URL link in same tab via ${trigger} (${modeLabel} mode)`, async ({ baseURL, collective, editor, page, user }) => { + test.skip( + trigger === 'ctrlClick' && process.env.PLAYWRIGHT_NC_SERVER_BRANCH === 'stable32', + 'ctrlClick handler not implemented on stable32', + ) + + const sourcePage = collective.getPageByTitle('Link Source') + const targetPage = collective.getPageByTitle('Link Target') + + if (!baseURL) { + throw new Error('baseURL is not defined') + } + + const share = await collective.createShare({ page }) + + const linkData: SameTabLinkTestCaseData = { + description: 'public share URL', + getLinkUrl: ({ targetPage }: GetCollectiveUrlParameters) => targetPage.getPageUrl(share.data.token), + getExpectedUrl: ({ baseURL, targetPage }: GetCollectiveUrlParameters) => (new URL(targetPage.getPageUrl(), baseURL)).href, + } + + await testLinkOpensInSameTab({ + baseURL, + page, + user, + editor, + sourcePage, + targetPage, + targetCollective: collective, + linkData, + editMode, + trigger, + }) + + await share.delete() + }) + } + } +}) + +test.describe('Link handler: public share → internal URL', () => { + for (const editMode of [false, true]) { + const modeLabel = editMode ? 'edit' : 'preview' + for (const trigger of triggers) { + test(`Opens internal URL link in same tab via ${trigger} in share context (${modeLabel} mode)`, async ({ baseURL, collective, editor, page, user }) => { + test.skip( + trigger === 'ctrlClick' && process.env.PLAYWRIGHT_NC_SERVER_BRANCH === 'stable32', + 'ctrlClick handler not implemented on stable32', + ) + + const sourcePage = collective.getPageByTitle('Link Source') + const targetPage = collective.getPageByTitle('Link Target') + + if (!baseURL) { + throw new Error('baseURL is not defined') + } + + const share = await collective.createShare({ page }) + if (editMode) { + await share.setEditable(true) + } + + const linkData: SameTabLinkTestCaseData = { + description: 'internal URL without share token', + getLinkUrl: ({ targetPage }: GetCollectiveUrlParameters) => targetPage.getPageUrl(), + getExpectedUrl: ({ baseURL, targetPage, shareToken }: GetCollectiveUrlParameters) => (new URL(targetPage.getPageUrl(shareToken), baseURL)).href, + } + + await testLinkOpensInSameTab({ + baseURL, + page, + user, + editor, + sourcePage, + targetPage, + targetCollective: collective, + linkData, + editMode, + shareToken: share.data.token, + trigger, + }) + + await share.delete() + }) + } + } +}) diff --git a/playwright/support/helpers/links.ts b/playwright/support/helpers/links.ts index 5a341f1c28..37b59859b1 100644 --- a/playwright/support/helpers/links.ts +++ b/playwright/support/helpers/links.ts @@ -42,6 +42,8 @@ export type NewTabLinkTestCaseData = { getExpectedUrl: (params: GetUrlParameters) => string } +type LinkTrigger = 'preview' | 'openLinkButton' | 'ctrlClick' + export type ViewerLinkTestCase = { page: Page user: User @@ -63,6 +65,7 @@ export type SameTabLinkTestCase = { linkData: SameTabLinkTestCaseData editMode: boolean shareToken?: string + trigger?: LinkTrigger } export type NewTabLinkTestCase = { @@ -111,7 +114,7 @@ export async function testLinkOpensInViewer({ await sourcePage.open(false) await sourcePage.switchMode(editMode) editor.setMode(editMode) - await editor.openLink({ linkText }) + await editor.openLinkViaBubblePreview({ linkText }) await expect(sourcePage.getViewerContent() .locator('.modal-header')) .toContainText(linkData.fixtureName) @@ -155,6 +158,7 @@ export async function testLinkOpensInSameTab({ linkData, editMode, shareToken, + trigger, }: SameTabLinkTestCase) { const linkText = 'Link Text' if (!targetPage || !targetCollective) { @@ -171,10 +175,17 @@ export async function testLinkOpensInSameTab({ await sourcePage.open(false, shareToken) await sourcePage.switchMode(editMode) editor.setMode(editMode) - await editor.openCollectiveLink({ - linkText, - pageTitle, - }) + + if (trigger === 'openLinkButton') { + await editor.openLinkViaOpenLinkButton({ linkText }) + } else if (trigger === 'ctrlClick') { + await editor.ctrlClickLink({ linkText }) + } else { + await editor.openCollectiveLinkViaBubblePreview({ + linkText, + pageTitle, + }) + } await expect(page).toHaveURL(linkData.getExpectedUrl({ baseURL, collective: targetCollective, targetPage, shareToken })) } @@ -215,7 +226,7 @@ export async function testLinkOpensInNewTab({ await sourcePage.switchMode(editMode) editor.setMode(editMode) const newTabPromise = page.waitForEvent('popup') - await editor.openLink({ linkText }) + await editor.openLinkViaBubblePreview({ linkText }) const newTab = await newTabPromise await newTab.waitForLoadState() diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts index 0e0ef913d3..3c0bf434da 100644 --- a/playwright/support/sections/EditorSection.ts +++ b/playwright/support/sections/EditorSection.ts @@ -75,34 +75,52 @@ export class EditorSection { await this.getContent() .getByRole('link', { name: linkText, exact: true }) .click() - await this.page.locator('.widgets--list') + await this.page.locator('.link-view-bubble') .waitFor({ state: 'visible' }) - return this.page.locator('.widgets--list') + return this.page.locator('.link-view-bubble') } public async hasCollectiveLink(linkText: string): Promise { await expect((await this.getLinkBubble(linkText)) - .locator('.collective-page .title')) + .locator('.widgets--list .collective-page .title')) .toHaveText(linkText) // Click somewhere else to close the link bubble await this.getContent() .click() } - public async openLink({ linkText }: { + public async openLinkViaBubblePreview({ linkText }: { linkText: string }): Promise { - const link = await this.getLinkBubble(linkText) - await link + const linkBubble = await this.getLinkBubble(linkText) + await linkBubble + .locator('.widgets--list') .getByRole('link') .click() } + public async openLinkViaOpenLinkButton({ linkText }: { + linkText: string + }): Promise { + const linkBubble = await this.getLinkBubble(linkText) + await linkBubble + .getByRole('button', { name: 'Open link' }) + .click() + } + + public async ctrlClickLink({ linkText }: { + linkText: string + }): Promise { + await this.getContent() + .getByRole('link', { name: linkText, exact: true }) + .click({ modifiers: ['Control'] }) + } + public async save(): Promise { await this.editor.getByRole('button', { name: 'Save document' }).click() } - public async openCollectiveLink({ linkText, pageTitle }: { + public async openCollectiveLinkViaBubblePreview({ linkText, pageTitle }: { linkText: string pageTitle?: string }): Promise { diff --git a/src/components/PagePreview.vue b/src/components/PagePreview.vue index 46dcc885a0..22e107c2d2 100644 --- a/src/components/PagePreview.vue +++ b/src/components/PagePreview.vue @@ -53,7 +53,6 @@