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
137 changes: 137 additions & 0 deletions apps/files/src/views/ReferenceFileWidget.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IFile } from '@nextcloud/files'

import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const viewer = vi.hoisted(() => ({ canView: vi.fn(), open: vi.fn() }))
vi.mock('@nextcloud/viewer', () => ({
canView: viewer.canView,
getViewer: () => ({ open: viewer.open }),
}))
const user = vi.hoisted(() => ({ current: { uid: 'alice' } as { uid: string } | null }))
vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => user.current }))

const { default: ReferenceFileWidget } = await import('./ReferenceFileWidget.vue')

// What the viewer's element keeps of what it is given
class ViewerFileStub extends HTMLElement {
file?: IFile
}
if (!customElements.get('nc-viewer-file')) {
customElements.define('nc-viewer-file', ViewerFileStub)
}

const richObject = {
id: 42,
name: 'beach.jpg',
size: 2048,
path: 'Photos/beach.jpg',
link: 'https://cloud.example.com/f/42',
mimetype: 'image/jpeg',
mtime: 1760000000,
'preview-available': true,
}

/**
* Show the widget for a file.
*
* @param overrides - What differs from the picture above
*/
function mountWidget(overrides: Record<string, unknown> = {}) {
return mount(ReferenceFileWidget, {
props: { richObject: { ...richObject, ...overrides } },
global: { mocks: { t: (_app: string, text: string) => text } },
attachTo: document.body,
})
}

beforeEach(() => {
user.current = { uid: 'alice' }
viewer.canView.mockReturnValue(true)
window.OC = { ...window.OC, Util: { humanFileSize: () => '2 KB', relativeModifiedDate: () => 'a week ago' } } as typeof window.OC
})

afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})

describe('ReferenceFileWidget', () => {
it('shows the file with the viewer\'s own element', () => {
const wrapper = mountWidget()

const element = wrapper.find('nc-viewer-file').element as ViewerFileStub
expect(element.file?.fileid).toBe(42)
expect(element.file?.displayname).toBe('beach.jpg')
expect(element.file?.source).toContain('/remote.php/dav/files/alice/Photos/beach.jpg')
})

it('hands the viewer the permissions the reference carries', () => {
const wrapper = mountWidget({ permissions: 27 })

expect((wrapper.find('nc-viewer-file').element as ViewerFileStub).file?.permissions).toBe(27)
})

// The viewer then offers no download, and asks for the preview by hand
it('hands the viewer what a share forbids', () => {
const wrapper = mountWidget({ 'hide-download': 'yes', 'can-download': 'no' })

const { attributes } = (wrapper.find('nc-viewer-file').element as ViewerFileStub).file!
expect(attributes['hide-download']).toBe(true)
expect(JSON.parse(attributes['share-attributes'])).toEqual([{ scope: 'permissions', key: 'download', value: false }])
})

it('lets the viewer download a file of a share that allows it', () => {
const wrapper = mountWidget({ 'hide-download': 'no', 'can-download': 'yes' })

const { attributes } = (wrapper.find('nc-viewer-file').element as ViewerFileStub).file!
expect(attributes['hide-download']).toBe(false)
expect(attributes['share-attributes']).toBeUndefined()
})

it('falls back to its link when the viewer cannot show the file', async () => {
const wrapper = mountWidget()

wrapper.find('nc-viewer-file').element.dispatchEvent(new CustomEvent('errored'))
await wrapper.vm.$nextTick()

expect(wrapper.find('nc-viewer-file').exists()).toBe(false)
expect(wrapper.find('a.widget-file--link').exists()).toBe(true)
})

it('is a link to a file the viewer has nothing for', () => {
viewer.canView.mockReturnValue(false)
const wrapper = mountWidget({ name: 'report.zip', mimetype: 'application/zip' })

expect(wrapper.find('nc-viewer-file').exists()).toBe(false)
expect(wrapper.find('a.widget-file--link').exists()).toBe(true)
})

// A guest has no files of their own the path would be in
it('is a link for a guest', () => {
user.current = null
const wrapper = mountWidget()

expect(wrapper.find('nc-viewer-file').exists()).toBe(false)
expect(viewer.canView).not.toHaveBeenCalled()
})

it('opens the file in the viewer from its link when it is not shown inline', async () => {
const wrapper = mount(ReferenceFileWidget, {
props: { richObject, interactive: false },
global: { mocks: { t: (_app: string, text: string) => text } },
})

await wrapper.find('a.widget-file--link').trigger('click')

expect(viewer.open).toHaveBeenCalledOnce()
const [files, file] = viewer.open.mock.calls[0]!
expect(files).toHaveLength(1)
expect(file.fileid).toBe(42)
})
})
117 changes: 54 additions & 63 deletions apps/files/src/views/ReferenceFileWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,17 @@
</span>
</div>

<!-- Live preview if a handler is available -->
<!-- Live preview where the viewer can show the file, read-only, with a menu
to open it in the viewer. <nc-viewer-file> is a custom element, not a Vue
component: as a plain tag, Vue would try to resolve it and warn, as our
build has no `isCustomElement` for it. `:is` with a string renders the
element without, and `.prop` hands it the file as a DOM property -->
<component
:is="viewerHandler.component"
v-else-if="interactive && viewerHandler && !failedViewer"
:active="false /* prevent video from autoplaying */"
:canSwipe="false"
:canZoom="false"
:isEmbedded="true"
v-bind="viewerFile"
:fileList="[viewerFile]"
:isFullScreen="false"
:isSidebarShown="false"
:is="viewerFileTag"
v-else-if="interactive && viewable && !failedViewer"
:file.prop="node"
class="widget-file widget-file--interactive"
@error="failedViewer = true" />
@errored="failedViewer = true" />

<!-- The file is accessible -->
<a
Expand All @@ -57,12 +54,14 @@

<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { Component, PropType } from 'vue'
import type { PropType } from 'vue'

import { getCurrentUser } from '@nextcloud/auth'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import { File, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { canView, getViewer } from '@nextcloud/viewer'
import path from 'path'
import { defineComponent } from 'vue'
import FileIcon from 'vue-material-design-icons/File.vue'
Expand All @@ -80,31 +79,9 @@ type Ressource = {
mimetype: string
mtime: number // as unix timestamp
'preview-available': boolean
}

type ViewerHandler = {
id: string
group: string
mimes: string[]
component: Component
}

/**
* Minimal mock of the legacy Viewer FileInfo
* TODO: replace by Node object
*/
type ViewerFile = {
filename: string // the path to the root folder
basename: string // the file name
lastmod: Date // the last modification date
size: number // the file size in bytes
type: string
mime: string
fileid: number
failed: boolean
loaded: boolean
davPath: string
source: string
permissions?: number
'hide-download'?: 'yes' | 'no'
'can-download'?: 'yes' | 'no'
}

export default defineComponent({
Expand Down Expand Up @@ -133,37 +110,51 @@ export default defineComponent({

data() {
return {
// The element @nextcloud/viewer defines to show a file anywhere
viewerFileTag: 'nc-viewer-file',
previewUrl: null as string | null,
failedViewer: false,
}
},

computed: {
availableViewerHandlers(): ViewerHandler[] {
return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[]
},

viewerHandler(): ViewerHandler | undefined {
return this.availableViewerHandlers
.find((handler) => handler.mimes.includes(this.richObject.mimetype))
/**
* The file, as the viewer takes it. The server resolved the reference
* for this user: the path is in their folder, a file shared with them
* included. None for a guest, who has no folder
*/
node(): File | null {
const uid = getCurrentUser()?.uid
if (!uid || this.isFolder) {
return null
}
return new File({
id: this.richObject.id,
source: generateRemoteUrl(`dav/files/${uid}/${this.richObject.path}`).replace(/\/\/$/, '/'),
root: `/files/${uid}`,
displayname: this.richObject.name,
mime: this.richObject.mimetype,
size: this.richObject.size,
mtime: new Date(this.richObject.mtime * 1000),
// Not known here, and not needed to show it
owner: null,
// Readable at least, or this widget would say it is not accessible
permissions: this.richObject.permissions ?? Permission.READ,
attributes: {
hasPreview: this.richObject['preview-available'],
// What a share allows, which the viewer reads as DAV gives it:
// no download offered, and the preview asked for by hand
'hide-download': this.richObject['hide-download'] === 'yes',
...(this.richObject['can-download'] === 'no'
? { 'share-attributes': JSON.stringify([{ scope: 'permissions', key: 'download', value: false }]) }
: {}),
},
})
},

viewerFile(): ViewerFile {
const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`)
.replace(/\/\/$/, '/')
return {
filename: this.richObject.path,
basename: this.richObject.name,
lastmod: new Date(this.richObject.mtime * 1000),
size: this.richObject.size,
type: 'file',
mime: this.richObject.mimetype,
fileid: this.richObject.id,
failed: false,
loaded: true,
davPath: davSource,
source: davSource,
}
/** Whether the viewer has something to show the file with */
viewable(): boolean {
return this.node !== null && canView(this.node)
},

fileSize() {
Expand Down Expand Up @@ -221,10 +212,10 @@ export default defineComponent({
event.stopPropagation()
event.preventDefault()
this.openFilePicker()
} else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) {
} else if (this.viewable) {
event.stopPropagation()
event.preventDefault()
window?.OCA?.Viewer?.open({ path: this.richObject.path })
getViewer().open([this.node!], this.node!)
}
},

Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"@nextcloud/paths": "^3.1.0",
"@nextcloud/router": "^3.2.0",
"@nextcloud/sharing": "^1.0.0-beta.4",
"@nextcloud/viewer": "^2.0.0-beta.18",
"@nextcloud/viewer": "^2.0.0-beta.19",
"@nextcloud/vue": "^9.12.0",
"@simplewebauthn/browser": "^14.0.0",
"@vueuse/core": "^15.0.0",
Expand Down
Loading