Skip to content

Commit b4a04c1

Browse files
committed
fix(view): ensure all optional properties are validated
This fixes an issue when using the `View` class as in this case the check `'property' in view` will always return true, thus the typecheck would fail when using the view class and `navigation.register`. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 5377cd3 commit b4a04c1

4 files changed

Lines changed: 104 additions & 79 deletions

File tree

__tests__/fixtures/view.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { Folder, View } from '../../lib/index.ts'
7+
8+
/**
9+
* Creates a mock View and its associated Folder for testing purposes.
10+
*/
11+
export function mockView() {
12+
const folder = new Folder({
13+
source: 'https://example.org/dav/files/admin/',
14+
root: '/files/admin',
15+
owner: 'admin',
16+
})
17+
18+
const view = new View({
19+
id: 'test',
20+
name: 'Test',
21+
caption: 'Test caption',
22+
emptyTitle: 'Test empty title',
23+
emptyCaption: 'Test empty caption',
24+
getContents: () => Promise.resolve({ folder, contents: [] }),
25+
hidden: true,
26+
icon: '<svg></svg>',
27+
order: 1,
28+
params: {},
29+
columns: [],
30+
emptyView: () => {},
31+
parent: 'parent',
32+
sticky: false,
33+
expanded: false,
34+
defaultSortKey: 'key',
35+
loadChildViews: async () => {},
36+
})
37+
38+
return { folder, view }
39+
}

__tests__/navigation.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
56
import { describe, it, expect, vi } from 'vitest'
67
import { Navigation, getNavigation } from '../lib/navigation/navigation'
7-
import { mockView } from './view.spec'
8+
import { mockView } from './fixtures/view.ts'
9+
import { View } from '../lib/index.ts'
810

911
describe('getNavigation', () => {
1012
it('creates a new navigation if needed', () => {
@@ -44,6 +46,19 @@ describe('Navigation', () => {
4446
expect(navigation.views).toEqual([view])
4547
})
4648

49+
it('Can register a view with only required files', async () => {
50+
const view = new View({
51+
id: 'minimal',
52+
name: 'Minimal view',
53+
icon: '<svg></svg>',
54+
getContents: () => Promise.reject(new Error('Not implemented')),
55+
})
56+
57+
const navigation = new Navigation()
58+
navigation.register(view)
59+
expect(navigation.views).toEqual([view])
60+
})
61+
4762
it('Throws when trying to register invalid view', async () => {
4863
const navigation = new Navigation()
4964
expect(() => {

__tests__/view.spec.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import { describe, expect, test } from 'vitest'
77

8-
import { View } from '../lib/navigation/view.ts'
9-
import { Folder } from '../lib/index.ts'
8+
import { IView, View } from '../lib/navigation/view.ts'
9+
import { mockView } from './fixtures/view.ts'
1010

1111
describe('Invalid View creation', () => {
1212
test('Invalid id', () => {
@@ -63,10 +63,10 @@ describe('Invalid View creation', () => {
6363
expect(() => new View({
6464
id: 'test',
6565
name: 'Test',
66-
order: 1,
67-
hidden: 'true',
66+
hidden: 'yes',
67+
icon: '<svg></svg>',
6868
getContents: () => Promise.reject(new Error()),
69-
} as unknown as View),
69+
} as unknown as IView),
7070
).toThrowError('View hidden must be a boolean')
7171
})
7272

@@ -181,36 +181,3 @@ describe('View creation', () => {
181181
await expect(view.loadChildViews?.({} as unknown as View)).resolves.toBe(undefined)
182182
})
183183
})
184-
185-
/**
186-
* Creates a mock View and its associated Folder for testing purposes.
187-
*/
188-
export function mockView() {
189-
const folder = new Folder({
190-
source: 'https://example.org/dav/files/admin/',
191-
root: '/files/admin',
192-
owner: 'admin',
193-
})
194-
195-
const view = new View({
196-
id: 'test',
197-
name: 'Test',
198-
caption: 'Test caption',
199-
emptyTitle: 'Test empty title',
200-
emptyCaption: 'Test empty caption',
201-
getContents: () => Promise.resolve({ folder, contents: [] }),
202-
hidden: true,
203-
icon: '<svg></svg>',
204-
order: 1,
205-
params: {},
206-
columns: [],
207-
emptyView: () => {},
208-
parent: 'parent',
209-
sticky: false,
210-
expanded: false,
211-
defaultSortKey: 'key',
212-
loadChildViews: async () => {},
213-
})
214-
215-
return { folder, view }
216-
}

lib/navigation/view.ts

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface IView {
4242
* This method _must_ also return the current directory
4343
* information alongside with its content.
4444
*
45-
* Usually a abort signal is provided to be able to
45+
* A abort signal is provided to be able to
4646
* cancel the request if the user change directory
4747
* {@see https://developer.mozilla.org/en-US/docs/Web/API/AbortController }.
4848
*/
@@ -201,64 +201,68 @@ export class View implements IView {
201201
* @throws {Error} if the view is not valid
202202
*/
203203
export function validateView(view: IView) {
204-
if (!view.id || typeof view.id !== 'string') {
205-
throw new Error('View id is required and must be a string')
206-
}
207-
208-
if (!view.name || typeof view.name !== 'string') {
209-
throw new Error('View name is required and must be a string')
204+
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
205+
throw new Error('View icon is required and must be a valid svg string')
210206
}
211207

212-
if ('caption' in view && typeof view.caption !== 'string') {
213-
throw new Error('View caption must be a string')
208+
if (!view.id || typeof view.id !== 'string') {
209+
throw new Error('View id is required and must be a string')
214210
}
215211

216212
if (!view.getContents || typeof view.getContents !== 'function') {
217213
throw new Error('View getContents is required and must be a function')
218214
}
219215

220-
if ('hidden' in view && typeof view.hidden !== 'boolean') {
221-
throw new Error('View hidden must be a boolean')
216+
if (!view.name || typeof view.name !== 'string') {
217+
throw new Error('View name is required and must be a string')
222218
}
223219

224-
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
225-
throw new Error('View icon is required and must be a valid svg string')
226-
}
220+
// optional properties type checks
227221

228-
if ('order' in view && typeof view.order !== 'number') {
229-
throw new Error('View order must be a number')
230-
}
222+
checkOptionalProperty(view, 'caption', 'string')
223+
checkOptionalProperty(view, 'columns', 'array')
224+
checkOptionalProperty(view, 'defaultSortKey', 'string')
225+
checkOptionalProperty(view, 'emptyCaption', 'string')
226+
checkOptionalProperty(view, 'emptyTitle', 'string')
227+
checkOptionalProperty(view, 'emptyView', 'function')
228+
checkOptionalProperty(view, 'expanded', 'boolean')
229+
checkOptionalProperty(view, 'hidden', 'boolean')
230+
checkOptionalProperty(view, 'loadChildViews', 'function')
231+
checkOptionalProperty(view, 'order', 'number')
232+
checkOptionalProperty(view, 'params', 'object')
233+
checkOptionalProperty(view, 'parent', 'string')
234+
checkOptionalProperty(view, 'sticky', 'boolean')
231235

232-
// Optional properties
233236
if (view.columns) {
234237
view.columns.forEach((column) => {
235238
if (!(column instanceof Column)) {
236239
throw new Error('View columns must be an array of Column. Invalid column found')
237240
}
238241
})
239242
}
243+
}
240244

241-
if (view.emptyView && typeof view.emptyView !== 'function') {
242-
throw new Error('View emptyView must be a function')
243-
}
244-
245-
if (view.parent && typeof view.parent !== 'string') {
246-
throw new Error('View parent must be a string')
247-
}
248-
249-
if ('sticky' in view && typeof view.sticky !== 'boolean') {
250-
throw new Error('View sticky must be a boolean')
251-
}
252-
253-
if ('expanded' in view && typeof view.expanded !== 'boolean') {
254-
throw new Error('View expanded must be a boolean')
255-
}
256-
257-
if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') {
258-
throw new Error('View defaultSortKey must be a string')
259-
}
260-
261-
if (view.loadChildViews && typeof view.loadChildViews !== 'function') {
262-
throw new Error('View loadChildViews must be a function')
245+
/**
246+
* Check an optional property type
247+
*
248+
* @param obj - the object to check
249+
* @param property - the property name
250+
* @param type - the expected type
251+
* @throws {Error} if the property is defined and not of the expected type
252+
*/
253+
function checkOptionalProperty(
254+
obj: Partial<IView>,
255+
property: keyof IView,
256+
type: 'array' | 'function' | 'string' | 'boolean' | 'number' | 'object',
257+
): void {
258+
if (typeof obj[property] !== 'undefined') {
259+
if (type === 'array') {
260+
if (!Array.isArray(obj[property])) {
261+
throw new Error(`View ${property} must be an array`)
262+
}
263+
// eslint-disable-next-line valid-typeof
264+
} else if (typeof obj[property] !== type) {
265+
throw new Error(`View ${property} must be a ${type}`)
266+
}
263267
}
264268
}

0 commit comments

Comments
 (0)