diff --git a/playwright/e2e/editor-lifecycle.spec.ts b/playwright/e2e/editor-lifecycle.spec.ts new file mode 100644 index 0000000000..b22aa4dca4 --- /dev/null +++ b/playwright/e2e/editor-lifecycle.spec.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../support/fixtures/create-collectives.ts' +import { observeTextEditors } from '../support/helpers/textEditor.ts' + +test('leaving during editor initialization does not affect the next page', async ({ user, page, collective }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await observeTextEditors(page) + const first = await collective.createPage({ title: 'Source', content: 'Source content', user, page }) + const second = await collective.createPage({ title: 'Destination', content: 'Destination content', user, page }) + await first.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.holdNext = true + }) + await first.getModeButton(true).click() + await page.waitForFunction(() => { + return (window as any).collectivesTextProbe.release !== null + }) + await page.evaluate(async (path) => { + await (window as any).OCA.Collectives.vueRouter.push(path) + }, second.getPageUrl().replace('/index.php/apps/collectives', '')) + await expect(second.getContent()).toContainText('Destination content') + await second.switchMode(true) + await expect(second.getContent(true)).toContainText('Destination content') + await page.evaluate(() => { + (window as any).collectivesTextProbe.release() + }) + await page.waitForFunction((id) => { + return (window as any).collectivesTextProbe.calls.some((call: { fileId: number, writable: boolean, resolved: boolean }) => call.fileId === id && call.writable && call.resolved) + }, first.data.id) + await expect(second.getContent(true)).toContainText('Destination content') + await second.getContent(true).fill('Destination saved after navigation') + await second.switchMode(false) + await expect(second.getContent()).toContainText('Destination saved after navigation') + await page.reload() + await expect(second.getContent()).toContainText('Destination saved after navigation') + await first.open(false) + await expect(first.getContent()).toContainText('Source content') + expect(pageErrors).toEqual([]) +}) + +test('editor initialization rejection can be retried without navigation', async ({ user, page, collective }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Retry editor', content: 'Initial content', user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failNext = true + }) + await cp.getModeButton(true).click() + await expect(page.getByText('Could not load the editor. Please try again.')).toBeVisible() + await page.locator('.toastify').filter({ hasText: 'Could not load the editor.' }).getByRole('button', { name: 'Close', exact: true }).click() + await cp.switchMode(true) + await cp.getContent(true).fill('Saved after retry') + await cp.switchMode(false) + await expect(cp.getContent()).toContainText('Saved after retry') + await page.reload() + await expect(cp.getContent()).toContainText('Saved after retry') + expect(pageErrors).toEqual([]) +}) diff --git a/playwright/e2e/page-attachments.spec.ts b/playwright/e2e/page-attachments.spec.ts new file mode 100644 index 0000000000..336230e3ac --- /dev/null +++ b/playwright/e2e/page-attachments.spec.ts @@ -0,0 +1,149 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../support/fixtures/create-collectives.ts' +import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts' +import { webdavUrl } from '../support/helpers/urls.ts' +import { PageSidebarSection } from '../support/sections/PageSidebarSection.ts' + +for (const action of ['Rename', 'Delete']) { + test(`${action} embedded attachment from fresh preview persists file and references`, async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: `Attachment ${action}`, content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await waitForTextEditors(page) + const sidebar = new PageSidebarSection(page) + const tab = await sidebar.openSidebarTab('Attachments') + const item = tab.locator('.attachment').filter({ hasText: 'test.png' }) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: action, exact: true }).click() + if (action === 'Rename') { + await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png') + await page.getByRole('button', { name: 'Rename attachment', exact: true }).click() + } + // Eventual persistence alone can hide a failed save confirmation. + const success = action === 'Rename' ? 'Renamed attachment' : 'Deleted attachment test.png' + await expect(page.locator('.toastify').filter({ hasText: success })).toBeVisible() + await expect(page.locator('.toastify').filter({ hasText: 'but the page could not be saved' })).toHaveCount(0) + const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName) + const oldUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? !(await response.text()).includes(src) : false + }).toBe(true) + await expect.poll(async () => (await page.request.get(oldUrl)).status()).toBe(404) + if (action === 'Rename') { + const newUrl = oldUrl.replace('test.png', 'renamed.png') + expect((await page.request.get(newUrl)).status()).toBe(200) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? response.text() : 'waiting for readable Markdown' + }).toContain('renamed.png') + } + await page.reload() + await cp.waitForContent(false) + if (action === 'Rename') { + await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/) + } else { + await expect(cp.getContent().locator('img')).toHaveCount(0) + } + }) +} + +test('embedded attachment classification survives preview edit preview and reload', async ({ user, page, collective }) => { + const cp = await collective.createPage({ title: 'Attachment classification', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + const embedded = tab.locator('.attachment-list-embedded .attachment').filter({ hasText: 'test.png' }) + await expect(embedded).toBeVisible() + await embedded.getByRole('button', { name: 'Actions', exact: true }).click() + await expect(page.getByRole('menuitem', { name: 'View in page', exact: true })).toBeVisible() + await page.keyboard.press('Escape') + await cp.switchMode(true) + await cp.switchMode(false) + await expect(embedded).toBeVisible() + await page.reload() + await expect(embedded).toBeVisible() +}) + +test('failed editor initialization prevents attachment deletion and permits retry', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Initialization failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failNext = true + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + const item = tab.locator('.attachment').filter({ hasText: 'test.png' }) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect(page.getByText('Failed to delete attachment')).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect.poll(async () => (await page.request.get(fileUrl)).status()).toBe(404) + await page.reload() + await expect(cp.getContent().locator('img')).toHaveCount(0) +}) + +test('failed preflight save prevents attachment deletion', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Save failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSave = true + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect(page.getByText('Failed to delete attachment')).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await page.reload() + await expect(cp.getContent().locator('img')).toBeVisible() +}) + +test('failed final save reports partial rename and can be saved again', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Final save failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSaveAfter = 1 + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Rename', exact: true }).click() + await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png') + await page.getByRole('button', { name: 'Rename attachment', exact: true }).click() + await expect(page.getByText('The attachment was renamed, but the page could not be saved. Keep the editor open and try saving again.')).toBeVisible() + await expect(page.locator('.toastify').filter({ hasText: /^Renamed attachment/ })).toHaveCount(0) + await page.locator('.toastify').filter({ hasText: 'The attachment was renamed, but the page could not be saved.' }).getByRole('button', { name: 'Close', exact: true }).click() + await expect(cp.getContent(true)).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src.replace('test.png', 'renamed.png')) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSaveAfter = -1 + }) + await cp.switchMode(false) + const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? response.text() : 'waiting for readable Markdown' + }).toContain('renamed.png') + await page.reload() + await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/) +}) diff --git a/playwright/e2e/page-content.spec.ts b/playwright/e2e/page-content.spec.ts index 57d63e48ae..4c7a4e1edc 100644 --- a/playwright/e2e/page-content.spec.ts +++ b/playwright/e2e/page-content.spec.ts @@ -7,10 +7,41 @@ import { runOcc } from '@nextcloud/e2e-test-server/docker' import { expect, mergeTests } from '@playwright/test' import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' import { test as editorTest } from '../support/fixtures/editor.ts' +import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts' const test = mergeTests(createCollectiveTest, editorTest) test.describe('Page content', () => { + test('preview does not lock the page for WebDAV writers', async ({ user, page, collective }) => { + await observeTextEditors(page) + const collectivePage = await collective.createPage({ + title: 'Preview without a writable Text session', + content: 'Original content', + user, + page, + }) + await collectivePage.open(false) + await expect(collectivePage.getContent()).toContainText('Original content') + await waitForTextEditors(page) + // A hidden writable editor would acquire a Text lock and reject this PUT. + await collectivePage.setContent({ content: 'Updated through WebDAV', user, page }) + const writableCalls = await page.evaluate(() => { + return (window as any).collectivesTextProbe.calls.filter((call: { writable: boolean }) => call.writable).length + }) + expect(writableCalls).toBe(0) + await page.reload() + await collectivePage.waitForContent(false) + await expect(collectivePage.getContent()).toContainText('Updated through WebDAV') + // Lazy initialization must still allow switching from reading to editing. + await collectivePage.switchMode(true) + await expect(collectivePage.getContent(true)).toContainText('Updated through WebDAV') + await collectivePage.getContent(true).fill('Edited after entering edit mode') + await collectivePage.switchMode(false) + await expect(collectivePage.getContent()).toContainText('Edited after entering edit mode') + await collectivePage.switchMode(true) + await expect(collectivePage.getContent(true)).toContainText('Edited after entering edit mode') + }) + test('create whiteboard from attachments menu', async ({ user, page, collective, editor }) => { test.slow() await runOcc(['app:enable', '--force', 'whiteboard']) diff --git a/playwright/support/helpers/textEditor.ts b/playwright/support/helpers/textEditor.ts new file mode 100644 index 0000000000..1322fda204 --- /dev/null +++ b/playwright/support/helpers/textEditor.ts @@ -0,0 +1,131 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +/** + * Observe the real Text API without replacing its session/network behavior. + * Test-controlled failures/delays are used only when explicitly armed. + * + * @param page Browser page before navigation. + */ +export async function observeTextEditors(page: Page) { + await page.addInitScript(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any + const probe = w.collectivesTextProbe = { + calls: [] as Array<{ writable: boolean, loaded: boolean, resolved: boolean, fileId: number }>, + failNext: false, + holdNext: false, + failSave: false, + failSaveAfter: -1, + release: null as null | (() => void), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instrument = (text: any) => { + // Text installs createEditor after assigning the OCA.Text namespace. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrap = (create: any) => async function(this: unknown, options: any) { + const writable = options.readOnly === false && options.useSession !== false + const call = { writable, loaded: false, resolved: false, fileId: options.fileId } + probe.calls.push(call) + if (writable && probe.failNext) { + probe.failNext = false + throw new Error('Injected editor initialization failure') + } + if (writable && probe.holdNext) { + probe.holdNext = false + await new Promise((resolve) => { + probe.release = resolve + }) + } + const instance = await create.call(this, { + ...options, + onLoaded: () => { + call.loaded = true + options.onLoaded?.() + }, + }) + call.resolved = true + if (writable) { + const save = instance.save.bind(instance) + instance.save = () => { + if (probe.failSave || probe.failSaveAfter === 0) { + return Promise.resolve(false) + } + if (probe.failSaveAfter > 0) { + probe.failSaveAfter-- + } + return save() + } + } + return instance + } + let wrapped = typeof text.createEditor === 'function' ? wrap(text.createEditor) : text.createEditor + Object.defineProperty(text, 'createEditor', { + configurable: true, + get: () => wrapped, + set: (value) => { + wrapped = wrap(value) + }, + }) + return text + } + // Core may replace window.OCA before Text installs its API. + const namespaces = new WeakSet() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const observeNamespace = (namespace: any) => { + if (namespaces.has(namespace)) { + return namespace + } + namespaces.add(namespace) + let text = namespace.Text + if (text) { + text = instrument(text) + } + Object.defineProperty(namespace, 'Text', { + configurable: true, + get: () => text, + set: (value) => { + text = instrument(value) + }, + }) + return namespace + } + let oca = observeNamespace(w.OCA || {}) + Object.defineProperty(w, 'OCA', { + configurable: true, + get: () => oca, + set: (value) => { + oca = observeNamespace(value) + }, + }) + }) +} + +/** + * Wait for every editor started during mounting, including a hidden writer. + * This deliberately does not require absence of a writer: the baseline must + * reach the WebDAV PUT and fail there with 423, after acquiring its Text lock. + * + * @param page Browser page with the Text observer installed. + */ +export async function waitForTextEditors(page: Page) { + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const calls = (window as any).collectivesTextProbe.calls + return calls.length > 0 && calls.every((call: { loaded: boolean, resolved: boolean }) => call.loaded && call.resolved) + }) + const hasWriter = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).collectivesTextProbe.calls.some((call: { writable: boolean }) => call.writable) + }) + if (hasWriter) { + // Also wait for the editable document to mount in the hidden container; + // a ready callback alone does not prove that its DOM has rendered. + await page.locator('[data-cy-collectives="editor"] .ProseMirror[contenteditable="true"]').waitFor({ state: 'attached' }) + } + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) +} diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index a05e46dcdb..02f9cb075c 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -31,6 +31,7 @@ import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { t } from '@nextcloud/l10n' import { useElementSize } from '@vueuse/core' +import escapeHtml from 'escape-html' import { mapActions, mapState } from 'pinia' import { ref, watch } from 'vue' import SkeletonLoading from '../SkeletonLoading.vue' @@ -69,14 +70,16 @@ export default { document.documentElement.style.setProperty('--text-container-width', value + 'px') }) const davContent = ref('') - const { contentLoaded, editor, editorContent, editorEl, pageContent, setupEditor } = useEditor(davContent) + const { contentLoaded, editor, editorContent, editorEl, pageContent, setupEditor, saveEditor } = useEditor(davContent) const { pageInfoBarPage, reader, readerEl, setupReader } = useReader(pageContent) - return { contentLoaded, davContent, editor, editorContent, editorEl, pageContent, pageInfoBarPage, reader, readerEl, setupEditor, setupReader, textContainer, width } + return { contentLoaded, davContent, editor, editorContent, editorEl, pageContent, pageInfoBarPage, reader, readerEl, setupEditor, saveEditor, setupReader, textContainer, width } }, data() { return { textEditWatcher: null, + mountedPageId: null, + disposed: false, unregisterCurrentSnapshotPreparer: null, } }, @@ -116,18 +119,35 @@ export default { }, async mounted() { + this.mountedPageId = this.currentPage.id this.unregisterCurrentSnapshotPreparer = this.registerCurrentSnapshotPreparer(() => this.prepareCurrentEditorSnapshot()) const readerPromise = this.setupReader(this.currentPage) const editorPromise = this.setupEditor() const pageContentPromise = this.getPageContent() Promise.all([readerPromise, editorPromise, pageContentPromise]).then(() => { - this.initEditMode() + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + this.initEditMode() + } + }).catch((error) => { + console.error('Failed to load page', error) }) this.textEditWatcher = this.$watch('isTextEdit', async (val) => { if (val === false) { this.stopEdit() } else if (val === true) { + try { + await this.setupEditor() + } catch { + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + showError(t('collectives', 'Could not load the editor. Please try again.')) + this.setTextPreview() + } + return + } + if (this.disposed || this.currentPage.id !== this.mountedPageId) { + return + } // Load full circle members for autocomplete when entering edit mode const circlesStore = useCirclesStore() if (!circlesStore.currentCircleMembersFullyLoaded && !this.isPublic) { @@ -136,14 +156,11 @@ export default { } }) subscribe('collectives:attachment:insert', this.insertAttachment) - subscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) - subscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) }, beforeUnmount() { + this.disposed = true this.unregisterCurrentSnapshotPreparer?.() - unsubscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) - unsubscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) unsubscribe('collectives:attachment:insert', this.insertAttachment) this.textEditWatcher() }, @@ -153,31 +170,22 @@ export default { ...mapActions(useRootStore, ['load', 'done']), ...mapActions(useVersionsStore, ['getVersions', 'registerCurrentSnapshotPreparer']), - ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage']), + ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage', 'runEditorAction']), ...mapActions(useCirclesStore, ['getCircleMembers']), - insertAttachment({ name }) { - // inspired by the fixedEncodeURIComponent function suggested in - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + async insertAttachment({ name }) { const src = '.attachments.' + this.currentPage.id + '/' + encodeAttachmentFilename(name) - // simply get rid of brackets to make sure link text is valid - // as it does not need to be unique and matching the real file name - const alt = name.replaceAll(/[[\]]/g, '') - - this.editor.insertAtCursor(`${alt}`) - }, - - replaceAttachmentFilename({ pageId, oldName, newName }) { - // Only available since editorApi 1.4 - if (this.editor.replaceAttachmentFilename) { - this.editor.replaceAttachmentFilename(pageId, oldName, newName) - } - }, - - removeAttachmentReferences({ pageId, name }) { - // Only available since editorApi 1.4 - if (this.editor.removeAttachmentReferences) { - this.editor.removeAttachmentReferences(pageId, name) + const alt = escapeHtml(name.replaceAll(/[[\]]/g, '')) + try { + await this.runEditorAction(async (editor, save) => { + editor.insertAtCursor(`${alt}`) + if (await save() !== true) { + throw new Error('Could not save the inserted attachment.') + } + }) + } catch (error) { + console.error('Failed to insert attachment', error) + showError(t('collectives', 'Could not insert and save the attachment. Please try again.')) } }, @@ -198,11 +206,6 @@ export default { this.editor?.focus() }, - // called from the parent component as well - saveEditor() { - return this.editor.save() - }, - async prepareCurrentEditorSnapshot() { if (this.isTextEdit && this.editor && await this.saveEditor() !== true) { throw new Error('Could not save the current page before comparison.') @@ -211,6 +214,9 @@ export default { }, async stopEdit() { + if (!this.editor) { + return + } // switch back to edit if there's no content if (!this.pageContent?.trim()) { this.setTextEdit() @@ -220,24 +226,27 @@ export default { return } - const changed = this.editorContent && (this.editorContent !== this.davContent) - if (changed) { - // Save pending changes in editor - // TODO: detect missing connection and display warning - await this.saveEditor() - .catch(() => { - showError(t('collectives', 'Error saving the document. Please try again.')) - this.setTextEdit() - }) - - // Touch page to update last changed timestamp - this.touchPage() + try { + if (await this.saveEditor() !== true) { + throw new Error('The editor did not confirm saving.') + } + if (!this.disposed && this.currentPage.id === this.mountedPageId && this.editorContent !== this.davContent) { + this.touchPage() + } + } catch { + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + showError(t('collectives', 'Error saving the document. Please try again.')) + this.setTextEdit() + } } }, async getPageContent() { - this.davContent = await this.fetchPageContent(this.currentPageDavUrl) - this.done('pageContent') + const content = await this.fetchPageContent(this.currentPageDavUrl) + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + this.davContent = content + this.done('pageContent') + } }, }, } diff --git a/src/components/PageSidebar/SidebarTabAttachments.vue b/src/components/PageSidebar/SidebarTabAttachments.vue index 2fecfaddef..eac928ef39 100644 --- a/src/components/PageSidebar/SidebarTabAttachments.vue +++ b/src/components/PageSidebar/SidebarTabAttachments.vue @@ -180,7 +180,6 @@