Skip to content

Commit a362b02

Browse files
authored
Merge pull request #1417 from nextcloud-libraries/feat/allow-to-register-view
refactor: use interfaces where possible instead of instances
2 parents 287421e + 43ec5ae commit a362b02

7 files changed

Lines changed: 96 additions & 82 deletions

File tree

__tests__/navigation.spec.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
*/
55
import { describe, it, expect, vi } from 'vitest'
66
import { Navigation, getNavigation } from '../lib/navigation/navigation'
7-
import { View } from '../lib/navigation/view'
8-
9-
const mockView = (id = 'view', order = 1) => new View({ id, order, name: 'View', icon: '<svg></svg>', getContents: () => Promise.reject(new Error()) })
7+
import { mockView } from './view.spec'
108

119
describe('getNavigation', () => {
1210
it('creates a new navigation if needed', () => {
@@ -40,23 +38,31 @@ describe('getNavigation', () => {
4038
describe('Navigation', () => {
4139
it('Can register a view', async () => {
4240
const navigation = new Navigation()
43-
const view = mockView()
41+
const { view } = mockView()
4442
navigation.register(view)
4543

4644
expect(navigation.views).toEqual([view])
4745
})
4846

47+
it('Throws when trying to register invalid view', async () => {
48+
const navigation = new Navigation()
49+
expect(() => {
50+
// @ts-expect-error mocking to test invalid input
51+
navigation.register({ id: 'someid' })
52+
}).toThrowError()
53+
})
54+
4955
it('Throws when registering the same view twice', async () => {
5056
const navigation = new Navigation()
51-
const view = mockView()
57+
const { view } = mockView()
5258
navigation.register(view)
5359
expect(() => navigation.register(view)).toThrow(/already registered/)
5460
expect(navigation.views).toEqual([view])
5561
})
5662

5763
it('Emits update event after registering a view', async () => {
5864
const navigation = new Navigation()
59-
const view = mockView()
65+
const { view } = mockView()
6066
const listener = vi.fn()
6167

6268
navigation.addEventListener('update', listener)
@@ -68,7 +74,7 @@ describe('Navigation', () => {
6874

6975
it('Can remove a view', async () => {
7076
const navigation = new Navigation()
71-
const view = mockView()
77+
const { view } = mockView()
7278
navigation.register(view)
7379
expect(navigation.views).toEqual([view])
7480
navigation.remove(view.id)
@@ -77,7 +83,7 @@ describe('Navigation', () => {
7783

7884
it('Emits update event after removing a view', async () => {
7985
const navigation = new Navigation()
80-
const view = mockView()
86+
const { view } = mockView()
8187
const listener = vi.fn()
8288
navigation.register(view)
8389
navigation.addEventListener('update', listener)
@@ -98,7 +104,7 @@ describe('Navigation', () => {
98104

99105
it('Can set a view as active', async () => {
100106
const navigation = new Navigation()
101-
const view = mockView()
107+
const { view } = mockView()
102108
navigation.register(view)
103109

104110
expect(navigation.active).toBe(null)
@@ -109,7 +115,7 @@ describe('Navigation', () => {
109115

110116
it('Emits event when setting a view as active', async () => {
111117
const navigation = new Navigation()
112-
const view = mockView()
118+
const { view } = mockView()
113119
navigation.register(view)
114120

115121
// add listener

__tests__/view.spec.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -161,31 +161,7 @@ describe('Invalid View creation', () => {
161161

162162
describe('View creation', () => {
163163
test('Create a View', async () => {
164-
const folder = new Folder({
165-
source: 'https://example.org/dav/files/admin/',
166-
root: '/files/admin',
167-
owner: 'admin',
168-
})
169-
const view = new View({
170-
id: 'test',
171-
name: 'Test',
172-
caption: 'Test caption',
173-
emptyTitle: 'Test empty title',
174-
emptyCaption: 'Test empty caption',
175-
getContents: () => Promise.resolve({ folder, contents: [] }),
176-
hidden: true,
177-
icon: '<svg></svg>',
178-
order: 1,
179-
params: {},
180-
columns: [],
181-
emptyView: () => {},
182-
parent: 'parent',
183-
sticky: false,
184-
expanded: false,
185-
defaultSortKey: 'key',
186-
loadChildViews: async () => {},
187-
})
188-
164+
const { view, folder } = mockView()
189165
expect(view.id).toBe('test')
190166
expect(view.name).toBe('Test')
191167
expect(view.caption).toBe('Test caption')
@@ -205,3 +181,33 @@ describe('View creation', () => {
205181
await expect(view.loadChildViews?.({} as unknown as View)).resolves.toBe(undefined)
206182
})
207183
})
184+
185+
export function mockView() {
186+
const folder = new Folder({
187+
source: 'https://example.org/dav/files/admin/',
188+
root: '/files/admin',
189+
owner: 'admin',
190+
})
191+
192+
const view = new View({
193+
id: 'test',
194+
name: 'Test',
195+
caption: 'Test caption',
196+
emptyTitle: 'Test empty title',
197+
emptyCaption: 'Test empty caption',
198+
getContents: () => Promise.resolve({ folder, contents: [] }),
199+
hidden: true,
200+
icon: '<svg></svg>',
201+
order: 1,
202+
params: {},
203+
columns: [],
204+
emptyView: () => {},
205+
parent: 'parent',
206+
sticky: false,
207+
expanded: false,
208+
defaultSortKey: 'key',
209+
loadChildViews: async () => {},
210+
})
211+
212+
return { folder, view }
213+
}

lib/fileListHeaders.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Folder } from './node/folder.ts'
7-
import type { View } from './navigation/view.ts'
6+
import type { IFolder } from './node/folder.ts'
7+
import type { IView } from './navigation/view.ts'
88

99
import logger from './utils/logger.ts'
1010

@@ -14,11 +14,11 @@ export interface HeaderData {
1414
/** Order */
1515
order: number
1616
/** Condition wether this header is shown or not */
17-
enabled?: (folder: Folder, view: View) => boolean
17+
enabled?: (folder: IFolder, view: IView) => boolean
1818
/** Executed when file list is initialized */
19-
render: (el: HTMLElement, folder: Folder, view: View) => void
19+
render: (el: HTMLElement, folder: IFolder, view: IView) => void
2020
/** Executed when root folder changed */
21-
updated(folder: Folder, view: View)
21+
updated(folder: IFolder, view: IView)
2222
}
2323

2424
export class Header {

lib/navigation/column.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,23 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Node } from '../node/node.ts'
7-
import type { View } from './view.ts'
6+
import type { INode } from '../node/node.ts'
7+
import type { IView } from './view.ts'
88

99
interface ColumnData {
1010
/** Unique column ID */
1111
id: string
1212
/** Translated column title */
1313
title: string
1414
/** The content of the cell. The element will be appended within */
15-
render: (node: Node, view: View) => HTMLElement
16-
/** Function used to sort Nodes between them */
17-
sort?: (nodeA: Node, nodeB: Node) => number
15+
render: (node: INode, view: IView) => HTMLElement
16+
/** Function used to sort INodes between them */
17+
sort?: (nodeA: INode, nodeB: INode) => number
1818
/**
1919
* Custom summary of the column to display at the end of the list.
2020
* Will not be displayed if nothing is provided
2121
*/
22-
summary?: (node: Node[], view: View) => string
22+
summary?: (node: INode[], view: IView) => string
2323
}
2424

2525
export class Column implements ColumnData {

lib/navigation/navigation.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5-
import type { View } from './view'
5+
6+
import type { IView } from './view'
7+
8+
import { validateView } from './view'
69
import { TypedEventTarget } from 'typescript-event-target'
710
import logger from '../utils/logger'
811

912
/**
1013
* The event is emitted when the navigation view was updated.
1114
* It contains the new active view in the `detail` attribute.
1215
*/
13-
interface UpdateActiveViewEvent extends CustomEvent<View | null> {
16+
interface UpdateActiveViewEvent extends CustomEvent<IView | null> {
1417
type: 'updateActive'
1518
}
1619

@@ -44,19 +47,22 @@ interface UpdateViewsEvent extends CustomEvent<never> {
4447
*/
4548
export class Navigation extends TypedEventTarget<{ updateActive: UpdateActiveViewEvent, update: UpdateViewsEvent }> {
4649

47-
private _views: View[] = []
48-
private _currentView: View | null = null
50+
private _views: IView[] = []
51+
private _currentView: IView | null = null
4952

5053
/**
5154
* Register a new view on the navigation
5255
* @param view The view to register
53-
* @throws `Error` is thrown if a view with the same id is already registered
56+
* @throws {Error} if a view with the same id is already registered
57+
* @throws {Error} if the registered view is invalid
5458
*/
55-
register(view: View): void {
59+
register(view: IView): void {
5660
if (this._views.find(search => search.id === view.id)) {
57-
throw new Error(`View id ${view.id} is already registered`)
61+
throw new Error(`IView id ${view.id} is already registered`)
5862
}
5963

64+
validateView(view)
65+
6066
this._views.push(view)
6167
this.dispatchTypedEvent('update', new CustomEvent<never>('update') as UpdateViewsEvent)
6268
}
@@ -78,23 +84,23 @@ export class Navigation extends TypedEventTarget<{ updateActive: UpdateActiveVie
7884
* @fires UpdateActiveViewEvent
7985
* @param view New active view
8086
*/
81-
setActive(view: View | null): void {
87+
setActive(view: IView | null): void {
8288
this._currentView = view
83-
const event = new CustomEvent<View | null>('updateActive', { detail: view })
89+
const event = new CustomEvent<IView | null>('updateActive', { detail: view })
8490
this.dispatchTypedEvent('updateActive', event as UpdateActiveViewEvent)
8591
}
8692

8793
/**
8894
* The currently active files view
8995
*/
90-
get active(): View | null {
96+
get active(): IView | null {
9197
return this._currentView
9298
}
9399

94100
/**
95101
* All registered views
96102
*/
97-
get views(): View[] {
103+
get views(): IView[] {
98104
return this._views
99105
}
100106

lib/navigation/view.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type ContentsWithRoot = {
1414
contents: Node[]
1515
}
1616

17-
interface ViewData {
17+
export interface IView {
1818
/** Unique view ID */
1919
id: string
2020
/** Translated view name */
@@ -97,12 +97,12 @@ interface ViewData {
9797
loadChildViews?: (view: View) => Promise<void>
9898
}
9999

100-
export class View implements ViewData {
100+
export class View implements IView {
101101

102-
private _view: ViewData
102+
private _view: IView
103103

104-
constructor(view: ViewData) {
105-
isValidView(view)
104+
constructor(view: IView) {
105+
validateView(view)
106106
this._view = view
107107
}
108108

@@ -193,14 +193,12 @@ export class View implements ViewData {
193193
}
194194

195195
/**
196-
* Typescript cannot validate an interface.
197-
* Please keep in sync with the View interface requirements.
196+
* Validate a view interface to check all required properties are satisfied.
198197
*
199-
* @param {ViewData} view the view to check
200-
* @return {boolean} true if the column is valid
198+
* @param view the view to check
201199
* @throws {Error} if the view is not valid
202200
*/
203-
const isValidView = function(view: ViewData): boolean {
201+
export function validateView(view: IView) {
204202
if (!view.id || typeof view.id !== 'string') {
205203
throw new Error('View id is required and must be a string')
206204
}
@@ -261,6 +259,4 @@ const isValidView = function(view: ViewData): boolean {
261259
if (view.loadChildViews && typeof view.loadChildViews !== 'function') {
262260
throw new Error('View loadChildViews must be a function')
263261
}
264-
265-
return true
266262
}

lib/types.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,25 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { Folder, Node } from './node/index.ts'
7-
import { View } from './navigation/index.ts'
6+
import type { IFolder, INode } from './node/index.ts'
7+
import type { IView } from './navigation/index.ts'
88

99
export type ActionContextSingle = {
10-
nodes: [Node],
11-
view: View,
12-
folder: Folder,
13-
contents: Node[],
10+
nodes: [INode],
11+
view: IView,
12+
folder: IFolder,
13+
contents: INode[],
1414
}
1515

1616
export type ActionContext = {
17-
nodes: Node[],
18-
view: View,
19-
folder: Folder,
20-
contents: Node[],
17+
nodes: INode[],
18+
view: IView,
19+
folder: IFolder,
20+
contents: INode[],
2121
}
2222

2323
export type ViewActionContext = {
24-
view: View,
25-
folder: Folder,
26-
contents: Node[],
24+
view: IView,
25+
folder: IFolder,
26+
contents: INode[],
2727
}

0 commit comments

Comments
 (0)