diff --git a/playwright/e2e/note-actions.spec.ts b/playwright/e2e/note-actions.spec.ts index c39796919..8c3d5ffee 100644 --- a/playwright/e2e/note-actions.spec.ts +++ b/playwright/e2e/note-actions.spec.ts @@ -3,18 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, TestInfo } from '@playwright/test' +import type { TestInfo } from '@playwright/test' import { expect, test } from '@playwright/test' import { login } from '../support/login.ts' -import { createNote, newNoteButton, noteRow, uniqueTitle } from '../support/note.ts' - -async function openNoteActions(page: Page, noteId: number): Promise { - const row = noteRow(page, noteId) - await row.hover() - await row.locator('.action-item__menutoggle').click() - return row -} +import { createNote, newNoteButton, noteRow, openNoteActions, uniqueTitle } from '../support/note.ts' test.describe('Note actions', () => { test.beforeEach(async ({ page }) => { diff --git a/playwright/e2e/note-sidebar.spec.ts b/playwright/e2e/note-sidebar.spec.ts new file mode 100644 index 000000000..ef0621e83 --- /dev/null +++ b/playwright/e2e/note-sidebar.spec.ts @@ -0,0 +1,268 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page, TestInfo } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { login } from '../support/login.ts' +import { createNote, createNoteRevisions, newNoteButton, openNoteActions, setNoteMode, uniqueTitle } from '../support/note.ts' +import { NoteEditor } from '../support/sections/NoteEditor.ts' + +interface EventBusWindow extends Window { + _nc_event_bus: { + emit: (name: string, payload: unknown) => void + } +} + +interface NodeLike { + mtime: Date + clone: () => NodeLike +} + +function sidebar(page: Page): Locator { + return page.locator('[data-cy-notes-share-sidebar]') +} + +function tabButton(page: Page, tabId: string): Locator { + return sidebar(page).locator(`#tab-button-${tabId}`) +} + +function versionsList(page: Page): Locator { + return sidebar(page).locator('[data-files-versions-versions-list]') +} + +// scoped to the list rather than the sidebar: the sharing tab's element reports +// itself as its own shadow root, which sends a piercing query into a loop +function versionEntries(page: Page): Locator { + return page.locator('[data-files-versions-versions-list] [data-files-versions-version]') +} + +function subname(page: Page): Locator { + return sidebar(page).locator('.app-sidebar-header__subname') +} + +async function openSidebarFromActions(page: Page, noteId: number, action: string): Promise { + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: action, exact: true }).click() + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) +} + +test.describe('Note sidebar', () => { + test.beforeEach(async ({ page }) => { + await login(page) + await page.goto('/index.php/apps/notes/') + await expect(newNoteButton(page)).toBeVisible() + }) + + test('opens the versions tab from the actions menu', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('versions', testInfo)) + + await openSidebarFromActions(page, noteId, 'Versions') + + await expect(tabButton(page, 'files_versions')).toHaveAttribute('aria-selected', 'true') + await expect(versionsList(page)).toBeAttached({ timeout: 15000 }) + }) + + test('reloads the versions list when the note is updated', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-reload', testInfo)) + + await openSidebarFromActions(page, noteId, 'Versions') + await expect(versionEntries(page).first()).toBeVisible({ timeout: 15000 }) + + let reloads = 0 + page.on('request', (request) => { + if (request.method() === 'PROPFIND' && request.url().includes('/remote.php/dav/versions/')) { + reloads += 1 + } + }) + + // what files_versions hands out once it has restored a version + await page.evaluate(() => { + const tab = document.querySelector('files-versions_sidebar-tab') as unknown as { node: NodeLike } + const node = tab.node.clone() + node.mtime = new Date(node.mtime.getTime() - 60000) + ;(window as unknown as EventBusWindow)._nc_event_bus.emit('files:node:updated', node) + }) + + await expect.poll(() => reloads, { timeout: 15000 }).toBeGreaterThan(0) + }) + + test('keeps the editor behind a spinner while a restored version loads', async ({ page, request }) => { + const noteId = await createNoteRevisions(request, [ + 'Restore spinner\n\nrevision one', + 'Restore spinner\n\nrevision two', + ]) + // the editor has to hold this note, not whichever one was open before + await page.goto(`/index.php/apps/notes/note/${noteId}`) + + // hold the restore long enough to observe what the editor does meanwhile + await page.route('**/remote.php/dav/versions/**', async (route) => { + if (route.request().method() === 'MOVE') { + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + await route.continue() + }) + + await openSidebarFromActions(page, noteId, 'Versions') + + const entries = versionEntries(page) + await expect(entries.nth(1)).toBeVisible({ timeout: 15000 }) + + const editor = page.locator('.text-editor, .note-editor') + const spinner = page.locator('#app-content-vue.loading, .text-editor-wrapper.loading') + await expect(editor).toBeVisible() + + await entries.last().hover() + await entries.last().locator('.action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Restore version' }).click() + + // the editor is gone while the restore runs, so it cannot be typed into + await expect(spinner).toBeVisible() + await expect(editor).toBeHidden() + + await expect(editor).toBeVisible({ timeout: 20000 }) + await new NoteEditor(page).expectText('Restore spinner\n\nrevision one') + }) + + test('gives the editor back when a restore fails', async ({ page, request }) => { + const noteId = await createNoteRevisions(request, [ + 'Restore failure\n\nrevision one', + 'Restore failure\n\nrevision two', + ]) + await page.goto(`/index.php/apps/notes/note/${noteId}`) + + await page.route('**/remote.php/dav/versions/**', async (route) => { + if (route.request().method() === 'MOVE') { + await new Promise((resolve) => setTimeout(resolve, 1000)) + await route.fulfill({ status: 500 }) + return + } + await route.continue() + }) + + await openSidebarFromActions(page, noteId, 'Versions') + + const entries = versionEntries(page) + await expect(entries.nth(1)).toBeVisible({ timeout: 15000 }) + + const editor = page.locator('.text-editor, .note-editor') + await expect(editor).toBeVisible() + + await entries.last().hover() + await entries.last().locator('.action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Restore version' }).click() + + await expect(page.locator('#app-content-vue.loading, .text-editor-wrapper.loading')).toBeVisible() + + // a failed restore must not leave the editor behind the spinner + await expect(editor).toBeVisible({ timeout: 15000 }) + }) + + test('shows the size, the modification date and the owner of the note', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-subname', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(subname(page)).toBeVisible({ timeout: 15000 }) + await expect(subname(page)).toContainText(/\d+(\.\d+)?\s?(B|KB|MB|GB)/) + await expect(subname(page).locator('[data-timestamp]')).toBeVisible() + await expect(subname(page).locator('.user-bubble__content')).toContainText('admin') + }) + + test('renders the allow-listed tabs only', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-tabs', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(tabButton(page, 'sharing')).toBeVisible() + await expect(tabButton(page, 'files_versions')).toBeVisible() + await expect(sidebar(page).getByRole('tab')).toHaveCount(2) + }) + + test('switches between the sharing and versions tabs', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-switch', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + + await tabButton(page, 'files_versions').click() + await expect(tabButton(page, 'files_versions')).toHaveAttribute('aria-selected', 'true') + await expect(versionsList(page)).toBeAttached({ timeout: 15000 }) + + await tabButton(page, 'sharing').click() + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible() + }) + + test('fills the sharing icon only while its tab is active', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-icons', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toBeVisible() + await expect(tabButton(page, 'sharing').locator('.share-variant-outline-icon')).toHaveCount(0) + + await tabButton(page, 'files_versions').click() + + await expect(tabButton(page, 'sharing').locator('.share-variant-outline-icon')).toBeVisible() + await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toHaveCount(0) + }) + + test('lines the tab icons up with each other', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-align', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + await expect(tabButton(page, 'files_versions')).toBeVisible() + + const icons = await page.evaluate(() => { + const box = (id: string) => { + const selector = `#tab-button-${id} :is(.icon-vue, .material-design-icon)` + const { y, height } = document.querySelector(selector)!.getBoundingClientRect() + return { y, height } + } + return { sharing: box('sharing'), versions: box('files_versions') } + }) + + expect(icons.versions.y).toBeCloseTo(icons.sharing.y, 0) + expect(icons.versions.height).toBeCloseTo(icons.sharing.height, 0) + }) + + test('falls back to the first tab when the requested one is unavailable', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-fallback', testInfo)) + + await page.evaluate((id) => { + (window as unknown as EventBusWindow)._nc_event_bus + .emit('notes:sidebar:open', { noteId: id, tab: 'not-a-note-sidebar-tab' }) + }, noteId) + + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + }) + + // The editor's own actions menu only exists in the markdown editor; the rich + // editor brings its own menu bar. + test.describe('markdown editor', () => { + test.beforeEach(async ({ page, request }) => { + await setNoteMode(request, 'edit') + await page.reload() + }) + + test.afterEach(async ({ request }) => { + await setNoteMode(request, 'rich') + }) + + test('opens the sidebar from the editor actions menu', async ({ page }, testInfo: TestInfo) => { + await createNote(page, uniqueTitle('sidebar-editor-menu', testInfo)) + + await page.locator('.action-buttons .action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Open sidebar', exact: true }).click() + + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + }) + }) +}) diff --git a/playwright/support/note.ts b/playwright/support/note.ts index 83eb612a6..351672c2e 100644 --- a/playwright/support/note.ts +++ b/playwright/support/note.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, TestInfo } from '@playwright/test' +import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' import { expect } from '@playwright/test' import { NoteEditor } from './sections/NoteEditor.ts' @@ -12,6 +12,66 @@ export function uniqueTitle(prefix: string, testInfo: TestInfo): string { return `Playwright ${prefix} ${testInfo.parallelIndex}-${Date.now()}` } +function apiUser(): string { + return process.env.NC_USER ?? 'admin' +} + +function apiHeaders(): Record { + const password = process.env.NC_PASS ?? 'admin' + return { Authorization: `Basic ${Buffer.from(`${apiUser()}:${password}`).toString('base64')}` } +} + +/** + * Create a note and rewrite it through WebDAV until it has one version per + * given revision. Writing goes around the app on purpose, so the note keeps its + * file name instead of being retitled from the changed content. + * + * @param request The request fixture to use + * @param revisions The contents to write, oldest first + * @return The id of the created note + */ +export async function createNoteRevisions(request: APIRequestContext, revisions: string[]): Promise { + expect(revisions.length, 'revisions to write').toBeGreaterThan(0) + + const created = await request.post('/index.php/apps/notes/api/v1/notes', { + headers: apiHeaders(), + data: { content: revisions[0] }, + }) + expect(created.ok(), 'creating the note').toBeTruthy() + + const note = await created.json() + const path = note.internalPath.split('/').map(encodeURIComponent).join('/') + + for (const content of revisions.slice(1)) { + // recent versions are thinned out to one per two seconds + await new Promise((resolve) => setTimeout(resolve, 3500)) + const written = await request.put(`/remote.php/dav/files/${apiUser()}${path}`, { + headers: apiHeaders(), + data: content, + }) + expect(written.ok(), 'writing a revision').toBeTruthy() + } + + return note.id +} + +/** + * Switch the editor the app renders: `rich`, `edit` or `preview`. + * + * Takes the isolated `request` fixture rather than `page.request`, whose basic + * auth would replace the session cookie the browser is logged in with. + * + * @param request The request fixture to use + * @param mode The editor mode to switch to + */ +export async function setNoteMode(request: APIRequestContext, mode: string): Promise { + const response = await request.put('/index.php/apps/notes/api/v1/settings', { + headers: apiHeaders(), + data: { noteMode: mode }, + }) + expect(response.ok(), `switching to the ${mode} editor`).toBeTruthy() +} + export function currentNoteId(page: Page): number | null { const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/) return match ? Number(match[1]) : null @@ -26,6 +86,13 @@ export function noteRow(page: Page, noteId: number): Locator { .locator('xpath=ancestor::li[1]') } +export async function openNoteActions(page: Page, noteId: number): Promise { + const row = noteRow(page, noteId) + await row.hover() + await row.locator('.action-item__menutoggle').click() + return row +} + export async function waitForNoteRoute(page: Page, previousNoteId: number | null): Promise { await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) @@ -43,9 +110,7 @@ export async function waitForNoteRoute(page: Page, previousNoteId: number | null * @param page The page object to use */ export async function deleteAllNotes(page: Page): Promise { - const user = process.env.NC_USER ?? 'admin' - const password = process.env.NC_PASS ?? 'admin' - const headers = { Authorization: `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}` } + const headers = apiHeaders() const response = await page.request.get('/index.php/apps/notes/api/v1/notes', { headers }) expect(response.ok()).toBeTruthy() diff --git a/src/components/NoteItem.vue b/src/components/NoteItem.vue index 535bfd6e0..4f3aba371 100644 --- a/src/components/NoteItem.vue +++ b/src/components/NoteItem.vue @@ -42,6 +42,13 @@ {{ t('notes', 'Share') }} + + + {{ t('notes', 'Versions') }} + + + + @@ -31,28 +39,28 @@ - + - {{ error || t('notes', 'Unable to load the selected note for sharing.') }} + {{ contextError || t('notes', 'Unable to load the selected note.') }} - + - {{ t('notes', 'Sharing is not available right now.') }} + {{ t('notes', 'Sharing and versions are not available right now.') }} @@ -65,11 +73,66 @@ import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue' +import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue' import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue' +import NoteSidebarSubname from './NoteSidebarSubname.vue' import logger from '../Logger.js' +import { selectNoteSidebarTabs } from '../sidebarTabs.js' import store from '../store.js' import { fetchDavNode } from '../WebdavService.js' +const TAB_DEFINITION_TIMEOUT = 10000 + +const pendingTabs = new Map() + +/** + * @param {object} tab a registered Files sidebar tab + * @return {Promise} whether its custom element got defined + */ +async function defineTabElement(tab) { + let timeout + try { + await Promise.race([ + (async () => { + await tab.onInit?.() + await window.customElements.whenDefined(tab.tagName) + })(), + new Promise((resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`${tab.tagName} was not defined in time`)), + TAB_DEFINITION_TIMEOUT, + ) + }), + ]) + return true + } catch (error) { + logger.error('Failed to initialize a sidebar tab in Notes', { error, tab: tab.id }) + return false + } finally { + clearTimeout(timeout) + } +} + +/** + * @param {object} tab a registered Files sidebar tab + * @return {Promise} whether the tab is usable + */ +function initializeTab(tab) { + if (window.customElements.get(tab.tagName)) { + return Promise.resolve(true) + } + + if (!pendingTabs.has(tab.tagName)) { + pendingTabs.set( + tab.tagName, + defineTabElement(tab).finally(() => pendingTabs.delete(tab.tagName)), + ) + } + + return pendingTabs.get(tab.tagName) +} + export default { name: 'NoteShareSidebar', @@ -79,6 +142,9 @@ export default { NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, + FileOutlineIcon, + NoteSidebarSubname, + ShareVariantIcon, ShareVariantOutlineIcon, }, @@ -89,21 +155,15 @@ export default { contextRequestToken: 0, currentFolder: null, currentNode: null, - initializingTabs: new Set(), - initializedTabs: new Set(), + failedTabs: new Set(), isOpen: false, loadingContext: false, loadingTab: false, noteId: null, - tabError: '', } }, computed: { - error() { - return this.tabError || this.contextError - }, - loading() { return this.loadingContext || this.loadingTab }, @@ -115,8 +175,28 @@ export default { return store.notes.getNote(this.noteId) }, - sharingTab() { - return getSidebarTabs().find((tab) => tab.id === 'sharing') || null + availableTabs() { + return selectNoteSidebarTabs(getSidebarTabs(), { + node: this.currentNode, + folder: this.currentFolder, + view: this.currentView, + }) + }, + + tabs() { + return this.availableTabs.filter((tab) => !this.failedTabs.has(tab.tagName)) + }, + + /** + * NcAppSidebar falls back to its first tab when the active one is not + * among them, but does not report that back, so the tab id has to be + * clamped here as well for `active` to reach the right custom element. + */ + resolvedTab() { + if (this.tabs.some(({ id }) => id === this.activeTab)) { + return this.activeTab + } + return this.tabs[0]?.id ?? this.activeTab }, currentView() { @@ -128,61 +208,64 @@ export default { }, mounted() { + // the share event is kept so anything already emitting it keeps working subscribe('notes:share:open', this.onShareOpen) + subscribe('notes:sidebar:open', this.onSidebarOpen) + subscribe('files:node:updated', this.onNodeUpdated) }, unmounted() { unsubscribe('notes:share:open', this.onShareOpen) + unsubscribe('notes:sidebar:open', this.onSidebarOpen) + unsubscribe('files:node:updated', this.onNodeUpdated) }, methods: { - async initializeSharingTab() { - const tab = this.sharingTab - if (!tab) { + async initializeTabs() { + const tabs = this.availableTabs + if (tabs.length === 0) { this.loadingTab = false - this.tabError = this.t('notes', 'Sharing is not available right now.') return } - if (window.customElements.get(tab.tagName) || this.initializedTabs.has(tab.tagName)) { - this.loadingTab = false - this.tabError = '' - return - } + const requestToken = this.contextRequestToken + this.loadingTab = true + + const results = await Promise.all(tabs.map(initializeTab)) - if (this.initializingTabs.has(tab.tagName)) { - this.loadingTab = true + if (requestToken !== this.contextRequestToken) { return } - this.initializingTabs.add(tab.tagName) - this.loadingTab = true - this.tabError = '' + tabs.forEach((tab, index) => { + if (!results[index]) { + this.failedTabs.add(tab.tagName) + } + }) - try { - await tab.onInit?.() - await window.customElements.whenDefined(tab.tagName) - this.initializedTabs.add(tab.tagName) - } catch (error) { - logger.error('Failed to initialize the sharing sidebar tab in Notes', { error }) - this.tabError = this.t('notes', 'Failed to load the sharing sidebar.') - } finally { - this.initializingTabs.delete(tab.tagName) - this.loadingTab = false - } + this.loadingTab = false + }, + + resetContext() { + this.contextRequestToken += 1 + this.contextError = '' + this.currentNode = null + this.currentFolder = null + this.loadingContext = false + this.loadingTab = false }, - async loadShareContext() { + async loadNodeContext() { const internalPath = this.note?.internalPath if (!internalPath) { this.loadingContext = false this.currentNode = null this.currentFolder = null - this.contextError = this.t('notes', 'Unable to load the selected note for sharing.') + this.contextError = this.t('notes', 'Unable to load the selected note.') return } - const requestToken = ++this.contextRequestToken + const requestToken = this.contextRequestToken this.loadingContext = true this.contextError = '' @@ -193,7 +276,7 @@ export default { try { folder = await fetchDavNode(node.dirname || '/') } catch (error) { - logger.error('Failed to load the parent folder for the Notes sharing sidebar', { error }) + logger.error('Failed to load the parent folder for the Notes sidebar', { error }) } if (requestToken !== this.contextRequestToken) { @@ -207,10 +290,10 @@ export default { return } - logger.error('Failed to load the selected note for the Notes sharing sidebar', { error }) + logger.error('Failed to load the selected note for the Notes sidebar', { error }) this.currentNode = null this.currentFolder = null - this.contextError = this.t('notes', 'Unable to load the selected note for sharing.') + this.contextError = this.t('notes', 'Unable to load the selected note.') } finally { if (requestToken === this.contextRequestToken) { this.loadingContext = false @@ -218,26 +301,38 @@ export default { } }, - async onShareOpen({ noteId }) { - this.contextRequestToken += 1 + /** + * Tabs report what they changed about the note through this event — a + * restored version for instance — and hand out a node of their own, + * which they in turn watch for changes. + * + * @param {object} node the updated node + */ + onNodeUpdated(node) { + if (node?.source && node.source === this.currentNode?.source) { + this.currentNode = node + } + }, + + onShareOpen({ noteId }) { + return this.onSidebarOpen({ noteId, tab: 'sharing' }) + }, + + async onSidebarOpen({ noteId, tab = 'sharing' }) { + this.resetContext() this.noteId = Number(noteId) - this.activeTab = 'sharing' this.isOpen = true - this.contextError = '' - this.tabError = '' - this.currentNode = null - this.currentFolder = null - this.loadingContext = false - this.loadingTab = false + this.failedTabs.clear() + this.activeTab = tab - if (!this.sharingTab) { - await this.initializeSharingTab() + if (this.availableTabs.length === 0) { + await this.initializeTabs() return } await Promise.all([ - this.initializeSharingTab(), - this.loadShareContext(), + this.initializeTabs(), + this.loadNodeContext(), ]) }, @@ -252,14 +347,8 @@ export default { return } - this.contextRequestToken += 1 + this.resetContext() this.noteId = null - this.contextError = '' - this.currentNode = null - this.currentFolder = null - this.loadingContext = false - this.loadingTab = false - this.tabError = '' }, }, } diff --git a/src/components/NoteSidebarSubname.vue b/src/components/NoteSidebarSubname.vue new file mode 100644 index 000000000..e3df8f3f5 --- /dev/null +++ b/src/components/NoteSidebarSubname.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/src/sidebarTabs.js b/src/sidebarTabs.js new file mode 100644 index 000000000..78c615ff6 --- /dev/null +++ b/src/sidebarTabs.js @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import logger from './Logger.js' + +/** + * Files sidebar tabs the Notes sidebar hosts, and nothing else. + * + * Notes dispatches OCA\Files\Event\LoadSidebar when rendering its page, so every + * app that registers a sidebar tab has registered one by the time this runs — + * including tabs that make no sense for a note. This is an allow-list so a newly + * installed app cannot start appearing in the Notes sidebar unannounced. + * + * @type {string[]} + */ +export const NOTE_SIDEBAR_TAB_IDS = ['sharing', 'files_versions'] + +/** + * The tabs to render, in the order the registering apps asked for. + * + * A tab's own `enabled()` predicate has the final say — the versions tab for + * instance hides itself on public shares and for anything that is not a file — + * but it needs a node to judge, so while the node is still loading the tabs are + * kept and filtered again once it arrives. A predicate that throws is treated as + * "not usable" rather than being allowed to take the sidebar down. + * + * @param {Array} tabs all registered tabs, from getSidebarTabs() + * @param {object} context what the tab is being asked about + * @param {object|null} context.node the note's DAV node, null while loading + * @param {object|null} context.folder the note's parent folder + * @param {object|null} context.view the pseudo view Notes reports + * @return {Array} tabs to render, sorted by their declared order + */ +export function selectNoteSidebarTabs(tabs, { node = null, folder = null, view = null } = {}) { + return (tabs ?? []) + .filter((tab) => NOTE_SIDEBAR_TAB_IDS.includes(tab?.id)) + .filter((tab) => { + if (typeof tab.enabled !== 'function' || node === null) { + return true + } + try { + return tab.enabled({ node, folder, view }) + } catch (error) { + logger.error('Sidebar tab predicate failed in Notes, dropping the tab', { error, tab: tab.id }) + return false + } + }) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) +} diff --git a/src/tests/sidebarTabs.spec.js b/src/tests/sidebarTabs.spec.js new file mode 100644 index 000000000..a451e320c --- /dev/null +++ b/src/tests/sidebarTabs.spec.js @@ -0,0 +1,98 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, vi } from 'vitest' +import { NOTE_SIDEBAR_TAB_IDS, selectNoteSidebarTabs } from '../sidebarTabs.js' + +const ids = (tabs, context) => selectNoteSidebarTabs(tabs, context).map((tab) => tab.id) + +const node = { basename: 'A note.md' } + +describe('selectNoteSidebarTabs', () => { + it('keeps the tabs a note sidebar hosts', () => { + expect(NOTE_SIDEBAR_TAB_IDS).toEqual(['sharing', 'files_versions']) + }) + + it('drops every tab that is not on the allow-list', () => { + const tabs = [ + { id: 'sharing' }, + { id: 'activity' }, + { id: 'files_versions' }, + { id: 'comments' }, + ] + + expect(ids(tabs)).toEqual(['sharing', 'files_versions']) + }) + + it.each([ + ['nothing registered', []], + ['a registry that is not there yet', null], + ['entries without an id', [{}, null, undefined]], + ])('returns no tabs for %s', (_label, tabs) => { + expect(ids(tabs)).toEqual([]) + }) + + it('sorts by the order the registering apps asked for', () => { + const tabs = [ + { id: 'files_versions', order: 5 }, + { id: 'sharing', order: 1 }, + ] + + expect(ids(tabs)).toEqual(['sharing', 'files_versions']) + }) + + it('treats a missing order as zero', () => { + const tabs = [ + { id: 'files_versions', order: 1 }, + { id: 'sharing' }, + ] + + expect(ids(tabs)).toEqual(['sharing', 'files_versions']) + }) + + it('keeps a tab while the node it would judge is still loading', () => { + const tabs = [{ id: 'files_versions', enabled: () => false }] + + expect(ids(tabs, { node: null })).toEqual(['files_versions']) + }) + + it('asks the tab once the node is there', () => { + const enabled = vi.fn(() => true) + const folder = { basename: 'Notes' } + const view = { id: 'notes' } + + expect(ids([{ id: 'sharing', enabled }], { node, folder, view })).toEqual(['sharing']) + expect(enabled).toHaveBeenCalledWith({ node, folder, view }) + }) + + it('drops a tab that says it does not apply to the node', () => { + const tabs = [ + { id: 'sharing' }, + { id: 'files_versions', enabled: () => false }, + ] + + expect(ids(tabs, { node })).toEqual(['sharing']) + }) + + it('drops only the tab whose predicate throws', () => { + const tabs = [ + { id: 'sharing' }, + { id: 'files_versions', enabled: () => { throw new Error('no node for you') } }, + ] + + expect(ids(tabs, { node })).toEqual(['sharing']) + }) + + it('leaves the registry it was given alone', () => { + const tabs = [ + { id: 'files_versions', order: 5 }, + { id: 'sharing', order: 1 }, + ] + + selectNoteSidebarTabs(tabs, { node }) + + expect(tabs.map((tab) => tab.id)).toEqual(['files_versions', 'sharing']) + }) +})