@brightspace-ui/labs 2.18.0 → 2.19.1

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);