diff options
Diffstat (limited to 'apps/files_trashbin/src/files_views')
-rw-r--r-- | apps/files_trashbin/src/files_views/columns.spec.ts | 217 | ||||
-rw-r--r-- | apps/files_trashbin/src/files_views/columns.ts | 144 | ||||
-rw-r--r-- | apps/files_trashbin/src/files_views/trashbinView.spec.ts | 52 | ||||
-rw-r--r-- | apps/files_trashbin/src/files_views/trashbinView.ts | 35 |
4 files changed, 448 insertions, 0 deletions
diff --git a/apps/files_trashbin/src/files_views/columns.spec.ts b/apps/files_trashbin/src/files_views/columns.spec.ts new file mode 100644 index 00000000000..a22ef17ea6b --- /dev/null +++ b/apps/files_trashbin/src/files_views/columns.spec.ts @@ -0,0 +1,217 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { File } from '@nextcloud/files' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { deleted, deletedBy, originalLocation } from './columns.ts' +import { trashbinView } from './trashbinView.ts' +import * as ncAuth from '@nextcloud/auth' + +vi.mock('@nextcloud/l10n', async (originalModule) => ({ + ...(await originalModule()), + getLanguage: () => 'en', + getCanonicalLocale: () => 'en-US', +})) + +describe('files_trashbin: file list columns', () => { + + describe('column: original location', () => { + it('has id set', () => { + expect(originalLocation.id).toBe('files_trashbin--original-location') + }) + + it('has title set', () => { + expect(originalLocation.title).toBe('Original location') + }) + + it('correctly sorts nodes by original location', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'z-folder/a.txt' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/b.txt' } }) + + expect(originalLocation.sort).toBeTypeOf('function') + expect(originalLocation.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(originalLocation.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('renders a node with original location', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/a.txt' } }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('folder') + expect(el.title).toBe('folder') + }) + + it('renders a node when original location is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('Unknown') + expect(el.title).toBe('Unknown') + }) + + it('renders a node when original location is the root', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'a.txt' } }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('All files') + expect(el.title).toBe('All files') + }) + }) + + describe('column: deleted time', () => { + it('has id set', () => { + expect(deleted.id).toBe('files_trashbin--deleted') + }) + + it('has title set', () => { + expect(deleted.title).toBe('Deleted') + }) + + it('correctly sorts nodes by deleted time', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684422 } }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes by deleted time and falls back to mtime', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes even if no deletion date is provided', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + describe('rendering', () => { + afterAll(() => { + vi.useRealTimers() + }) + + beforeEach(() => { + vi.useFakeTimers({ now: 1741684582000 }) + }) + + it('renders a node with deletion date', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': (Date.now() / 1000) - 120 } }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('2 minutes ago') + expect(el.title).toBe('March 11, 2025 at 9:14 AM') + }) + + it('renders a node when deletion date is missing and falls back to mtime', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', mtime: new Date(Date.now() - 60000) }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('1 minute ago') + expect(el.title).toBe('March 11, 2025 at 9:15 AM') + }) + + it('renders a node when deletion date is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('A long time ago') + }) + }) + + describe('column: deleted by', () => { + it('has id set', () => { + expect(deletedBy.id).toBe('files_trashbin--deleted-by') + }) + + it('has title set', () => { + expect(deletedBy.title).toBe('Deleted by') + }) + + it('correctly sorts nodes by user-id of deleting user', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('correctly sorts nodes by display name of deleting user', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('correctly sorts nodes by display name of deleting user before user id', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': '000', 'trashbin-deleted-by-id': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa', 'trashbin-deleted-by-id': '999' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes even when one is missing', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } }) + const nodeC = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain' }) + + expect(deletedBy.sort).toBeTypeOf('function') + // aaa is less then "Unknown" + expect(deletedBy.sort!(nodeA, nodeC)).toBeLessThan(0) + // zzz is greater than "Unknown" + expect(deletedBy.sort!(nodeB, nodeC)).toBeGreaterThan(0) + }) + + it('renders a node with deleting user', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toMatch(/\suser-id\s/) + }) + + it('renders a node with deleting user display name', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'user-name', 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toMatch(/\suser-name\s/) + }) + + it('renders a node even when information is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('Unknown') + }) + + it('renders a node when current user is the deleting user', () => { + vi.spyOn(ncAuth, 'getCurrentUser').mockImplementationOnce(() => ({ + uid: 'user-id', + displayName: 'user-display-name', + isAdmin: false, + })) + + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('You') + }) + }) + + }) + +}) diff --git a/apps/files_trashbin/src/files_views/columns.ts b/apps/files_trashbin/src/files_views/columns.ts new file mode 100644 index 00000000000..085d22c67a6 --- /dev/null +++ b/apps/files_trashbin/src/files_views/columns.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { Column, Node } from '@nextcloud/files' +import { formatRelativeTime, getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { dirname } from '@nextcloud/paths' + +import Vue from 'vue' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' + +export const originalLocation = new Column({ + id: 'files_trashbin--original-location', + title: t('files_trashbin', 'Original location'), + render(node) { + const originalLocation = parseOriginalLocation(node) + const span = document.createElement('span') + span.title = originalLocation + span.textContent = originalLocation + return span + }, + sort(nodeA, nodeB) { + const locationA = parseOriginalLocation(nodeA) + const locationB = parseOriginalLocation(nodeB) + return locationA.localeCompare(locationB, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' }) + }, +}) + +export const deletedBy = new Column({ + id: 'files_trashbin--deleted-by', + title: t('files_trashbin', 'Deleted by'), + render(node) { + const { userId, displayName, label } = parseDeletedBy(node) + if (label) { + const span = document.createElement('span') + span.textContent = label + return span + } + + const UserBubble = Vue.extend(NcUserBubble) + const propsData = { + size: 32, + user: userId ?? undefined, + displayName: displayName ?? userId, + } + const userBubble = new UserBubble({ propsData }).$mount().$el + return userBubble as HTMLElement + }, + sort(nodeA, nodeB) { + const deletedByA = parseDeletedBy(nodeA) + const deletedbyALabel = deletedByA.label ?? deletedByA.displayName ?? deletedByA.userId + const deletedByB = parseDeletedBy(nodeB) + const deletedByBLabel = deletedByB.label ?? deletedByB.displayName ?? deletedByB.userId + // label is set if uid and display name are unset - if label is unset at least uid or display name is set. + return deletedbyALabel!.localeCompare(deletedByBLabel!, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' }) + }, +}) + +export const deleted = new Column({ + id: 'files_trashbin--deleted', + title: t('files_trashbin', 'Deleted'), + + render(node) { + const deletionTime = node.attributes?.['trashbin-deletion-time'] || ((node?.mtime?.getTime() ?? 0) / 1000) + const span = document.createElement('span') + if (deletionTime) { + const formatter = Intl.DateTimeFormat([getCanonicalLocale()], { dateStyle: 'long', timeStyle: 'short' }) + const timestamp = new Date(deletionTime * 1000) + + span.title = formatter.format(timestamp) + span.textContent = formatRelativeTime(timestamp, { ignoreSeconds: t('files', 'few seconds ago') }) + return span + } + + // Unknown deletion time + span.textContent = t('files_trashbin', 'A long time ago') + return span + }, + + sort(nodeA, nodeB) { + // deletion time is a unix timestamp while mtime is a JS Date -> we need to align the numbers (seconds vs milliseconds) + const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || ((nodeA?.mtime?.getTime() ?? 0) / 1000) + const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || ((nodeB?.mtime?.getTime() ?? 0) / 1000) + return deletionTimeB - deletionTimeA + }, +}) + +/** + * Get the original file location of a trashbin file. + * + * @param node The node to parse + */ +function parseOriginalLocation(node: Node): string { + const path = stringOrNull(node.attributes?.['trashbin-original-location']) + if (!path) { + return t('files_trashbin', 'Unknown') + } + + const dir = dirname(path) + if (dir === path) { // Node is in root folder + return t('files_trashbin', 'All files') + } + + return dir.replace(/^\//, '') +} + +/** + * Parse a trashbin file to get information about the user that deleted the file. + * + * @param node The node to parse + */ +function parseDeletedBy(node: Node) { + const userId = stringOrNull(node.attributes?.['trashbin-deleted-by-id']) + const displayName = stringOrNull(node.attributes?.['trashbin-deleted-by-display-name']) + + let label: string|undefined + const currentUserId = getCurrentUser()?.uid + if (userId === currentUserId) { + label = t('files_trashbin', 'You') + } + if (!userId && !displayName) { + label = t('files_trashbin', 'Unknown') + } + + return { + userId, + displayName, + label, + } +} + +/** + * If the attribute is given it will be stringified and returned - otherwise null is returned. + * + * @param attribute The attribute to check + */ +function stringOrNull(attribute: unknown): string | null { + if (attribute) { + return String(attribute) + } + return null +} diff --git a/apps/files_trashbin/src/files_views/trashbinView.spec.ts b/apps/files_trashbin/src/files_views/trashbinView.spec.ts new file mode 100644 index 00000000000..7f5a45ee9cd --- /dev/null +++ b/apps/files_trashbin/src/files_views/trashbinView.spec.ts @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { describe, expect, it } from 'vitest' +import isSvg from 'is-svg' + +import { deleted, deletedBy, originalLocation } from './columns' +import { TRASHBIN_VIEW_ID, trashbinView } from './trashbinView.ts' +import { getContents } from '../services/trashbin.ts' + +describe('files_trasbin: trashbin files view', () => { + it('has correct strings', () => { + expect(trashbinView.id).toBe(TRASHBIN_VIEW_ID) + expect(trashbinView.name).toBe('Deleted files') + expect(trashbinView.caption).toBe('List of files that have been deleted.') + expect(trashbinView.emptyTitle).toBe('No deleted files') + expect(trashbinView.emptyCaption).toBe('Files and folders you have deleted will show up here') + }) + + it('sorts by deleted time', () => { + expect(trashbinView.defaultSortKey).toBe('deleted') + }) + + it('is sticky to the bottom in the view list', () => { + expect(trashbinView.sticky).toBe(true) + }) + + it('has order defined', () => { + expect(trashbinView.order).toBeTypeOf('number') + expect(trashbinView.order).toBe(50) + }) + + it('has valid icon', () => { + expect(trashbinView.icon).toBeTypeOf('string') + expect(isSvg(trashbinView.icon)).toBe(true) + }) + + it('has custom columns', () => { + expect(trashbinView.columns).toHaveLength(3) + expect(trashbinView.columns).toEqual([ + originalLocation, + deletedBy, + deleted, + ]) + }) + + it('has get content method', () => { + expect(trashbinView.getContents).toBeTypeOf('function') + expect(trashbinView.getContents).toBe(getContents) + }) +}) diff --git a/apps/files_trashbin/src/files_views/trashbinView.ts b/apps/files_trashbin/src/files_views/trashbinView.ts new file mode 100644 index 00000000000..f55c6b71595 --- /dev/null +++ b/apps/files_trashbin/src/files_views/trashbinView.ts @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { View } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { deleted, deletedBy, originalLocation } from './columns.ts' +import { getContents } from '../services/trashbin.ts' + +import svgDelete from '@mdi/svg/svg/delete-outline.svg?raw' + +export const TRASHBIN_VIEW_ID = 'trashbin' + +export const trashbinView = new View({ + id: TRASHBIN_VIEW_ID, + name: t('files_trashbin', 'Deleted files'), + caption: t('files_trashbin', 'List of files that have been deleted.'), + + emptyTitle: t('files_trashbin', 'No deleted files'), + emptyCaption: t('files_trashbin', 'Files and folders you have deleted will show up here'), + + icon: svgDelete, + order: 50, + sticky: true, + + defaultSortKey: 'deleted', + + columns: [ + originalLocation, + deletedBy, + deleted, + ], + + getContents, +}) |