Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions playwright/e2e/note-sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* 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, newNoteButton, noteRow, uniqueTitle } from '../support/note.ts'

interface EventBusWindow extends Window {
_nc_event_bus: {
emit: (name: string, payload: unknown) => void
}
}

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 detailRow(page: Page, label: string): Locator {
return sidebar(page).locator('.note-info__row')
.filter({ has: page.getByText(label, { exact: true }) })
.locator('.note-info__value')
}

/**
* The store only holds a note's body once it has been saved, and the reading
* estimate counts what the store holds, so the tests wait for the write.
*/
async function createSavedNote(page: Page, content: string): Promise<number> {
const saved = page.waitForResponse((response) => /\/notes\/\d+$/.test(response.url())
&& response.request().method() === 'PUT')
const noteId = await createNote(page, content)
await saved

return noteId
}

async function openSidebarFromActions(page: Page, noteId: number, action: string): Promise<void> {
const row = noteRow(page, noteId)
await row.hover()
await row.locator('.action-item__menutoggle').click()
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 details tab from the actions menu', async ({ page }, testInfo: TestInfo) => {
const noteId = await createNote(page, uniqueTitle('sidebar-details', testInfo))

await openSidebarFromActions(page, noteId, 'Details')

await expect(tabButton(page, 'notes-info')).toHaveAttribute('aria-selected', 'true')
await expect(detailRow(page, 'Category')).toHaveText('Uncategorized')
await expect(detailRow(page, 'Path')).toContainText('.md')
})

test('keeps the sharing tab reachable next to the details one', async ({ page }, testInfo: TestInfo) => {
const noteId = await createNote(page, uniqueTitle('sidebar-tabs', testInfo))

await openSidebarFromActions(page, noteId, 'Share')

await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true')
await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 })

await tabButton(page, 'notes-info').click()
await expect(detailRow(page, 'Category')).toBeVisible()
})

test('fills a tab icon only while its tab is active', async ({ page }, testInfo: TestInfo) => {
const noteId = await createNote(page, uniqueTitle('sidebar-icons', testInfo))

await openSidebarFromActions(page, noteId, 'Details')

await expect(tabButton(page, 'notes-info').locator('.information-icon')).toBeVisible()
await expect(tabButton(page, 'notes-info').locator('.information-outline-icon')).toHaveCount(0)
await expect(tabButton(page, 'sharing').locator('.share-variant-outline-icon')).toBeVisible()
await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toHaveCount(0)

await tabButton(page, 'sharing').click()

await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toBeVisible()
await expect(tabButton(page, 'notes-info').locator('.information-outline-icon')).toBeVisible()
})

test('falls back to the details tab when the requested one is unavailable', async ({ page }, testInfo: TestInfo) => {
const noteId = await createSavedNote(page, `# ${uniqueTitle('sidebar-fallback', testInfo)}\n\nfour plain words here`)
// a reload drops the body from the store, so the tab has to fetch it
await page.goto('/index.php/apps/notes/')

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, 'notes-info')).toHaveAttribute('aria-selected', 'true')
// the fallback has to load the body too, not just render the tab
await expect(detailRow(page, 'Reading time')).toHaveText('1 minute')
})

test('estimates the reading time from the note body', async ({ page }, testInfo: TestInfo) => {
const noteId = await createSavedNote(page, `# ${uniqueTitle('sidebar-reading', testInfo)}\n\nfour plain words here`)

await openSidebarFromActions(page, noteId, 'Details')

await expect(detailRow(page, 'Reading time')).toHaveText('1 minute')
})

test('loads the body of the note it moves to while another body is still on its way', async ({ page }, testInfo: TestInfo) => {
const held = await createSavedNote(page, uniqueTitle('sidebar-held', testInfo))
const wanted = await createSavedNote(page, `# ${uniqueTitle('sidebar-wanted', testInfo)}\n\nfour plain words here`)
const opened = await createSavedNote(page, uniqueTitle('sidebar-opened', testInfo))

// a third note carries the route, so the editor loads neither of the two
// bodies the tab is after, and the reload drops them from the store
await page.goto(`/index.php/apps/notes/note/${opened}`)

// keep the first body on its way while the sidebar is sent to the second
await page.route(`**/apps/notes/notes/${held}`, async (route) => {
if (route.request().method() !== 'GET') {
return route.continue()
}
await new Promise((resolve) => setTimeout(resolve, 5000))
await route.continue()
})

await openSidebarFromActions(page, held, 'Details')
await openSidebarFromActions(page, wanted, 'Details')

await expect(detailRow(page, 'Reading time')).toHaveText('1 minute', { timeout: 15000 })
})

test('follows the note the list navigates to', async ({ page }, testInfo: TestInfo) => {
const first = await createSavedNote(page, uniqueTitle('sidebar-first', testInfo))
const second = await createSavedNote(page, uniqueTitle('sidebar-second', testInfo))

// the app is on the second note, so the sidebar starts where the route is
await openSidebarFromActions(page, second, 'Details')
const shown = await detailRow(page, 'Path').textContent()

await noteRow(page, first).getByRole('link').first().click()

// each note has a path of its own, so a different one means the sidebar moved
await expect(page).toHaveURL(new RegExp(`/note/${first}(\\?.*)?$`))
await expect(detailRow(page, 'Path')).not.toHaveText(shown ?? '')
})

test('marks the reading time unavailable when the note body cannot be loaded', async ({ page }, testInfo: TestInfo) => {
const noteId = await createSavedNote(page, uniqueTitle('sidebar-unreadable', testInfo))

// a reload drops the body from the store, so the tab has to fetch it
await page.route(
`**/apps/notes/notes/${noteId}`,
(route) => route.request().method() === 'GET' ? route.abort() : route.continue(),
)
await page.goto('/index.php/apps/notes/')

await openSidebarFromActions(page, noteId, 'Details')

await expect(detailRow(page, 'Reading time')).toHaveText('β€”')
})
})
3 changes: 3 additions & 0 deletions src/NotesService.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ export function setCategory(noteId, category) {
handleSyncError(t('notes', 'Updating the note\'s category has failed. Is the target directory writable?'))
}
store.notes.setNoteAttribute({ noteId, attribute: 'category', value: realCategory })
// the file moves with its category, so the path the note reports changes
// as well, and the category endpoint answers with the category alone
return fetchNote(noteId)
})
.catch((err) => {
logger.error('Updating the category for note has failed', { noteId, error: err })
Expand Down
131 changes: 131 additions & 0 deletions src/components/NoteInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<dl class="note-info">
<div v-for="row in rows" :key="row.label" class="note-info__row">
<dt class="note-info__label">
{{ row.label }}
</dt>
<dd class="note-info__value" :title="row.title || undefined">
{{ row.value }}
</dd>
</div>
</dl>
</template>

<script>
import { noteTextStats } from '../noteStats.js'
import { categoryLabel } from '../Util.js'

export default {
name: 'NoteInfo',

props: {
/** The note, as held in the store */
note: {
type: Object,
required: true,
},

/** Whether the note's content has been loaded yet */
contentLoading: {
type: Boolean,
default: false,
},

/** Whether loading the note's content failed */
contentError: {
type: Boolean,
default: false,
},
},

computed: {
stats() {
return noteTextStats(this.note.content)
},

hasContent() {
return typeof this.note.content === 'string'
},

rows() {
const rows = [
{
label: this.t('notes', 'Category'),
value: this.note.category
? categoryLabel(this.note.category)
: this.t('notes', 'Uncategorized'),
},
]

// the reading estimate needs the body, which is fetched separately
if (this.contentLoading && !this.hasContent) {
rows.push({ label: this.t('notes', 'Reading time'), value: '…' })
} else if (this.hasContent) {
rows.push({
label: this.t('notes', 'Reading time'),
value: this.stats.readingMinutes === 0
? 'β€”'
: this.n('notes', '%n minute', '%n minutes', this.stats.readingMinutes),
})
} else if (this.contentError) {
rows.push({
label: this.t('notes', 'Reading time'),
value: 'β€”',
title: this.t('notes', 'The note content could not be loaded.'),
})
}

if (this.note.readonly) {
rows.push({ label: this.t('notes', 'Access'), value: this.t('notes', 'Read-only') })
}

rows.push({
label: this.t('notes', 'Path'),
value: this.note.internalPath || 'β€”',
})

return rows
},
},
}
</script>

<style lang="scss" scoped>
.note-info {
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline) * 3);
margin: 0;
/* the inset the Files sidebar tabs put their own content at */
padding: calc(var(--default-grid-baseline) * 2);
}

.note-info__row {
display: flex;
flex-direction: column;
}

.note-info__label,
.note-info__value {
padding: 0;
white-space: normal;
}

.note-info__label {
width: auto;
text-align: start;
color: var(--color-text-maxcontrast);
}

.note-info__value {
margin: 0;
font-variant-numeric: tabular-nums;
/* a long path must wrap rather than widen the sidebar */
overflow-wrap: anywhere;
}
</style>
14 changes: 14 additions & 0 deletions src/components/NoteItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
{{ actionFavoriteText }}
</NcActionButton>

<NcActionButton @click="onShowDetails">
<template #icon>
<InformationOutlineIcon :size="20" />
</template>
{{ t('notes', 'Details') }}
</NcActionButton>

<NcActionButton @click="onToggleSharing">
<template #icon>
<ShareVariantOutlineIcon :size="20" />
Expand Down Expand Up @@ -107,6 +114,7 @@ import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AlertOctagonOutlineIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue'
import InformationOutlineIcon from 'vue-material-design-icons/InformationOutline.vue'
import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue'
import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
import StarIcon from 'vue-material-design-icons/Star.vue'
Expand All @@ -121,6 +129,7 @@ export default {
components: {
AlertOctagonOutlineIcon,
FolderOutlineIcon,
InformationOutlineIcon,
NcActionButton,
NcListItem,
StarIcon,
Expand Down Expand Up @@ -332,6 +341,11 @@ export default {
}
},

onShowDetails() {
this.actionsOpen = false
emit('notes:sidebar:open', { noteId: this.note.id, tab: 'notes-info' })
},

onToggleSharing() {
this.actionsOpen = false
emit('notes:share:open', { noteId: this.note.id })
Expand Down
Loading
Loading