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

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions css/main-aH47CtxB.chunk.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion css/office-main.css
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/* extracted by css-entry-points-plugin */
@import './main-CxcBSS6q.chunk.css';
@import './main-CNbNUD9n.chunk.css';
54 changes: 27 additions & 27 deletions js/office-main.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/office-main.mjs.map

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions src/components/FilePreview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { makeNode } from '../test-utils/fixtures.ts'
import FilePreview from './FilePreview.vue'

describe('FilePreview', () => {
it('requests a preview at the given size, keyed by fileid and etag', () => {
const file = makeNode({ basename: 'report.odt' })
// `attributes` is a getter returning a live reference — mutate in place,
// it has no setter (Node/File from @nextcloud/files).
Object.assign(file.attributes, { etag: 'abcdef1234567890' })

const wrapper = mount(FilePreview, { props: { file, size: 96 } })

const img = wrapper.find('img')
expect(img.attributes('src')).toContain(`fileId=${file.fileid}`)
expect(img.attributes('src')).toContain('x=96')
expect(img.attributes('src')).toContain('y=96')
// Only the first 6 chars of the etag are used for cache-busting.
expect(img.attributes('src')).toContain('v=abcdef')
expect(img.attributes('src')).not.toContain('1234567890')
})

it('passes the alt text through, defaulting to empty (decorative)', () => {
const withoutAlt = mount(FilePreview, { props: { file: makeNode() } })
expect(withoutAlt.find('img').attributes('alt')).toBe('')

const withAlt = mount(FilePreview, { props: { file: makeNode(), alt: 'report.odt' } })
expect(withAlt.find('img').attributes('alt')).toBe('report.odt')
})

it('falls back to a document icon on image load failure, sized via fallbackIconSize', async () => {
const wrapper = mount(FilePreview, { props: { file: makeNode(), fallbackIconSize: 32 } })

await wrapper.find('img').trigger('error')

expect(wrapper.find('img').exists()).toBe(false)
const fallback = wrapper.findComponent({ name: 'NcIconSvgWrapper' })
expect(fallback.exists()).toBe(true)
expect(fallback.props('size')).toBe(32)
})

it('gives each instance independent failure state (no cross-instance/cross-size leakage)', async () => {
const file = makeNode()
const small = mount(FilePreview, { props: { file, size: 96 } })
const large = mount(FilePreview, { props: { file, size: 300 } })

await small.find('img').trigger('error')

expect(small.find('img').exists()).toBe(false)
expect(large.find('img').exists()).toBe(true)
})
})
72 changes: 72 additions & 0 deletions src/components/FilePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import { computed, ref } from 'vue'
import { generateUrl } from '@nextcloud/router'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { mdiFileDocumentOutline } from '@mdi/js'
import type { Node } from '@nextcloud/files'

const props = withDefaults(defineProps<{
file: Node
size?: number
fallbackIconSize?: number
alt?: string
}>(), {
size: 300,
fallbackIconSize: 48,
alt: '',
})

// Own state per instance (one per file per view) rather than a single map
// shared across grid/list — a failure at one requested size no longer
// suppresses a different size's thumbnail for the same file.
const failed = ref(false)

const previewUrl = computed(() => {
const etag = (props.file.attributes?.etag as string | undefined ?? '').slice(0, 6)
return generateUrl('/core/preview?fileId={fileid}&x={x}&y={y}&v={v}&a=1&mimeFallback=true', {
fileid: props.file.fileid,
x: props.size,
y: props.size,
v: etag,
})
})
</script>

<template>
<div class="file-preview">
<img v-if="!failed"
:src="previewUrl"
:alt="alt"
loading="lazy"
class="file-preview__image"
@error="failed = true">
<NcIconSvgWrapper v-else
:path="mdiFileDocumentOutline"
:size="fallbackIconSize"
class="file-preview__fallback" />
</div>
</template>

<style scoped>
.file-preview {
display: flex;
width: 100%;
height: 100%;
}

.file-preview__image {
width: 100%;
height: 100%;
object-fit: cover;
}

.file-preview__fallback {
margin: auto;
color: var(--color-text-maxcontrast);
}
</style>
49 changes: 48 additions & 1 deletion src/views/OfficeOverview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ const NC_DIALOG_STUB = {
// lookups below always match by { name } rather than by imported reference:
// an object-identity match (e.g. findComponent(NcButton)) silently fails
// because "our" NcButton import and OfficeOverview's are different instances.
async function mountOverview() {
// extraStubs merges in on top of the defaults below — used by tests that need
// a named slot rendered on a component that's normally left as a plain
// shallow stub (e.g. NcListItem's #icon, FileCard's #preview).
async function mountOverview(extraStubs: Record<string, unknown> = {}) {
vi.resetModules()
const { default: OfficeOverview } = await import('./OfficeOverview.vue')
const wrapper = shallowMount(OfficeOverview, {
Expand All @@ -68,6 +71,7 @@ async function mountOverview() {
NcDialog: NC_DIALOG_STUB,
NcAppNavigation: stubRenderingAllSlots('NcAppNavigation'),
NcEmptyContent: stubRenderingAllSlots('NcEmptyContent', ['name']),
...extraStubs,
},
},
})
Expand Down Expand Up @@ -207,6 +211,49 @@ describe('OfficeOverview > rendering states', () => {
})
})

describe('OfficeOverview > preview thumbnails', () => {
// FilePreview's own rendering (image src, error-to-icon fallback) is
// covered by FilePreview.spec.ts in isolation. These tests are wiring
// only: does each view pass FilePreview the props it's supposed to?
// NcListItem/FileCard's default shallow stub only renders the default
// slot (see stubRenderingAllSlots' comment above), so their #icon/#preview
// named slots — where FilePreview lives — need it rendered explicitly.
const LIST_ITEM_STUB = stubRenderingAllSlots('NcListItem', ['name', 'active'])
const FILE_CARD_STUB = stubRenderingAllSlots('FileCard', [])

it('passes list view a small thumbnail size and the file, decorative (no alt)', async () => {
getTemplatesMock.mockResolvedValue([makeCreator()])
const file = makeNode({ owner: 'alice', basename: 'report.odt' })
getAllOfficeFilesMock.mockResolvedValue(officeFilesResult([file]))

const wrapper = await mountOverview({ NcListItem: LIST_ITEM_STUB })

const preview = wrapper.findComponent({ name: 'FilePreview' })
// Vue wraps allFiles.value in a reactive proxy, so the prop is a proxied
// copy, not the exact same reference as `file` — compare by fileid.
expect(preview.props('file').fileid).toBe(file.fileid)
expect(preview.props('size')).toBe(96)
expect(preview.props('fallbackIconSize')).toBe(32)
expect(preview.props('alt')).toBeFalsy()
expect(preview.classes()).toContain('office-overview__list-thumb')
})

it('passes grid view the file\'s basename as alt text (not decorative)', async () => {
localStorage.setItem('office.overview.gridView', 'true')
getTemplatesMock.mockResolvedValue([makeCreator()])
const file = makeNode({ owner: 'alice', basename: 'report.odt' })
getAllOfficeFilesMock.mockResolvedValue(officeFilesResult([file]))

const wrapper = await mountOverview({ FileCard: FILE_CARD_STUB })

const preview = wrapper.findComponent({ name: 'FilePreview' })
// Vue wraps allFiles.value in a reactive proxy, so the prop is a proxied
// copy, not the exact same reference as `file` — compare by fileid.
expect(preview.props('file').fileid).toBe(file.fileid)
expect(preview.props('alt')).toBe('report.odt')
})
})

describe('OfficeOverview > openFile', () => {
it('navigates to the WOPI editor URL with fileId when editorUrl is set', async () => {
vi.mocked(loadState).mockReturnValue('/apps/office/editor')
Expand Down
48 changes: 17 additions & 31 deletions src/views/OfficeOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
mdiViewList,
} from '@mdi/js'
import FileCard from '../components/FileCard.vue'
import FilePreview from '../components/FilePreview.vue'
import TemplateSection from '../components/TemplateSection.vue'
import { getAllOfficeFiles, invalidateOfficeFilesCache, MAX_DISPLAY_FILES } from '../services/officeFiles.ts'
import { getTemplates, createFromTemplate } from '../services/templates.ts'
Expand Down Expand Up @@ -60,7 +61,6 @@ const pendingCreator = ref<TemplateCreator | null>(null)
const pendingTemplate = ref<TemplateFile | null>(null)
const creating = ref(false)
const createError = ref('')
const failedPreviews = ref<Record<number, boolean>>({})
const createInput = ref<InstanceType<typeof NcTextField> | null>(null)

watch(activeCreator, () => {
Expand Down Expand Up @@ -110,16 +110,6 @@ function toggleViewMode() {
setOverviewGridView(mode === 'grid')
}

function getPreviewUrl(file: Node): string {
const etag = (file.attributes?.etag as string | undefined ?? '').slice(0, 6)
return generateUrl('/core/preview?fileId={fileid}&x={x}&y={y}&v={v}&a=1&mimeFallback=true', {
fileid: file.fileid,
x: 300,
y: 300,
v: etag,
})
}

// Provided by PageController::index() — set to the editor open URL when a WOPI
// backend is active, null otherwise.
const editorUrl = loadState<string | null>('office', 'editor-url', null)
Expand Down Expand Up @@ -320,16 +310,7 @@ fetchAll()
:key="file.fileid"
@click="openFile(file)">
<template #preview>
<img v-if="!failedPreviews[file.fileid]"
:src="getPreviewUrl(file)"
:alt="file.basename"
loading="lazy"
class="overview-file-preview"
@error="failedPreviews = { ...failedPreviews, [file.fileid]: true }">
<NcIconSvgWrapper v-else
:path="mdiFileDocumentOutline"
:size="48"
class="overview-file-icon" />
<FilePreview :file="file" :alt="file.basename" />
</template>

<template #icon>
Expand All @@ -352,6 +333,13 @@ fetchAll()
:name="file.basename"
:active="false"
@click="openFile(file)">
<template #icon>
<!-- Requested size is 2x the rendered box for crisp hidpi rendering. -->
<FilePreview :file="file"
:size="96"
:fallback-icon-size="32"
class="office-overview__list-thumb" />
</template>
<template #indicator>
<NcIconSvgWrapper v-if="file.attributes?.favorite === 1"
:path="mdiStar"
Expand Down Expand Up @@ -408,16 +396,6 @@ fetchAll()
padding: calc(var(--default-grid-baseline) * 4);
}

.overview-file-preview {
width: 100%;
height: 100%;
object-fit: cover;
}

.overview-file-icon {
margin: auto;
}

.office-overview__content {
/* Safe area so content never sits under the app navigation toggle. */
padding-top: var(--default-clickable-area);
Expand Down Expand Up @@ -475,6 +453,14 @@ fetchAll()
padding: calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 4);
}

.office-overview__list-thumb {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
border-radius: var(--border-radius);
background-color: var(--color-background-dark);
flex-shrink: 0;
}

.office-overview__favourite-icon {
color: var(--color-warning);
}
Expand Down
Loading