@brightspace-ui/labs 2.18.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import '@brightspace-ui/core/components/empty-state/empty-state-simple.js';
|
|
2
|
+
import '@brightspace-ui/core/components/loading-spinner/loading-spinner.js';
|
|
3
|
+
import './tree-selector.js';
|
|
4
|
+
|
|
5
|
+
import { action, computed, decorate, observable } from 'mobx';
|
|
6
|
+
import { css, html, nothing } from 'lit';
|
|
7
|
+
import { LocalizeLabsElement } from '../localize-labs-element.js';
|
|
8
|
+
import { MobxLitElement } from '@adobe/lit-mobx';
|
|
9
|
+
|
|
10
|
+
// node array indices
|
|
11
|
+
export const COURSE_OFFERING = 3;
|
|
12
|
+
|
|
13
|
+
export const includesSearch = (nodeName, searchString) =>
|
|
14
|
+
nodeName.toLowerCase().includes(searchString.toLowerCase());
|
|
15
|
+
|
|
16
|
+
export const startsWithSearch = (nodeName, searchString) =>
|
|
17
|
+
nodeName.toLowerCase().startsWith(searchString.toLowerCase());
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An object that represents org unit node
|
|
21
|
+
* @typedef {Object} OrgUnitNode
|
|
22
|
+
* @property {number} Id
|
|
23
|
+
* @property {string} Name
|
|
24
|
+
* @property {number} Type
|
|
25
|
+
* @property {number[]} Parents - array of org unit ids
|
|
26
|
+
* @property {boolean} IsActive - optional, should be populated if accessed via Tree.isActive property
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export class Tree {
|
|
30
|
+
/**
|
|
31
|
+
* Type to use as the .tree property of a d2l-labs-tree-filter. Mutator methods will
|
|
32
|
+
* trigger re-rendering as needed. Call as new Tree({}) for a default empty tree.
|
|
33
|
+
* NB: this is actually a DAG, not a tree. :)
|
|
34
|
+
* @param {OrgUnitNode[]} [nodes=[]] - Array of OrgUnitNode
|
|
35
|
+
* @param {Number[]} [leafTypes=[]] - TYPE values that cannot be opened
|
|
36
|
+
* @param {Number[]} [invisibleTypes=[]] - TYPE values that should not be rendered
|
|
37
|
+
* @param {Number[]} [selectedIds] - ids to mark selected. Ancestors and descendants will be marked accordingly.
|
|
38
|
+
* @param {Number[]} [ancestorIds] - same as if passed to setAncestorFilter
|
|
39
|
+
* @param {Tree} [oldTree] - tree to copy previous state from (e.g. which nodes are open)
|
|
40
|
+
* @param {Boolean} isDynamic - if true, the tree is assumed to be incomplete, and tree-filter will fire events as needed
|
|
41
|
+
* to request children
|
|
42
|
+
* @param {Map}[extraChildren] - Map from parent node ids to arrays of
|
|
43
|
+
* {Items: OrgUnitNode[], PagingInfo: {HasMoreItems: boolean, Bookmark}}; these will be added to the tree before
|
|
44
|
+
* any selections are applied and the parents marked as populated. Useful for adding cached lookups to a dynamic tree.
|
|
45
|
+
* @param visibilityModifiers - optional kvp where values are functions that map an orgUnitId to a boolean indicating visibility
|
|
46
|
+
* @param searchFn - function that filters nodes when a user hit search button. It takes two params nodeName and searchString
|
|
47
|
+
*/
|
|
48
|
+
constructor({
|
|
49
|
+
nodes = [],
|
|
50
|
+
leafTypes = [],
|
|
51
|
+
invisibleTypes = [],
|
|
52
|
+
selectedIds,
|
|
53
|
+
ancestorIds,
|
|
54
|
+
oldTree,
|
|
55
|
+
isDynamic = false,
|
|
56
|
+
extraChildren,
|
|
57
|
+
visibilityModifiers = {},
|
|
58
|
+
searchFn = includesSearch
|
|
59
|
+
}) {
|
|
60
|
+
this.leafTypes = leafTypes;
|
|
61
|
+
this.invisibleTypes = invisibleTypes;
|
|
62
|
+
this.initialSelectedIds = selectedIds;
|
|
63
|
+
this._nodes = new Map(nodes.map(x => [x.Id, x]));
|
|
64
|
+
this._children = new Map();
|
|
65
|
+
this._ancestors = new Map();
|
|
66
|
+
this._state = new Map((selectedIds ?? []).map(id => [id, 'explicit']));
|
|
67
|
+
this._open = oldTree ? new Set(oldTree.open) : new Set();
|
|
68
|
+
// null for no filter, vs. empty Set() when none match
|
|
69
|
+
this._visible = null;
|
|
70
|
+
this._populated = isDynamic ? new Set() : null;
|
|
71
|
+
|
|
72
|
+
// for dynamic trees; see addNodes
|
|
73
|
+
this._loading = new Set();
|
|
74
|
+
this._hasMore = new Set();
|
|
75
|
+
this._bookmarks = new Map();
|
|
76
|
+
|
|
77
|
+
this._visibilityModifiers = visibilityModifiers;
|
|
78
|
+
this._searchFn = searchFn;
|
|
79
|
+
|
|
80
|
+
// fill in children (parents are provided by the caller, and ancestors will be generated on demand)
|
|
81
|
+
this._updateChildren(this.ids);
|
|
82
|
+
|
|
83
|
+
if (extraChildren) {
|
|
84
|
+
extraChildren.forEach((data, orgUnitId) => {
|
|
85
|
+
this.addNodes(orgUnitId, data.Items, data.PagingInfo.HasMoreItems, data.PagingInfo.Bookmark);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (selectedIds) {
|
|
90
|
+
this.select(selectedIds);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ancestorIds) {
|
|
94
|
+
this.setAncestorFilter(ancestorIds);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get selected() {
|
|
99
|
+
// if selections are set before any nodes are populated, this getter should still work
|
|
100
|
+
if (this._nodes.size === 0) {
|
|
101
|
+
return [...this._state]
|
|
102
|
+
.filter(([, state]) => state === 'explicit')
|
|
103
|
+
.map(([id]) => id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// if there are nodes, only return the root of each selected subtree
|
|
107
|
+
const selected = new Set([
|
|
108
|
+
...this._getSelected(this.rootId),
|
|
109
|
+
...(this.initialSelectedIds ?? [])
|
|
110
|
+
// if id has node then _getSelected correctly counts it
|
|
111
|
+
.filter(id => !this._nodes.has(id))
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
return [...selected];
|
|
115
|
+
}
|
|
116
|
+
set selected(ids) {
|
|
117
|
+
this.clearSelection();
|
|
118
|
+
this.select(ids);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get allSelectedCourses() {
|
|
122
|
+
const selected = [...this._state]
|
|
123
|
+
.filter(([, state]) => state === 'explicit')
|
|
124
|
+
.map(([id]) => id);
|
|
125
|
+
return selected.filter(id => this.getType(id) === 3);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get ids() {
|
|
129
|
+
return [...this._nodes.keys()];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get isDynamic() {
|
|
133
|
+
return !!this._populated;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get open() {
|
|
137
|
+
return [...this._open];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get rootId() {
|
|
141
|
+
if (!this._rootId) {
|
|
142
|
+
this._rootId = this.ids.find(x => this._isRoot(x));
|
|
143
|
+
}
|
|
144
|
+
return this._rootId;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Adds nodes as children of the given parent. New nodes will be selected if the parent is.
|
|
149
|
+
* The parents of the new nodes will be set to the given parent plus any previous parents (if the node
|
|
150
|
+
* was already in the tree). The new nodes are assumed to match the ancestorFilter, if any;
|
|
151
|
+
* future changes to that filter are not supported (i.e. it is assumed the caller will reload data
|
|
152
|
+
* and create a new tree in that case). See also note on setAncestorFilter().
|
|
153
|
+
* @param {number} parentId The parent of the new nodes. The new nodes supplement any existing children.
|
|
154
|
+
* @param newChildren Array of nodes to be added to the tree; name and type will be updated if the id already exists.
|
|
155
|
+
* @param hasMore - if true, the node is not considered fully populated
|
|
156
|
+
* @param bookmark - Opaque data that will be stored with the parent if hasMore is true (or cleared if hasMore is falsy)
|
|
157
|
+
*/
|
|
158
|
+
addNodes(parentId, newChildren, hasMore, bookmark) {
|
|
159
|
+
this._loading.delete(parentId);
|
|
160
|
+
|
|
161
|
+
if (hasMore) {
|
|
162
|
+
this._hasMore.add(parentId);
|
|
163
|
+
this._bookmarks.set(parentId, bookmark);
|
|
164
|
+
} else {
|
|
165
|
+
this._hasMore.delete(parentId);
|
|
166
|
+
this._bookmarks.delete(parentId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// add parentId to any existing parents of these nodes (before replacing the nodes and losing this info)
|
|
170
|
+
newChildren.forEach(x => {
|
|
171
|
+
const existingParents = this.getParentIds(x.Id);
|
|
172
|
+
const allParents = new Set([parentId, ...existingParents]);
|
|
173
|
+
x.Parents = [...allParents];
|
|
174
|
+
});
|
|
175
|
+
newChildren.forEach(x => this._nodes.set(x.Id, x));
|
|
176
|
+
|
|
177
|
+
// merge the new children in to the parent
|
|
178
|
+
this._children.set(parentId, new Set([...newChildren.map(x => x.Id), ...this.getChildIds(parentId)]));
|
|
179
|
+
|
|
180
|
+
// caller should only provide visible nodes
|
|
181
|
+
if (this._visible) {
|
|
182
|
+
newChildren.forEach(x => this._visible.add(x.Id));
|
|
183
|
+
}
|
|
184
|
+
if (this.getState(parentId) === 'explicit') {
|
|
185
|
+
newChildren.forEach(x => this._state.set(x.Id, 'explicit'));
|
|
186
|
+
} else {
|
|
187
|
+
const initialSelectedIdSet = new Set(this.initialSelectedIds);
|
|
188
|
+
if (newChildren.some(x => initialSelectedIdSet.has(x.Id))) {
|
|
189
|
+
this._updateSelected(parentId);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ancestors may need updating: if one or more of newChildren was already present (due to
|
|
194
|
+
// being added under another parent), then they may also have been opened and have descendants,
|
|
195
|
+
// which now need a new ancestor.
|
|
196
|
+
// For simplicity and correctness, we simply reset the ancestors map, which will be
|
|
197
|
+
// regenerated as needed by getAncestorIds.
|
|
198
|
+
this._ancestors = new Map();
|
|
199
|
+
|
|
200
|
+
if (this._populated) {
|
|
201
|
+
this._populated.add(parentId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Merges the given nodes into the tree.
|
|
207
|
+
* @param {[][]} [nodes=[]] - Array of arrays, including all ancestors up to root, in the same format as for the constructor
|
|
208
|
+
*/
|
|
209
|
+
addTree(nodes) {
|
|
210
|
+
nodes.forEach(x => this._nodes.set(x.Id, x));
|
|
211
|
+
|
|
212
|
+
// invariant: the tree must always contain all ancestors of all nodes.
|
|
213
|
+
// This means existing nodes cannot be new children of any node: we only need to update children for parents of new nodes.
|
|
214
|
+
this._updateChildren(nodes.map(x => x.Id));
|
|
215
|
+
|
|
216
|
+
// Set selected state for ancestors and descendants if a new node should be selected because its
|
|
217
|
+
// parent is.
|
|
218
|
+
// This could perform poorly if the tree being merged in is large and deep, but in the expected use case
|
|
219
|
+
// (search with load more), we should only be adding a handful of nodes at a time.
|
|
220
|
+
nodes.forEach(node => {
|
|
221
|
+
if (node.Parents.some(parentId => this.getState(parentId) === 'explicit')) {
|
|
222
|
+
this.setSelected(node.Id, true);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// caller should only provide visible nodes
|
|
227
|
+
if (this._visible) {
|
|
228
|
+
nodes.forEach(x => this._visible.add(x.Id));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// refresh ancestors
|
|
232
|
+
this._ancestors = new Map();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
clearSelection() {
|
|
236
|
+
this._state.clear();
|
|
237
|
+
// when we hit Clear button we expect that all selection is cleared
|
|
238
|
+
// including selection in the dynamic tree with invisible selected nodes
|
|
239
|
+
this.initialSelectedIds = [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
getAncestorIds(id) {
|
|
243
|
+
if (id === 0) return new Set();
|
|
244
|
+
|
|
245
|
+
if (!this._ancestors.has(id)) {
|
|
246
|
+
const ancestors = new Set([
|
|
247
|
+
id,
|
|
248
|
+
...this.getParentIds(id).flatMap(x => [...this.getAncestorIds(x)])
|
|
249
|
+
]);
|
|
250
|
+
this._ancestors.set(id, ancestors);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return this._ancestors.get(id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
getBookmark(id) {
|
|
257
|
+
return this._bookmarks.get(id);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getChildIds(id) {
|
|
261
|
+
if (!id) id = this.rootId;
|
|
262
|
+
if (!id) return [];
|
|
263
|
+
const children = this._children.get(id);
|
|
264
|
+
return children ? [...children] : [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getChildIdsForDisplay(id, pruning) {
|
|
268
|
+
const children = this.getChildIds(id).filter(x => this._isVisible(x));
|
|
269
|
+
|
|
270
|
+
const isPruning = !this.isDynamic
|
|
271
|
+
&& (pruning || this._isRoot(id))
|
|
272
|
+
&& children.length === 1
|
|
273
|
+
&& this.getType(children[0]) !== COURSE_OFFERING;
|
|
274
|
+
if (isPruning) return this.getChildIdsForDisplay(children[0], true);
|
|
275
|
+
|
|
276
|
+
return children.sort((a, b) => this._nameForSort(a).localeCompare(this._nameForSort(b)));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* NB: for the purposes of this function, a node is its own descendant
|
|
281
|
+
* @param {Number} id
|
|
282
|
+
* @returns {Set<Number>}
|
|
283
|
+
*/
|
|
284
|
+
getDescendantIds(id) {
|
|
285
|
+
const children = this._children.get(id);
|
|
286
|
+
if (!children || !children.size) {
|
|
287
|
+
return new Set([id]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const descendants = new Set([...children].flatMap(child => [...this.getDescendantIds(child)]));
|
|
291
|
+
descendants.add(id);
|
|
292
|
+
return descendants;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
getMatchingIds(searchString) {
|
|
296
|
+
return this.ids
|
|
297
|
+
.filter(x => this._isVisible(x))
|
|
298
|
+
.filter(x => !this._isRoot(x) && this._searchFn(this._nameForSort(x), searchString))
|
|
299
|
+
// reverse order by id so the order is consistent and (most likely) newer items are on top
|
|
300
|
+
.sort((x, y) => y - x);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
getName(id) {
|
|
304
|
+
const node = this._nodes.get(id);
|
|
305
|
+
return (node && node.Name) || '';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getParentIds(id) {
|
|
309
|
+
const node = this._nodes.get(id);
|
|
310
|
+
return (node && node.Parents) || [];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
getState(id) {
|
|
314
|
+
return this._state.get(id) || 'none';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getType(id) {
|
|
318
|
+
const node = this._nodes.get(id);
|
|
319
|
+
return (node && node.Type) || 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Checks if a node has ancestors in a given list.
|
|
324
|
+
* NB: returns true if an id is itself is in the list to check
|
|
325
|
+
* @param {Number} id - the node whose ancestors we want to check
|
|
326
|
+
* @param {[Number]} listToCheck - an array of node ids which potentially has ancestors in it
|
|
327
|
+
* @returns {boolean}
|
|
328
|
+
*/
|
|
329
|
+
hasAncestorsInList(id, listToCheck) {
|
|
330
|
+
const ancestorsSet = this.getAncestorIds(id);
|
|
331
|
+
|
|
332
|
+
return listToCheck.some(potentialAncestor => ancestorsSet.has(potentialAncestor));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* NB: returns true if an id is itself is in the list to check
|
|
337
|
+
* @param {Number} id
|
|
338
|
+
* @param {[Number]} listToCheck
|
|
339
|
+
* @returns {boolean}
|
|
340
|
+
*/
|
|
341
|
+
hasDescendantsInList(id, listToCheck) {
|
|
342
|
+
const descendants = this.getDescendantIds(id);
|
|
343
|
+
const listToCheckUnique = [...new Set(listToCheck)];
|
|
344
|
+
return listToCheckUnique.some(potentialDescendant => descendants.has(potentialDescendant));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
hasMore(id) {
|
|
348
|
+
return this._hasMore.has(id);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
isActive(id) {
|
|
352
|
+
const node = this._nodes.get(id);
|
|
353
|
+
return (node && node.IsActive) || false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
isLoading(id) {
|
|
357
|
+
return this._loading.has(id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
isOpen(id) {
|
|
361
|
+
return this._open.has(id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
isOpenable(id) {
|
|
365
|
+
return !this.leafTypes.includes(this.getType(id));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* True iff the children of id are known (even if there are zero children).
|
|
370
|
+
* @param id
|
|
371
|
+
* @returns {boolean}
|
|
372
|
+
*/
|
|
373
|
+
isPopulated(id) {
|
|
374
|
+
return !this._populated || this._populated.has(id);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
removeVisibilityModifier(key) {
|
|
378
|
+
this._visibilityModifiers = Object.fromEntries(Object.entries(this._visibilityModifiers).filter(kvp => kvp[0] !== key));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
select(ids) {
|
|
382
|
+
ids.forEach(x => this.setSelected(x, true));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
selectAll() {
|
|
386
|
+
this.setSelected(this.rootId, true);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Filters the visible tree to nodes which are ancestors of nodes descended from the given ids
|
|
391
|
+
* (a node is its own ancestor).
|
|
392
|
+
* NB: ignored if the tree is dynamic, so that dynamically loaded partial trees don't get
|
|
393
|
+
* hidden due to missing information. It is expected that dynamic trees only include visible
|
|
394
|
+
* nodes, and that the tree will be replaced if the ancestor filter should change.
|
|
395
|
+
* @param {Number[]} ancestorIds
|
|
396
|
+
*/
|
|
397
|
+
setAncestorFilter(ancestorIds) {
|
|
398
|
+
if (this.isDynamic) return;
|
|
399
|
+
|
|
400
|
+
if (!ancestorIds || ancestorIds.length === 0) {
|
|
401
|
+
this._visible = null;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this._visible = new Set();
|
|
406
|
+
|
|
407
|
+
this.ids.forEach(id => {
|
|
408
|
+
if (this.hasAncestorsInList(id, ancestorIds)) {
|
|
409
|
+
this._visible.add(id);
|
|
410
|
+
this.getAncestorIds(id).forEach(ancestorId => this._visible.add(ancestorId));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
setLoading(id) {
|
|
416
|
+
this._loading.add(id);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
setOpen(id, isOpen) {
|
|
420
|
+
if (isOpen) {
|
|
421
|
+
this._open.add(id);
|
|
422
|
+
} else {
|
|
423
|
+
this._open.delete(id);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
setSelected(id, isSelected) {
|
|
428
|
+
// clicking on a node either fully selects or fully deselects its entire subtree
|
|
429
|
+
this._setSubtreeSelected(id, isSelected);
|
|
430
|
+
|
|
431
|
+
// parents may now be in any state, depending on siblings
|
|
432
|
+
this.getParentIds(id).forEach(parentId => this._updateSelected(parentId));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
setVisibilityModifier(key, visibilityModFn) {
|
|
436
|
+
const modifiersCopy = { ...this._visibilityModifiers };
|
|
437
|
+
modifiersCopy[key] = visibilityModFn;
|
|
438
|
+
this._visibilityModifiers = modifiersCopy;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_getSelected(id) {
|
|
442
|
+
const state = this.getState(id);
|
|
443
|
+
|
|
444
|
+
if (state === 'explicit') return [id];
|
|
445
|
+
|
|
446
|
+
if (state === 'indeterminate' || this._isRoot(id)) {
|
|
447
|
+
// when this.getChildIds(id) returns null as one of its child id it causes infinite loop
|
|
448
|
+
const isNotNullOrUndefined = (id) => id !== undefined && id !== null;
|
|
449
|
+
return this.getChildIds(id).filter(isNotNullOrUndefined).flatMap(childId => this._getSelected(childId));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_isRoot(id) {
|
|
456
|
+
return this.getParentIds(id).includes(0);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_isVisible(id) {
|
|
460
|
+
const visible = (this._visible === null || this._visible.has(id))
|
|
461
|
+
&& !this.invisibleTypes.includes(this.getType(id));
|
|
462
|
+
|
|
463
|
+
return visible && Object.values(this._visibilityModifiers).every(modifier => modifier(id)); // every returns true if the array is empty
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_nameForSort(id) {
|
|
467
|
+
return this.getName(id) + id;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_setSubtreeSelected(id, isSelected) {
|
|
471
|
+
if (isSelected) {
|
|
472
|
+
this._state.set(id, 'explicit');
|
|
473
|
+
} else {
|
|
474
|
+
this._state.delete(id);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this.getChildIds(id).forEach(childId => this._setSubtreeSelected(childId, isSelected));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
_updateChildren(ids) {
|
|
481
|
+
ids.forEach(id => {
|
|
482
|
+
this.getParentIds(id).forEach(parentId => {
|
|
483
|
+
if (this._children.has(parentId)) {
|
|
484
|
+
this._children.get(parentId).add(id);
|
|
485
|
+
} else {
|
|
486
|
+
this._children.set(parentId, new Set([id]));
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
_updateSelected(id) {
|
|
493
|
+
// never select the root (user can clear the selection instead)
|
|
494
|
+
if (this._isRoot(id)) return;
|
|
495
|
+
|
|
496
|
+
// don't select invisible node types
|
|
497
|
+
if (this.invisibleTypes.includes(this.getType(id))) return;
|
|
498
|
+
|
|
499
|
+
// Only consider children of visible types: this node is selected if all potentially visible children are
|
|
500
|
+
// Note that if this node hasn't been populated, we don't know if all children are selected,
|
|
501
|
+
// so it is indeterminate at most.
|
|
502
|
+
const childIds = this.getChildIds(id).filter(x => !this.invisibleTypes.includes(this.getType(x)));
|
|
503
|
+
const state = (this.isPopulated(id) && !this.hasMore(id) && childIds.every(childId => this.getState(childId) === 'explicit'))
|
|
504
|
+
? 'explicit'
|
|
505
|
+
: childIds.every(childId => this.getState(childId) === 'none')
|
|
506
|
+
? 'none'
|
|
507
|
+
: 'indeterminate' ;
|
|
508
|
+
|
|
509
|
+
if (state === 'none') {
|
|
510
|
+
this._state.delete(id);
|
|
511
|
+
} else {
|
|
512
|
+
this._state.set(id, state);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
this.getParentIds(id).forEach(x => this._updateSelected(x));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
decorate(Tree, {
|
|
520
|
+
_nodes: observable,
|
|
521
|
+
_children: observable,
|
|
522
|
+
_ancestors: observable,
|
|
523
|
+
_state: observable,
|
|
524
|
+
_open: observable,
|
|
525
|
+
_visible: observable,
|
|
526
|
+
_populated: observable,
|
|
527
|
+
_loading: observable,
|
|
528
|
+
_bookmarks: observable,
|
|
529
|
+
_hasMore: observable,
|
|
530
|
+
_visibilityModifiers: observable,
|
|
531
|
+
initialSelectedIds: observable,
|
|
532
|
+
selected: computed,
|
|
533
|
+
allSelectedCourses: computed,
|
|
534
|
+
addNodes: action,
|
|
535
|
+
clearSelection: action,
|
|
536
|
+
selectAll: action,
|
|
537
|
+
select: action,
|
|
538
|
+
setAncestorFilter: action,
|
|
539
|
+
setLoading: action,
|
|
540
|
+
setOpen: action,
|
|
541
|
+
setSelected: action
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* This is an opinionated wrapper around d2l-labs-tree-selector which maintains state
|
|
546
|
+
* in the above Tree class.
|
|
547
|
+
* @property {Object} tree - a Tree (defined above)
|
|
548
|
+
* @property {String} openerText - appears on the dropdown opener if no items are selected
|
|
549
|
+
* @property {String} openerTextSelected - appears on the dropdown opener if one or more items are selected
|
|
550
|
+
* @fires d2l-labs-tree-filter-select - selection has changed; selected property of this element is the list of selected ids
|
|
551
|
+
* @fires d2l-labs-tree-filter-request-children - (dynamic tree only) owner should call tree.addNodes with children of event.detail.id
|
|
552
|
+
* @fires d2l-labs-tree-filter-search - (dynamic tree only) owner may call this.addSearchResults with nodes and ancestors matching
|
|
553
|
+
* event.detail.searchString and event.detail.bookmark (arbitrary data previously passed to this.addSearchResults)
|
|
554
|
+
*/
|
|
555
|
+
class TreeFilter extends LocalizeLabsElement(MobxLitElement) {
|
|
556
|
+
|
|
557
|
+
static get properties() {
|
|
558
|
+
return {
|
|
559
|
+
tree: { type: Object, attribute: false },
|
|
560
|
+
openerText: { type: String, attribute: 'opener-text' },
|
|
561
|
+
openerTextSelected: { type: String, attribute: 'opener-text-selected' },
|
|
562
|
+
searchString: { type: String, attribute: 'search-string', reflect: true },
|
|
563
|
+
isLoadMoreSearch: { type: Boolean, attribute: 'load-more-search', reflect: true },
|
|
564
|
+
isSelectAllVisible: { type: Boolean, attribute: 'select-all-ui', reflect: true },
|
|
565
|
+
disabled: { type: Boolean, attribute: 'disabled' },
|
|
566
|
+
_isLoadingSearch: { type: Boolean, attribute: false }
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
static get styles() {
|
|
571
|
+
return css`
|
|
572
|
+
:host {
|
|
573
|
+
display: inline-block;
|
|
574
|
+
}
|
|
575
|
+
:host([hidden]) {
|
|
576
|
+
display: none;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
d2l-button.d2l-tree-load-more {
|
|
580
|
+
padding-bottom: 12px;
|
|
581
|
+
}
|
|
582
|
+
`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
constructor() {
|
|
586
|
+
super();
|
|
587
|
+
|
|
588
|
+
this.openerText = 'MISSING NAME';
|
|
589
|
+
this.openerTextSelected = 'MISSING NAME';
|
|
590
|
+
this.searchString = '';
|
|
591
|
+
this.isLoadMoreSearch = false;
|
|
592
|
+
this.isSelectAllVisible = false;
|
|
593
|
+
this.disabled = false;
|
|
594
|
+
|
|
595
|
+
this._needResize = false;
|
|
596
|
+
this._searchBookmark = null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
get selected() {
|
|
600
|
+
return this.tree.selected;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @returns {Promise} - resolves when all tree-selector-nodes, recursively, have finished updating
|
|
605
|
+
*/
|
|
606
|
+
get treeUpdateComplete() {
|
|
607
|
+
return this.updateComplete.then(() => this.shadowRoot?.querySelector('d2l-labs-tree-selector').treeUpdateComplete || false);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
render() {
|
|
611
|
+
// if selections are applied when loading from server but the selected ids were truncated out of the results,
|
|
612
|
+
// the visible selections in the UI (this.tree.selected) could be empty even though selections are applied.
|
|
613
|
+
// In that case, we should indicate to the user that selections are applied, even if they can't see them.
|
|
614
|
+
const isSelected = (this.tree.selected.length || (this.tree.initialSelectedIds && this.tree.initialSelectedIds.length));
|
|
615
|
+
const openerText = isSelected ? this.openerTextSelected : this.openerText;
|
|
616
|
+
|
|
617
|
+
return html`<d2l-labs-tree-selector
|
|
618
|
+
class="vdiff-target"
|
|
619
|
+
name="${openerText}"
|
|
620
|
+
?search="${this._isSearch}"
|
|
621
|
+
?selected="${isSelected}"
|
|
622
|
+
?select-all-ui="${this.isSelectAllVisible}"
|
|
623
|
+
?disabled="${this.disabled}"
|
|
624
|
+
@d2l-labs-tree-selector-search="${this._onSearch}"
|
|
625
|
+
@d2l-labs-tree-selector-clear="${this._onClear}"
|
|
626
|
+
@d2l-labs-tree-selector-select-all="${this._onSelectAll}"
|
|
627
|
+
>
|
|
628
|
+
${this._renderSearchResults()}
|
|
629
|
+
${this._renderSearchLoadingControls()}
|
|
630
|
+
${this._renderChildren(this.tree.rootId)}
|
|
631
|
+
</d2l-labs-tree-selector>
|
|
632
|
+
</div>`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async updated() {
|
|
636
|
+
if (!this._needResize) return;
|
|
637
|
+
|
|
638
|
+
await this.resize();
|
|
639
|
+
this._needResize = false;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Adds the given children to the given parent. See Tree.addNodes().
|
|
644
|
+
* @param parent
|
|
645
|
+
* @param children
|
|
646
|
+
* @param hasMore - Will display a "load more" button in the tree if true
|
|
647
|
+
* @param bookmark - Opaque data that will be sent in the request-children event if the user asks to load more results
|
|
648
|
+
*/
|
|
649
|
+
addChildren(parent, children, hasMore, bookmark) {
|
|
650
|
+
this._needResize = true;
|
|
651
|
+
this.tree.addNodes(parent, children, hasMore, bookmark);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Merges the given nodes into the tree and may display a load more control.
|
|
656
|
+
* @param {[][]} [nodes=[]] - Array of arrays, including all ancestors up to root, in the same format as for the constructor
|
|
657
|
+
* @param {Boolean}hasMore - Will display a "load more" button in the search if true
|
|
658
|
+
* @param {Object}bookmark - Opaque data that will be sent in the search event if the user asks to load more results
|
|
659
|
+
*/
|
|
660
|
+
addSearchResults(nodes, hasMore, bookmark) {
|
|
661
|
+
this._needResize = true;
|
|
662
|
+
this.tree.addTree(nodes);
|
|
663
|
+
this.isLoadMoreSearch = hasMore;
|
|
664
|
+
this._searchBookmark = bookmark;
|
|
665
|
+
this._isLoadingSearch = false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
clearSearchAndSelection(generateEvent = true) {
|
|
669
|
+
this.shadowRoot.querySelector('d2l-labs-tree-selector').clearSearchAndSelection(generateEvent);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async resize() {
|
|
673
|
+
await this.updateComplete;
|
|
674
|
+
const treeSelector = this.shadowRoot?.querySelector('d2l-labs-tree-selector');
|
|
675
|
+
treeSelector && treeSelector.resize();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
get _isSearch() {
|
|
679
|
+
return this.searchString.length > 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
_fireSearchEvent(searchString, bookmark) {
|
|
683
|
+
if (!searchString) return;
|
|
684
|
+
|
|
685
|
+
this._isLoadingSearch = true;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* @event d2l-labs-tree-filter-search
|
|
689
|
+
*/
|
|
690
|
+
this.dispatchEvent(new CustomEvent(
|
|
691
|
+
'd2l-labs-tree-filter-search',
|
|
692
|
+
{
|
|
693
|
+
bubbles: true,
|
|
694
|
+
composed: false,
|
|
695
|
+
detail: { searchString, bookmark }
|
|
696
|
+
}
|
|
697
|
+
));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
_fireSelectEvent() {
|
|
701
|
+
/**
|
|
702
|
+
* @event d2l-labs-tree-filter-select
|
|
703
|
+
*/
|
|
704
|
+
this.dispatchEvent(new CustomEvent(
|
|
705
|
+
'd2l-labs-tree-filter-select',
|
|
706
|
+
{ bubbles: true, composed: false }
|
|
707
|
+
));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
_onClear(event) {
|
|
711
|
+
event.stopPropagation();
|
|
712
|
+
this.tree.clearSelection();
|
|
713
|
+
this._fireSelectEvent();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
_onOpen(event) {
|
|
717
|
+
event.stopPropagation();
|
|
718
|
+
this._needResize = true;
|
|
719
|
+
this.tree.setOpen(event.detail.id, event.detail.isOpen);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_onParentLoadMore(event) {
|
|
723
|
+
const id = Number(event.target.getAttribute('data-id'));
|
|
724
|
+
const bookmark = this.tree.getBookmark(id);
|
|
725
|
+
this._requestChildren(id, bookmark);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
_onSearch(event) {
|
|
729
|
+
event.stopPropagation();
|
|
730
|
+
this._needResize = true;
|
|
731
|
+
this.searchString = event.detail.value;
|
|
732
|
+
|
|
733
|
+
if (this.tree.isDynamic) {
|
|
734
|
+
this._fireSearchEvent(this.searchString);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
_onSearchLoadMore(event) {
|
|
739
|
+
event.stopPropagation();
|
|
740
|
+
this._fireSearchEvent(this.searchString, this._searchBookmark);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
_onSelect(event) {
|
|
744
|
+
event.stopPropagation();
|
|
745
|
+
this.tree.setSelected(event.detail.id, event.detail.isSelected);
|
|
746
|
+
this._fireSelectEvent();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_onSelectAll(event) {
|
|
750
|
+
event.stopPropagation();
|
|
751
|
+
this.tree.selectAll();
|
|
752
|
+
this._fireSelectEvent();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
_renderChildren(id, parentName, indentLevel = 0) {
|
|
756
|
+
parentName = parentName || this.localize('components:ouFilter:treeFilter:nodeName:root');
|
|
757
|
+
|
|
758
|
+
if (id === undefined || this.tree.getChildIdsForDisplay(id).length === 0) {
|
|
759
|
+
return html`<d2l-empty-state-simple
|
|
760
|
+
slot="tree"
|
|
761
|
+
description="${this.localize('components:ouFilter:treeSelector:noFiltersAvailable')}"
|
|
762
|
+
>
|
|
763
|
+
</d2l-empty-state-simple>`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!this.tree.isPopulated(id)) {
|
|
767
|
+
// request children; in the meantime we can render whatever we have
|
|
768
|
+
this._requestChildren(id);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return [
|
|
772
|
+
...this.tree
|
|
773
|
+
.getChildIdsForDisplay(id)
|
|
774
|
+
.map(id => this._renderNode(id, parentName, indentLevel + 1)),
|
|
775
|
+
|
|
776
|
+
this._renderParentLoadingControls(id)
|
|
777
|
+
];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
_renderNode(id, parentName, indentLevel) {
|
|
781
|
+
const isOpen = this.tree.isOpen(id);
|
|
782
|
+
const isOpenable = this.tree.isOpenable(id);
|
|
783
|
+
const orgUnitName = this.tree.getName(id);
|
|
784
|
+
const state = this.tree.getState(id);
|
|
785
|
+
return html`<d2l-labs-tree-selector-node slot="tree"
|
|
786
|
+
name="${this.localize('components:ouFilter:treeFilter:nodeName', { orgUnitName, id })}"
|
|
787
|
+
data-id="${id}"
|
|
788
|
+
?openable="${isOpenable}"
|
|
789
|
+
?open="${isOpen}"
|
|
790
|
+
selected-state="${state}"
|
|
791
|
+
indent-level="${indentLevel}"
|
|
792
|
+
parent-name="${parentName}"
|
|
793
|
+
@d2l-labs-tree-selector-node-open="${this._onOpen}"
|
|
794
|
+
@d2l-labs-tree-selector-node-select="${this._onSelect}"
|
|
795
|
+
>
|
|
796
|
+
${isOpen ? this._renderChildren(id, orgUnitName, indentLevel) : ''}
|
|
797
|
+
</d2l-labs-tree-selector-node>`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
_renderParentLoadingControls(id) {
|
|
801
|
+
if (this.tree.isLoading(id)) {
|
|
802
|
+
return html`<d2l-loading-spinner slot="tree"></d2l-loading-spinner>`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (this.tree.hasMore(id)) {
|
|
806
|
+
return html`<d2l-button slot="tree"
|
|
807
|
+
class="d2l-tree-load-more"
|
|
808
|
+
@click="${this._onParentLoadMore}"
|
|
809
|
+
data-id="${id}"
|
|
810
|
+
description="${this.localize('components:ouFilter:treeSelector:parentLoadMore:ariaLabel')}"
|
|
811
|
+
>${this.localize('components:ouFilter:treeSelector:loadMoreLabel')}</d2l-button>`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return nothing;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
_renderSearchLoadingControls() {
|
|
818
|
+
if (!this._isSearch) return nothing;
|
|
819
|
+
|
|
820
|
+
if (this._isLoadingSearch) {
|
|
821
|
+
return html`<d2l-loading-spinner slot="search-results"></d2l-loading-spinner>`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (this.isLoadMoreSearch) {
|
|
825
|
+
return html`<d2l-button slot="search-results"
|
|
826
|
+
@click="${this._onSearchLoadMore}"
|
|
827
|
+
description="${this.localize('components:ouFilter:treeSelector:searchLoadMore:ariaLabel')}"
|
|
828
|
+
>${this.localize('components:ouFilter:treeSelector:loadMoreLabel')}</d2l-button>`;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
_renderSearchResults() {
|
|
833
|
+
if (!this._isSearch || this._isLoadingSearch) return nothing;
|
|
834
|
+
|
|
835
|
+
const searchResults = this.tree
|
|
836
|
+
.getMatchingIds(this.searchString);
|
|
837
|
+
|
|
838
|
+
if (searchResults.length > 0) {
|
|
839
|
+
return searchResults.map(id => {
|
|
840
|
+
const orgUnitName = this.tree.getName(id);
|
|
841
|
+
const state = this.tree.getState(id);
|
|
842
|
+
return html`<d2l-labs-tree-selector-node slot="search-results"
|
|
843
|
+
name="${this.localize('components:ouFilter:treeFilter:nodeName', { orgUnitName, id })}"
|
|
844
|
+
data-id="${id}"
|
|
845
|
+
selected-state="${state}"
|
|
846
|
+
@d2l-labs-tree-selector-node-select="${this._onSelect}"
|
|
847
|
+
>
|
|
848
|
+
</d2l-labs-tree-selector-node>`;
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return html`<d2l-empty-state-simple
|
|
853
|
+
slot="search-results"
|
|
854
|
+
description="${this.localize('components:ouFilter:treeSelector:noSearchResults')}"
|
|
855
|
+
>
|
|
856
|
+
</d2l-empty-state-simple>`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
_requestChildren(id, bookmark) {
|
|
860
|
+
this.tree.setLoading(id);
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* @event d2l-labs-tree-filter-request-children
|
|
864
|
+
*/
|
|
865
|
+
this.dispatchEvent(new CustomEvent(
|
|
866
|
+
'd2l-labs-tree-filter-request-children',
|
|
867
|
+
{
|
|
868
|
+
bubbles: true,
|
|
869
|
+
composed: false,
|
|
870
|
+
detail: { id, bookmark }
|
|
871
|
+
}
|
|
872
|
+
));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
customElements.define('d2l-labs-tree-filter', TreeFilter);
|