diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index 70ac31e91..3aef2de87 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -722,7 +722,7 @@ async function addImage(event: Event) { return } - const url = await inputPrompt(event.target.getBoundingClientRect()) + const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value) if (url) { editor.value?.chain().focus().setImage({src: url}).run() diff --git a/frontend/src/components/input/editor/setLinkInEditor.ts b/frontend/src/components/input/editor/setLinkInEditor.ts index f5f547ff4..b1fa4e88e 100644 --- a/frontend/src/components/input/editor/setLinkInEditor.ts +++ b/frontend/src/components/input/editor/setLinkInEditor.ts @@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt' export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) { const previousUrl = editor?.getAttributes('link').href || '' - const url = await inputPrompt(pos, previousUrl) + const url = await inputPrompt(pos, previousUrl, editor ?? undefined) // empty if (url === '') { diff --git a/frontend/src/helpers/inputPrompt.ts b/frontend/src/helpers/inputPrompt.ts index 11dd3325f..77b70e089 100644 --- a/frontend/src/helpers/inputPrompt.ts +++ b/frontend/src/helpers/inputPrompt.ts @@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId' import {computePosition, flip, shift, offset} from '@floating-ui/dom' import {nextTick} from 'vue' import {eventToShortcutString} from '@/helpers/shortcut' +import type {Editor} from '@tiptap/core' +import {getPopupContainer} from '@/components/input/editor/popupContainer' -export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise { +export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise { return new Promise((resolve) => { const id = 'link-input-' + createRandomID() + // Append inside the open task (top-layer) when present, otherwise + // document.body. A body-level popup is painted behind a showModal() dialog + // and unfocusable through its focus trap, breaking the link prompt in the + // Kanban task popup (#2940). + const container = getPopupContainer(editor) // Create popup element const popupElement = document.createElement('div') @@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro inputElement.value = oldValue wrapperDiv.appendChild(inputElement) popupElement.appendChild(wrapperDiv) - document.body.appendChild(popupElement) + container.appendChild(popupElement) // Create a local mutable copy of the position for scroll tracking let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height) @@ -84,8 +91,8 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro const cleanup = () => { window.removeEventListener('scroll', handleScroll, true) - if (document.body.contains(popupElement)) { - document.body.removeChild(popupElement) + if (container.contains(popupElement)) { + container.removeChild(popupElement) } } diff --git a/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts b/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts new file mode 100644 index 000000000..43fe85c75 --- /dev/null +++ b/frontend/tests/e2e/editor/link-prompt-kanban-popup.spec.ts @@ -0,0 +1,67 @@ +import {test, expect} from '../../support/fixtures' +import {ProjectFactory} from '../../factories/project' +import {ProjectViewFactory} from '../../factories/project_view' +import {BucketFactory} from '../../factories/bucket' +import {TaskFactory} from '../../factories/task' +import {TaskBucketFactory} from '../../factories/task_buckets' + +// Regression test for #2940: in the Kanban task popup the description editor is +// rendered inside a native opened via showModal() (browser top-layer). +// The link prompt used to be appended to document.body, so it was painted behind +// the dialog and unfocusable through its focus trap, making "set link" a no-op. +test.describe('Editor link prompt inside the Kanban task popup', () => { + test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => { + const projects = await ProjectFactory.create(1) + const views = await ProjectViewFactory.create(1, { + id: 1, + project_id: projects[0].id, + view_kind: 3, + bucket_configuration_mode: 1, + }) + const buckets = await BucketFactory.create(1, { + project_view_id: views[0].id, + }) + const tasks = await TaskFactory.create(1, { + project_id: projects[0].id, + description: 'link me', + index: 1, + }) + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: buckets[0].id, + project_view_id: views[0].id, + }) + + await page.goto(`/projects/${projects[0].id}/${views[0].id}`) + + const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title}) + await expect(card).toBeVisible() + await card.click() + + // The task popup must be a native in the top layer. + const dialog = page.locator('dialog[open]') + await expect(dialog).toBeVisible() + await expect(dialog.locator('.task-view')).toBeVisible() + + const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'}) + await expect(editButton).toBeVisible({timeout: 10000}) + await editButton.click() + + const description = dialog.locator('.details.content.description') + const editor = description.locator('[contenteditable="true"]').first() + await expect(editor).toBeVisible({timeout: 10000}) + await editor.click() + await page.keyboard.press('ControlOrMeta+a') + + await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click() + + const urlInput = dialog.locator('input.input[placeholder="URL"]') + await expect(urlInput).toBeVisible() + await urlInput.fill('https://vikunja.io') + await urlInput.press('Enter') + + const link = editor.locator('a[href="https://vikunja.io"]') + await expect(link).toBeVisible() + await expect(link).toHaveText('link me') + }) +})