Skip to content

Commit 3b3d44a

Browse files
committed
test(editor): run task link tests against the real editor extension config
Includes a paste-order test that fails without the explicit TaskLink priority over the markdown paste handler.
1 parent 51e4d0c commit 3b3d44a

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import {describe, it, expect, beforeEach} from 'vitest'
2+
import {createPinia, setActivePinia} from 'pinia'
3+
import {ref} from 'vue'
4+
import {Editor} from '@tiptap/core'
5+
import {Fragment, Slice} from '@tiptap/pm/model'
6+
import {createEditorExtensions, type EditorExtensionDeps} from './editorExtensions'
7+
8+
const TASK_URL = 'http://localhost:3000/tasks/123'
9+
10+
// Inert deps so the editor runs TipTap.vue's real extension set, whose markdown
11+
// paste handler and Link mark both compete with TaskLink for pastes.
12+
const stubDeps: EditorExtensionDeps = {
13+
t: key => key,
14+
isEditing: ref(true),
15+
isEditEnabled: () => true,
16+
placeholder: '',
17+
contentHasChanged: ref(false),
18+
bubbleSave: () => {},
19+
getEditor: () => undefined,
20+
uploadCallback: undefined,
21+
uploadAndInsertFiles: () => {},
22+
loadedAttachments: ref({}),
23+
attachmentService: {} as never,
24+
}
25+
26+
// pasteText() leaves clipboardData empty, which the markdown paste handler bails
27+
// on — go through the plugin chain the way ProseMirror does instead.
28+
function paste(editor: Editor, text: string) {
29+
const event = {clipboardData: {getData: () => text, items: []}} as unknown as ClipboardEvent
30+
const slice = new Slice(Fragment.from(editor.schema.text(text)), 0, 0)
31+
editor.view.someProp('handlePaste', handler => handler(editor.view, event, slice))
32+
}
33+
34+
beforeEach(() => {
35+
setActivePinia(createPinia())
36+
})
37+
38+
describe('TaskLink extension', () => {
39+
const createEditor = (content: string = '') => {
40+
return new Editor({
41+
extensions: createEditorExtensions(stubDeps),
42+
content,
43+
})
44+
}
45+
46+
const findTaskLinks = (editor: Editor) => {
47+
const found: {href: string}[] = []
48+
editor.state.doc.descendants(node => {
49+
if (node.type.name === 'taskLink') {
50+
found.push({href: node.attrs.href})
51+
}
52+
})
53+
return found
54+
}
55+
56+
it('upgrades a same-origin task url anchor whose text equals its href', () => {
57+
const editor = createEditor(`<p><a href="${TASK_URL}">${TASK_URL}</a></p>`)
58+
59+
expect(findTaskLinks(editor)).toEqual([{href: TASK_URL}])
60+
61+
editor.destroy()
62+
})
63+
64+
it('serializes back to the same plain anchor', () => {
65+
const html = `<p><a href="${TASK_URL}">${TASK_URL}</a></p>`
66+
const editor = createEditor(html)
67+
68+
expect(editor.getHTML()).toBe(html)
69+
70+
editor.destroy()
71+
})
72+
73+
it('preserves target and rel attributes through the round-trip', () => {
74+
const html = `<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}">${TASK_URL}</a></p>`
75+
const editor = createEditor(html)
76+
77+
expect(findTaskLinks(editor)).toHaveLength(1)
78+
expect(editor.getHTML()).toBe(html)
79+
80+
editor.destroy()
81+
})
82+
83+
it('leaves a task url anchor with custom text as a plain link', () => {
84+
const html = `<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}">see this task</a></p>`
85+
const editor = createEditor(html)
86+
87+
expect(findTaskLinks(editor)).toHaveLength(0)
88+
expect(editor.getHTML()).toBe(html)
89+
90+
editor.destroy()
91+
})
92+
93+
it('leaves a non-task anchor as a plain link', () => {
94+
const html = '<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com/">https://example.com/</a></p>'
95+
const editor = createEditor(html)
96+
97+
expect(findTaskLinks(editor)).toHaveLength(0)
98+
expect(editor.getHTML()).toBe(html)
99+
100+
editor.destroy()
101+
})
102+
103+
it('leaves an other-origin task url as a plain link', () => {
104+
const html = '<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://other.example.com/tasks/123">https://other.example.com/tasks/123</a></p>'
105+
const editor = createEditor(html)
106+
107+
expect(findTaskLinks(editor)).toHaveLength(0)
108+
expect(editor.getHTML()).toBe(html)
109+
110+
editor.destroy()
111+
})
112+
113+
it('leaves a task url anchor containing child markup as a plain link', () => {
114+
const html = `<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}"><u>${TASK_URL}</u></a></p>`
115+
const editor = createEditor(html)
116+
117+
expect(findTaskLinks(editor)).toHaveLength(0)
118+
expect(editor.getHTML()).toBe(html)
119+
120+
editor.destroy()
121+
})
122+
123+
it('leaves a task url anchor with extra attributes as a plain link', () => {
124+
const html = `<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}" title="x">${TASK_URL}</a></p>`
125+
const editor = createEditor(html)
126+
127+
expect(findTaskLinks(editor)).toHaveLength(0)
128+
expect(editor.getHTML()).toBe(html)
129+
130+
editor.destroy()
131+
})
132+
133+
it('creates one node per link, even for the same task', () => {
134+
const editor = createEditor(
135+
`<p><a href="${TASK_URL}">${TASK_URL}</a> and <a href="${TASK_URL}">${TASK_URL}</a></p>`,
136+
)
137+
138+
expect(findTaskLinks(editor)).toHaveLength(2)
139+
140+
editor.destroy()
141+
})
142+
})
143+
144+
describe('TaskLink paste handling', () => {
145+
const createEditor = (content: string = '<p></p>') => {
146+
return new Editor({
147+
extensions: createEditorExtensions(stubDeps),
148+
content,
149+
})
150+
}
151+
152+
const countTaskLinks = (editor: Editor) => {
153+
let count = 0
154+
editor.state.doc.descendants(node => {
155+
if (node.type.name === 'taskLink') {
156+
count++
157+
}
158+
})
159+
return count
160+
}
161+
162+
it('inserts a taskLink node when pasting a bare task url', () => {
163+
const editor = createEditor()
164+
editor.commands.focus('end')
165+
166+
editor.view.pasteText(TASK_URL)
167+
168+
expect(countTaskLinks(editor)).toBe(1)
169+
expect(editor.getHTML()).toBe(`<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}">${TASK_URL}</a></p>`)
170+
171+
editor.destroy()
172+
})
173+
174+
it('does not pill-ify a task url pasted as part of a longer text', () => {
175+
const editor = createEditor()
176+
editor.commands.focus('end')
177+
178+
editor.view.pasteText(`see ${TASK_URL} please`)
179+
180+
expect(countTaskLinks(editor)).toBe(0)
181+
expect(editor.getText()).toBe(`see ${TASK_URL} please`)
182+
183+
editor.destroy()
184+
})
185+
186+
it('leaves pastes inside code blocks alone', () => {
187+
const editor = createEditor('<pre><code></code></pre>')
188+
editor.commands.focus('end')
189+
190+
editor.view.pasteText(TASK_URL)
191+
192+
expect(countTaskLinks(editor)).toBe(0)
193+
expect(editor.getHTML()).toBe(`<pre><code>${TASK_URL}</code></pre><p></p>`)
194+
195+
editor.destroy()
196+
})
197+
198+
it('does not pill-ify when text is selected (link mark applies instead)', () => {
199+
const editor = createEditor('<p>hello</p>')
200+
editor.commands.setTextSelection({from: 1, to: 6})
201+
202+
editor.view.pasteText(TASK_URL)
203+
204+
expect(countTaskLinks(editor)).toBe(0)
205+
expect(editor.getHTML()).toBe(`<p><a target="_blank" rel="noopener noreferrer nofollow" href="${TASK_URL}">hello</a></p>`)
206+
expect(editor.getText()).toBe('hello')
207+
208+
editor.destroy()
209+
})
210+
211+
it('pill-ifies a task url containing markdown syntax characters', () => {
212+
const editor = createEditor()
213+
editor.commands.focus('end')
214+
const url = `${TASK_URL}#comment-1`
215+
216+
paste(editor, url)
217+
218+
expect(countTaskLinks(editor)).toBe(1)
219+
expect(editor.getHTML()).toBe(`<p><a target="_blank" rel="noopener noreferrer nofollow" href="${url}">${url}</a></p>`)
220+
221+
editor.destroy()
222+
})
223+
224+
it('ignores non-task urls', () => {
225+
const editor = createEditor()
226+
editor.commands.focus('end')
227+
228+
editor.view.pasteText('https://example.com/')
229+
230+
expect(countTaskLinks(editor)).toBe(0)
231+
232+
editor.destroy()
233+
})
234+
})

0 commit comments

Comments
 (0)