Skip to content
Merged
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
124 changes: 121 additions & 3 deletions playwright/e2e/note-sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 { createNote, createNoteRevisions, newNoteButton, noteRow, openNoteActions, setNoteMode, uniqueTitle } from '../support/note.ts'
import { NoteEditor } from '../support/sections/NoteEditor.ts'

interface EventBusWindow extends Window {
Expand Down Expand Up @@ -43,6 +43,25 @@ function subname(page: Page): Locator {
return sidebar(page).locator('.app-sidebar-header__subname')
}

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> {
await openNoteActions(page, noteId)
await page.getByRole('menuitem', { name: action, exact: true }).click()
Expand Down Expand Up @@ -171,14 +190,15 @@ test.describe('Note sidebar', () => {
await expect(subname(page).locator('.user-bubble__content')).toContainText('admin')
})

test('renders the allow-listed tabs only', async ({ page }, testInfo: TestInfo) => {
test('renders the allow-listed tabs and the details one 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)
await expect(tabButton(page, 'notes-info')).toBeVisible()
await expect(sidebar(page).getByRole('tab')).toHaveCount(3)
})

test('switches between the sharing and versions tabs', async ({ page }, testInfo: TestInfo) => {
Expand Down Expand Up @@ -242,6 +262,104 @@ test.describe('Note sidebar', () => {
await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 })
})

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('fills the details icon only while its tab is active', async ({ page }, testInfo: TestInfo) => {
const noteId = await createNote(page, uniqueTitle('sidebar-details-icon', 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 tabButton(page, 'sharing').click()

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

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 a note that has never been opened', async ({ page }, testInfo: TestInfo) => {
const noteId = await createSavedNote(page, `# ${uniqueTitle('sidebar-body', 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 openSidebarFromActions(page, noteId, 'Share')
await tabButton(page, 'notes-info').click()

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

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))

await openSidebarFromActions(page, second, 'Details')
const shown = await detailRow(page, 'Path').textContent()

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

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')

const readingTime = detailRow(page, 'Reading time')
await expect(readingTime).toHaveText(/^β€”/)
await expect(readingTime.locator('.hidden-visually'))
.toHaveText('The note content could not be loaded.')
})

// The editor's own actions menu only exists in the markdown editor; the rich
// editor brings its own menu bar.
test.describe('markdown editor', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/NotesService.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ 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 endpoint answers with the category alone, but the file moves with
// it, so the new path has to come from a refetch, whose own failure
// fetchNote() has already reported
return fetchNote(noteId).catch(() => {})
})
.catch((err) => {
logger.error('Updating the category for note has failed', { noteId, error: err })
Expand Down
130 changes: 130 additions & 0 deletions src/components/NoteInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<!--
- 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.hint || undefined">
{{ row.value }}
<span v-if="row.hint" class="hidden-visually">{{ row.hint }}</span>
</dd>
</div>
</dl>
</template>

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

export default {
name: 'NoteInfo',

props: {
note: {
type: Object,
required: true,
},

contentLoading: {
type: Boolean,
default: false,
},

contentError: {
type: Boolean,
default: false,
},
},

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

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

rows() {
const readingTime = this.t('notes', 'Reading time')
const rows = [
{
label: this.t('notes', 'Category'),
value: categoryLabel(this.note.category || ''),
},
]

// the reading estimate needs the body, which is fetched separately
if (this.contentLoading && !this.hasContent) {
rows.push({ label: readingTime, value: '…' })
} else if (this.hasContent) {
rows.push({
label: readingTime,
value: this.stats.readingMinutes === 0
? 'β€”'
: this.n('notes', '%n minute', '%n minutes', this.stats.readingMinutes),
})
} else if (this.contentError) {
rows.push({
label: readingTime,
value: 'β€”',
hint: 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;
}

/* core styles dt/dd for prose in a wide content area β€” 12px of padding and a
fixed 130px label column aligned to its end β€” which a sidebar row undoes */
.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>
22 changes: 13 additions & 9 deletions src/components/NoteItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,27 @@
{{ actionFavoriteText }}
</NcActionButton>

<NcActionButton @click="onToggleSharing">
<NcActionButton @click="openSidebar('sharing')">
<template #icon>
<ShareVariantOutlineIcon :size="20" />
</template>
{{ t('notes', 'Share') }}
</NcActionButton>

<NcActionButton v-if="hasVersionsTab()" @click="onShowVersions">
<NcActionButton v-if="hasVersionsTab()" @click="openSidebar('files_versions')">
<template #icon>
<BackupRestoreIcon :size="20" />
</template>
{{ t('notes', 'Versions') }}
</NcActionButton>

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

<NcActionButton v-if="!showCategorySelect" @click="showCategorySelect = true">
<template #icon>
<FolderOutlineIcon :size="20" />
Expand Down Expand Up @@ -116,6 +123,7 @@ import NcListItem from '@nextcloud/vue/components/NcListItem'
import AlertOctagonOutlineIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import BackupRestoreIcon from 'vue-material-design-icons/BackupRestore.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 @@ -131,6 +139,7 @@ export default {
AlertOctagonOutlineIcon,
BackupRestoreIcon,
FolderOutlineIcon,
InformationOutlineIcon,
NcActionButton,
NcListItem,
StarIcon,
Expand Down Expand Up @@ -342,18 +351,13 @@ export default {
}
},

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

hasVersionsTab() {
return getSidebarTabs().some((tab) => tab?.id === 'files_versions')
},

onShowVersions() {
openSidebar(tab) {
this.actionsOpen = false
emit('notes:sidebar:open', { noteId: this.note.id, tab: 'files_versions' })
emit('notes:sidebar:open', { noteId: this.note.id, tab })
},

async onShareCreated(event) {
Expand Down
Loading
Loading