Skip to content

Commit e8b166d

Browse files
committed
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 nextcloud/text#8571 Fixes: #2378 Signed-off-by: Jonas <jonas@freesources.org>
1 parent 162e306 commit e8b166d

8 files changed

Lines changed: 274 additions & 24 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type {
7+
GetCollectiveUrlParameters,
8+
SameTabLinkTestCaseData,
9+
} from '../support/helpers/links.ts'
10+
11+
import { mergeTests } from '@playwright/test'
12+
import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts'
13+
import { test as editorTest } from '../support/fixtures/editor.ts'
14+
import { testLinkOpensInSameTab } from '../support/helpers/links.ts'
15+
import { randomString } from '../support/helpers/randomString.ts'
16+
17+
const triggers = ['preview', 'openLinkButton', 'ctrlClick'] as const
18+
19+
const collectiveTest = createCollectiveTest.extend({
20+
// eslint-disable-next-line no-empty-pattern
21+
collectiveConfigs: async ({}, use) => use([
22+
{
23+
name: randomString(),
24+
pages: [
25+
{ title: 'Link Target', content: 'Some content' },
26+
{ title: 'Link Source' },
27+
],
28+
},
29+
]),
30+
})
31+
32+
const test = mergeTests(collectiveTest, editorTest)
33+
34+
test.describe('Link handler: authenticated → public share URL', () => {
35+
for (const editMode of [false, true]) {
36+
const modeLabel = editMode ? 'edit' : 'preview'
37+
for (const trigger of triggers) {
38+
test(`Opens public share URL link in same tab via ${trigger} (${modeLabel} mode)`, async ({ baseURL, collective, editor, page, user }) => {
39+
test.skip(
40+
trigger === 'ctrlClick' && process.env.PLAYWRIGHT_NC_SERVER_BRANCH === 'stable31',
41+
'ctrlClick handler not implemented on stable31',
42+
)
43+
44+
const sourcePage = collective.getPageByTitle('Link Source')
45+
const targetPage = collective.getPageByTitle('Link Target')
46+
47+
if (!baseURL) {
48+
throw new Error('baseURL is not defined')
49+
}
50+
51+
const share = await collective.createShare({ page })
52+
53+
const linkData: SameTabLinkTestCaseData = {
54+
description: 'public share URL',
55+
getLinkUrl: ({ targetPage }: GetCollectiveUrlParameters) => targetPage.getPageUrl(share.data.token),
56+
getExpectedUrl: ({ baseURL, targetPage }: GetCollectiveUrlParameters) => (new URL(targetPage.getPageUrl(), baseURL)).href,
57+
}
58+
59+
await testLinkOpensInSameTab({
60+
baseURL,
61+
page,
62+
user,
63+
editor,
64+
sourcePage,
65+
targetPage,
66+
targetCollective: collective,
67+
linkData,
68+
editMode,
69+
trigger,
70+
})
71+
72+
await share.delete()
73+
})
74+
}
75+
}
76+
})
77+
78+
test.describe('Link handler: public share → internal URL', () => {
79+
for (const editMode of [false, true]) {
80+
const modeLabel = editMode ? 'edit' : 'preview'
81+
for (const trigger of triggers) {
82+
test(`Opens internal URL link in same tab via ${trigger} in share context (${modeLabel} mode)`, async ({ baseURL, collective, editor, page, user }) => {
83+
const sourcePage = collective.getPageByTitle('Link Source')
84+
const targetPage = collective.getPageByTitle('Link Target')
85+
86+
if (!baseURL) {
87+
throw new Error('baseURL is not defined')
88+
}
89+
90+
const share = await collective.createShare({ page })
91+
if (editMode) {
92+
await share.setEditable(true)
93+
}
94+
95+
const linkData: SameTabLinkTestCaseData = {
96+
description: 'internal URL without share token',
97+
getLinkUrl: ({ targetPage }: GetCollectiveUrlParameters) => targetPage.getPageUrl(),
98+
getExpectedUrl: ({ baseURL, targetPage, shareToken }: GetCollectiveUrlParameters) => (new URL(targetPage.getPageUrl(shareToken), baseURL)).href,
99+
}
100+
101+
await testLinkOpensInSameTab({
102+
baseURL,
103+
page,
104+
user,
105+
editor,
106+
sourcePage,
107+
targetPage,
108+
targetCollective: collective,
109+
linkData,
110+
editMode,
111+
shareToken: share.data.token,
112+
trigger,
113+
})
114+
115+
await share.delete()
116+
})
117+
}
118+
}
119+
})

playwright/support/helpers/links.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export type NewTabLinkTestCaseData = {
4242
getExpectedUrl: (params: GetUrlParameters) => string
4343
}
4444

45+
type LinkTrigger = 'preview' | 'openLinkButton' | 'ctrlClick'
46+
4547
export type ViewerLinkTestCase = {
4648
page: Page
4749
user: User
@@ -63,6 +65,7 @@ export type SameTabLinkTestCase = {
6365
linkData: SameTabLinkTestCaseData
6466
editMode: boolean
6567
shareToken?: string
68+
trigger?: LinkTrigger
6669
}
6770

6871
export type NewTabLinkTestCase = {
@@ -111,7 +114,7 @@ export async function testLinkOpensInViewer({
111114
await sourcePage.open(false)
112115
await sourcePage.switchMode(editMode)
113116
editor.setMode(editMode)
114-
await editor.openLink({ linkText })
117+
await editor.openLinkViaBubblePreview({ linkText })
115118
await expect(sourcePage.getViewerContent()
116119
.locator('.modal-header'))
117120
.toContainText(linkData.fixtureName)
@@ -155,6 +158,7 @@ export async function testLinkOpensInSameTab({
155158
linkData,
156159
editMode,
157160
shareToken,
161+
trigger,
158162
}: SameTabLinkTestCase) {
159163
const linkText = 'Link Text'
160164
if (!targetPage || !targetCollective) {
@@ -171,10 +175,17 @@ export async function testLinkOpensInSameTab({
171175
await sourcePage.open(false, shareToken)
172176
await sourcePage.switchMode(editMode)
173177
editor.setMode(editMode)
174-
await editor.openCollectiveLink({
175-
linkText,
176-
pageTitle,
177-
})
178+
179+
if (trigger === 'openLinkButton') {
180+
await editor.openLinkViaOpenLinkButton({ linkText })
181+
} else if (trigger === 'ctrlClick') {
182+
await editor.ctrlClickLink({ linkText })
183+
} else {
184+
await editor.openCollectiveLinkViaBubblePreview({
185+
linkText,
186+
pageTitle,
187+
})
188+
}
178189

179190
await expect(page).toHaveURL(linkData.getExpectedUrl({ baseURL, collective: targetCollective, targetPage, shareToken }))
180191
}
@@ -215,7 +226,7 @@ export async function testLinkOpensInNewTab({
215226
await sourcePage.switchMode(editMode)
216227
editor.setMode(editMode)
217228
const newTabPromise = page.waitForEvent('popup')
218-
await editor.openLink({ linkText })
229+
await editor.openLinkViaBubblePreview({ linkText })
219230
const newTab = await newTabPromise
220231
await newTab.waitForLoadState()
221232

playwright/support/sections/EditorSection.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,34 +75,52 @@ export class EditorSection {
7575
await this.getContent()
7676
.getByRole('link', { name: linkText, exact: true })
7777
.click()
78-
await this.page.locator('.widgets--list')
78+
await this.page.locator('.link-view-bubble')
7979
.waitFor({ state: 'visible' })
80-
return this.page.locator('.widgets--list')
80+
return this.page.locator('.link-view-bubble')
8181
}
8282

8383
public async hasCollectiveLink(linkText: string): Promise<void> {
8484
await expect((await this.getLinkBubble(linkText))
85-
.locator('.collective-page .title'))
85+
.locator('.widgets--list .collective-page .title'))
8686
.toHaveText(linkText)
8787
// Click somewhere else to close the link bubble
8888
await this.getContent()
8989
.click()
9090
}
9191

92-
public async openLink({ linkText }: {
92+
public async openLinkViaBubblePreview({ linkText }: {
9393
linkText: string
9494
}): Promise<void> {
95-
const link = await this.getLinkBubble(linkText)
96-
await link
95+
const linkBubble = await this.getLinkBubble(linkText)
96+
await linkBubble
97+
.locator('.widgets--list')
9798
.getByRole('link')
9899
.click()
99100
}
100101

102+
public async openLinkViaOpenLinkButton({ linkText }: {
103+
linkText: string
104+
}): Promise<void> {
105+
const linkBubble = await this.getLinkBubble(linkText)
106+
await linkBubble
107+
.getByRole('button', { name: 'Open link' })
108+
.click()
109+
}
110+
111+
public async ctrlClickLink({ linkText }: {
112+
linkText: string
113+
}): Promise<void> {
114+
await this.getContent()
115+
.getByRole('link', { name: linkText, exact: true })
116+
.click({ modifiers: ['Control'] })
117+
}
118+
101119
public async save(): Promise<void> {
102120
await this.editor.getByRole('button', { name: 'Save document' }).click()
103121
}
104122

105-
public async openCollectiveLink({ linkText, pageTitle }: {
123+
public async openCollectiveLinkViaBubblePreview({ linkText, pageTitle }: {
106124
linkText: string
107125
pageTitle?: string
108126
}): Promise<void> {

src/components/PagePreview.vue

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353

5454
<script lang="ts">
5555
import { t } from '@nextcloud/l10n'
56-
import { generateUrl } from '@nextcloud/router'
5756
import { defineComponent } from 'vue'
5857
import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
5958
import PageIcon from './Icon/PageIcon.vue'
@@ -143,18 +142,13 @@ export default defineComponent({
143142
t,
144143
145144
clickLink(event: Event) {
146-
if (this.notFound || !this.link) {
145+
if (!this.link) {
147146
return false
148147
}
149148
150-
const appUrl = '/apps/collectives'
151-
const linkUrl = new URL(this.link, window.location)
152-
// Only consider rerouting if we're inside the collectives app and for links to collectives app
153-
if (window.OCA.Collectives?.vueRouter
154-
&& linkUrl.pathname.toString().startsWith(generateUrl(appUrl))) {
149+
if (window.OCA.Collectives?.openLink) {
155150
event.preventDefault()
156-
const collectivesUrl = linkUrl.href.substring(linkUrl.href.indexOf(appUrl) + appUrl.length)
157-
window.OCA.Collectives.vueRouter.push(collectivesUrl)
151+
window.OCA.Collectives.openLink(this.link)
158152
}
159153
},
160154
},
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Pinia } from 'pinia'
7+
import type { Router } from 'vue-router'
8+
import type { Collective } from '../types.ts'
9+
10+
import { generateUrl } from '@nextcloud/router'
11+
import { useCollectivesStore } from '../stores/collectives.js'
12+
import { useRootStore } from '../stores/root.js'
13+
14+
/**
15+
* Check whether a URL collective segment (slug or name form) refers to
16+
* the same collective as `currentCollective`.
17+
*
18+
* @param segment - URL-encoded collective segment
19+
* @param currentCollective - store object with id, name, slug
20+
*/
21+
function isSegmentSameCollective(segment: string, currentCollective: Collective | null | undefined) {
22+
if (!currentCollective) {
23+
return false
24+
}
25+
const decoded = decodeURIComponent(segment)
26+
// Try slug form: "CollectiveName-123"
27+
const slugMatch = decoded.match(/^(.+)-(\d+)$/)
28+
if (slugMatch) {
29+
return Number(slugMatch[2]) === currentCollective.id
30+
}
31+
// Fall back to name form
32+
return decoded === currentCollective.name
33+
}
34+
35+
/**
36+
* Create the smart Collectives link opener.
37+
* Must be called after pinia and router are set up.
38+
*
39+
* @param router Vue Router instance
40+
* @param pinia Pinia instance
41+
*/
42+
export function createOpenCollectivesLink(router: Router, pinia: Pinia) {
43+
return function openCollectivesLink(href: string) {
44+
const rootStore = useRootStore(pinia)
45+
const collectivesStore = useCollectivesStore(pinia)
46+
47+
let linkUrl: URL
48+
try {
49+
linkUrl = new URL(href, window.location.href)
50+
} catch {
51+
window.open(href, '_blank')
52+
return
53+
}
54+
55+
const collectivesBase = generateUrl('/apps/collectives')
56+
if (!linkUrl.pathname.startsWith(collectivesBase)) {
57+
window.open(linkUrl.href, '_blank')
58+
return
59+
}
60+
61+
// The path inside the collectives router (everything after /apps/collectives)
62+
const pathInRouter = linkUrl.pathname.slice(collectivesBase.length) || '/'
63+
const searchAndHash = linkUrl.search + linkUrl.hash
64+
65+
// Is the target a public share URL? Matches /p/<token>[/...]
66+
const publicShareMatch = pathInRouter.match(/^\/p\/([^/]+)(\/.*)?$/)
67+
68+
if (rootStore.isPublic) {
69+
const currentToken = rootStore.shareTokenParam
70+
71+
if (publicShareMatch) {
72+
// public → public: navigate directly (same or different token, router handles it)
73+
router.push(pathInRouter + searchAndHash)
74+
} else {
75+
// public → internal: check if same collective
76+
// pathInRouter looks like /<collectiveSegment>[/...]
77+
const internalMatch = pathInRouter.match(/^\/([^/]+)(\/.*)?$/)
78+
if (!internalMatch) {
79+
window.open(linkUrl.href, '_blank')
80+
return
81+
}
82+
const collectiveSegment = internalMatch[1]
83+
const rest = internalMatch[2] || ''
84+
85+
if (isSegmentSameCollective(collectiveSegment, collectivesStore.currentCollective)) {
86+
// Rewrite to /p/<token>/<segment><rest>
87+
router.push(`/p/${currentToken}/${collectiveSegment}${rest}${searchAndHash}`)
88+
} else {
89+
// Different collective — open in new tab
90+
window.open(linkUrl.href, '_blank')
91+
}
92+
}
93+
} else {
94+
if (publicShareMatch) {
95+
// authenticated → public share link: strip /p/<token>
96+
const rest = publicShareMatch[2] || '/'
97+
router.push(rest + searchAndHash)
98+
} else {
99+
// authenticated → internal: navigate directly
100+
router.push(pathInRouter + searchAndHash)
101+
}
102+
}
103+
}
104+
}

src/composables/useEditor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function useEditor(davContent: Ref<string>) {
110110
return getLinkWithPicker('collectives-ref-pages', false)
111111
},
112112
},
113+
openLinkHandler: window.OCA.Collectives.openLink,
113114
onCreate: ({ markdown }: { markdown: string }) => {
114115
updateEditorContentDebounced(markdown)
115116
},

src/composables/useReader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function useReader(content: Ref<string>) {
122122
component: 'page-info-bar',
123123
props: {},
124124
},
125+
openLinkHandler: window.OCA.Collectives.openLink,
125126
onOutlineToggle: pagesStore.setOutlineForCurrentPage,
126127
onLoaded: () => {
127128
nextTick(updateReadonlyBarProps)

0 commit comments

Comments
 (0)