diff --git a/__tests__/fixtures/view.ts b/__tests__/fixtures/view.ts new file mode 100644 index 000000000..dac627829 --- /dev/null +++ b/__tests__/fixtures/view.ts @@ -0,0 +1,39 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Folder, View } from '../../lib/index.ts' + +/** + * Creates a mock View and its associated Folder for testing purposes. + */ +export function mockView() { + const folder = new Folder({ + source: 'https://example.org/dav/files/admin/', + root: '/files/admin', + owner: 'admin', + }) + + const view = new View({ + id: 'test', + name: 'Test', + caption: 'Test caption', + emptyTitle: 'Test empty title', + emptyCaption: 'Test empty caption', + getContents: () => Promise.resolve({ folder, contents: [] }), + hidden: true, + icon: '', + order: 1, + params: {}, + columns: [], + emptyView: () => {}, + parent: 'parent', + sticky: false, + expanded: false, + defaultSortKey: 'key', + loadChildViews: async () => {}, + }) + + return { folder, view } +} diff --git a/__tests__/navigation.spec.ts b/__tests__/navigation.spec.ts index b421a3a30..1b40eec4c 100644 --- a/__tests__/navigation.spec.ts +++ b/__tests__/navigation.spec.ts @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + import { describe, it, expect, vi } from 'vitest' import { Navigation, getNavigation } from '../lib/navigation/navigation' -import { mockView } from './view.spec' +import { mockView } from './fixtures/view.ts' +import { View } from '../lib/index.ts' describe('getNavigation', () => { it('creates a new navigation if needed', () => { @@ -44,6 +46,19 @@ describe('Navigation', () => { expect(navigation.views).toEqual([view]) }) + it('Can register a view with only required files', async () => { + const view = new View({ + id: 'minimal', + name: 'Minimal view', + icon: '', + getContents: () => Promise.reject(new Error('Not implemented')), + }) + + const navigation = new Navigation() + navigation.register(view) + expect(navigation.views).toEqual([view]) + }) + it('Throws when trying to register invalid view', async () => { const navigation = new Navigation() expect(() => { diff --git a/__tests__/view.spec.ts b/__tests__/view.spec.ts index 969c6a015..ed12c5089 100644 --- a/__tests__/view.spec.ts +++ b/__tests__/view.spec.ts @@ -5,8 +5,8 @@ import { describe, expect, test } from 'vitest' -import { View } from '../lib/navigation/view.ts' -import { Folder } from '../lib/index.ts' +import { IView, View } from '../lib/navigation/view.ts' +import { mockView } from './fixtures/view.ts' describe('Invalid View creation', () => { test('Invalid id', () => { @@ -63,10 +63,10 @@ describe('Invalid View creation', () => { expect(() => new View({ id: 'test', name: 'Test', - order: 1, - hidden: 'true', + hidden: 'yes', + icon: '', getContents: () => Promise.reject(new Error()), - } as unknown as View), + } as unknown as IView), ).toThrowError('View hidden must be a boolean') }) @@ -157,6 +157,28 @@ describe('Invalid View creation', () => { } as unknown as View), ).toThrowError('View loadChildViews must be a function') }) + test('Invalid params', () => { + expect(() => new View({ + id: 'test', + name: 'Test', + order: 1, + icon: '', + getContents: () => Promise.reject(new Error()), + params: [], + } as unknown as View), + ).toThrowError('View params must be an object') + }) + test('Invalid params with null', () => { + expect(() => new View({ + id: 'test', + name: 'Test', + order: 1, + icon: '', + getContents: () => Promise.reject(new Error()), + params: null, + } as unknown as View), + ).toThrowError('View params must be an object') + }) }) describe('View creation', () => { @@ -167,7 +189,7 @@ describe('View creation', () => { expect(view.caption).toBe('Test caption') expect(view.emptyTitle).toBe('Test empty title') expect(view.emptyCaption).toBe('Test empty caption') - await expect(view.getContents('/')).resolves.toStrictEqual({ folder, contents: [] }) + await expect(view.getContents('/', { signal: new AbortController().signal })).resolves.toStrictEqual({ folder, contents: [] }) expect(view.hidden).toBe(true) expect(view.icon).toBe('') expect(view.order).toBe(1) @@ -181,36 +203,3 @@ describe('View creation', () => { await expect(view.loadChildViews?.({} as unknown as View)).resolves.toBe(undefined) }) }) - -/** - * Creates a mock View and its associated Folder for testing purposes. - */ -export function mockView() { - const folder = new Folder({ - source: 'https://example.org/dav/files/admin/', - root: '/files/admin', - owner: 'admin', - }) - - const view = new View({ - id: 'test', - name: 'Test', - caption: 'Test caption', - emptyTitle: 'Test empty title', - emptyCaption: 'Test empty caption', - getContents: () => Promise.resolve({ folder, contents: [] }), - hidden: true, - icon: '', - order: 1, - params: {}, - columns: [], - emptyView: () => {}, - parent: 'parent', - sticky: false, - expanded: false, - defaultSortKey: 'key', - loadChildViews: async () => {}, - }) - - return { folder, view } -} diff --git a/lib/navigation/view.ts b/lib/navigation/view.ts index 729f54dae..cc5a80efb 100644 --- a/lib/navigation/view.ts +++ b/lib/navigation/view.ts @@ -208,35 +208,38 @@ export class View implements IView { * @throws {Error} if the view is not valid */ export function validateView(view: IView) { - if (!view.id || typeof view.id !== 'string') { - throw new Error('View id is required and must be a string') - } - - if (!view.name || typeof view.name !== 'string') { - throw new Error('View name is required and must be a string') + if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { + throw new Error('View icon is required and must be a valid svg string') } - if ('caption' in view && typeof view.caption !== 'string') { - throw new Error('View caption must be a string') + if (!view.id || typeof view.id !== 'string') { + throw new Error('View id is required and must be a string') } if (!view.getContents || typeof view.getContents !== 'function') { throw new Error('View getContents is required and must be a function') } - if ('hidden' in view && typeof view.hidden !== 'boolean') { - throw new Error('View hidden must be a boolean') + if (!view.name || typeof view.name !== 'string') { + throw new Error('View name is required and must be a string') } - if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { - throw new Error('View icon is required and must be a valid svg string') - } + // optional properties type checks - if ('order' in view && typeof view.order !== 'number') { - throw new Error('View order must be a number') - } + checkOptionalProperty(view, 'caption', 'string') + checkOptionalProperty(view, 'columns', 'array') + checkOptionalProperty(view, 'defaultSortKey', 'string') + checkOptionalProperty(view, 'emptyCaption', 'string') + checkOptionalProperty(view, 'emptyTitle', 'string') + checkOptionalProperty(view, 'emptyView', 'function') + checkOptionalProperty(view, 'expanded', 'boolean') + checkOptionalProperty(view, 'hidden', 'boolean') + checkOptionalProperty(view, 'loadChildViews', 'function') + checkOptionalProperty(view, 'order', 'number') + checkOptionalProperty(view, 'params', 'object') + checkOptionalProperty(view, 'parent', 'string') + checkOptionalProperty(view, 'sticky', 'boolean') - // Optional properties if (view.columns) { view.columns.forEach((column) => { if (!(column instanceof Column)) { @@ -244,28 +247,31 @@ export function validateView(view: IView) { } }) } +} - if (view.emptyView && typeof view.emptyView !== 'function') { - throw new Error('View emptyView must be a function') - } - - if (view.parent && typeof view.parent !== 'string') { - throw new Error('View parent must be a string') - } - - if ('sticky' in view && typeof view.sticky !== 'boolean') { - throw new Error('View sticky must be a boolean') - } - - if ('expanded' in view && typeof view.expanded !== 'boolean') { - throw new Error('View expanded must be a boolean') - } - - if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') { - throw new Error('View defaultSortKey must be a string') - } - - if (view.loadChildViews && typeof view.loadChildViews !== 'function') { - throw new Error('View loadChildViews must be a function') +/** + * Check an optional property type + * + * @param obj - the object to check + * @param property - the property name + * @param type - the expected type + * @throws {Error} if the property is defined and not of the expected type + */ +function checkOptionalProperty( + obj: Partial, + property: keyof IView, + type: 'array' | 'function' | 'string' | 'boolean' | 'number' | 'object', +): void { + if (typeof obj[property] !== 'undefined') { + if (type === 'array') { + if (!Array.isArray(obj[property])) { + throw new Error(`View ${property} must be an array`) + } + // eslint-disable-next-line valid-typeof + } else if (typeof obj[property] !== type) { + throw new Error(`View ${property} must be a ${type}`) + } else if (type === 'object' && (obj[property] === null || Array.isArray(obj[property]))) { + throw new Error(`View ${property} must be an object`) + } } }