Skip to content

Commit ce0d836

Browse files
authored
Merge pull request #1438 from nextcloud-libraries/fix/view
fix(view): ensure all optional properties are validated
2 parents 233e287 + d2216ee commit ce0d836

4 files changed

Lines changed: 128 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: 28 additions & 39 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

@@ -157,6 +157,28 @@ describe('Invalid View creation', () => {
157157
} as unknown as View),
158158
).toThrowError('View loadChildViews must be a function')
159159
})
160+
test('Invalid params', () => {
161+
expect(() => new View({
162+
id: 'test',
163+
name: 'Test',
164+
order: 1,
165+
icon: '<svg></svg>',
166+
getContents: () => Promise.reject(new Error()),
167+
params: [],
168+
} as unknown as View),
169+
).toThrowError('View params must be an object')
170+
})
171+
test('Invalid params with null', () => {
172+
expect(() => new View({
173+
id: 'test',
174+
name: 'Test',
175+
order: 1,
176+
icon: '<svg></svg>',
177+
getContents: () => Promise.reject(new Error()),
178+
params: null,
179+
} as unknown as View),
180+
).toThrowError('View params must be an object')
181+
})
160182
})
161183

162184
describe('View creation', () => {
@@ -167,7 +189,7 @@ describe('View creation', () => {
167189
expect(view.caption).toBe('Test caption')
168190
expect(view.emptyTitle).toBe('Test empty title')
169191
expect(view.emptyCaption).toBe('Test empty caption')
170-
await expect(view.getContents('/')).resolves.toStrictEqual({ folder, contents: [] })
192+
await expect(view.getContents('/', { signal: new AbortController().signal })).resolves.toStrictEqual({ folder, contents: [] })
171193
expect(view.hidden).toBe(true)
172194
expect(view.icon).toBe('<svg></svg>')
173195
expect(view.order).toBe(1)
@@ -181,36 +203,3 @@ describe('View creation', () => {
181203
await expect(view.loadChildViews?.({} as unknown as View)).resolves.toBe(undefined)
182204
})
183205
})
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: 45 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -208,64 +208,70 @@ export class View implements IView {
208208
* @throws {Error} if the view is not valid
209209
*/
210210
export function validateView(view: IView) {
211-
if (!view.id || typeof view.id !== 'string') {
212-
throw new Error('View id is required and must be a string')
213-
}
214-
215-
if (!view.name || typeof view.name !== 'string') {
216-
throw new Error('View name is required and must be a string')
211+
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
212+
throw new Error('View icon is required and must be a valid svg string')
217213
}
218214

219-
if ('caption' in view && typeof view.caption !== 'string') {
220-
throw new Error('View caption must be a string')
215+
if (!view.id || typeof view.id !== 'string') {
216+
throw new Error('View id is required and must be a string')
221217
}
222218

223219
if (!view.getContents || typeof view.getContents !== 'function') {
224220
throw new Error('View getContents is required and must be a function')
225221
}
226222

227-
if ('hidden' in view && typeof view.hidden !== 'boolean') {
228-
throw new Error('View hidden must be a boolean')
223+
if (!view.name || typeof view.name !== 'string') {
224+
throw new Error('View name is required and must be a string')
229225
}
230226

231-
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
232-
throw new Error('View icon is required and must be a valid svg string')
233-
}
227+
// optional properties type checks
234228

235-
if ('order' in view && typeof view.order !== 'number') {
236-
throw new Error('View order must be a number')
237-
}
229+
checkOptionalProperty(view, 'caption', 'string')
230+
checkOptionalProperty(view, 'columns', 'array')
231+
checkOptionalProperty(view, 'defaultSortKey', 'string')
232+
checkOptionalProperty(view, 'emptyCaption', 'string')
233+
checkOptionalProperty(view, 'emptyTitle', 'string')
234+
checkOptionalProperty(view, 'emptyView', 'function')
235+
checkOptionalProperty(view, 'expanded', 'boolean')
236+
checkOptionalProperty(view, 'hidden', 'boolean')
237+
checkOptionalProperty(view, 'loadChildViews', 'function')
238+
checkOptionalProperty(view, 'order', 'number')
239+
checkOptionalProperty(view, 'params', 'object')
240+
checkOptionalProperty(view, 'parent', 'string')
241+
checkOptionalProperty(view, 'sticky', 'boolean')
238242

239-
// Optional properties
240243
if (view.columns) {
241244
view.columns.forEach((column) => {
242245
if (!(column instanceof Column)) {
243246
throw new Error('View columns must be an array of Column. Invalid column found')
244247
}
245248
})
246249
}
250+
}
247251

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

0 commit comments

Comments
 (0)