1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {classNames} from '../base/classnames'; 17import {hasChildren} from '../base/mithril_utils'; 18 19// Heirachical tree layout with left and right values. 20// Right and left values of the same indentation level are horizontally aligned. 21// Example: 22// foo bar 23// ├ baz qux 24// └ quux corge 25// ├ looong_left aaa 26// └ a bbb 27// grault garply 28 29interface TreeAttrs { 30 // Space delimited class list applied to our tree element. 31 className?: string; 32} 33 34export class Tree implements m.ClassComponent<TreeAttrs> { 35 view({attrs, children}: m.Vnode<TreeAttrs>): m.Children { 36 const {className = ''} = attrs; 37 38 const classes = classNames(className); 39 40 return m('.pf-tree', {class: classes}, children); 41 } 42} 43 44interface TreeNodeAttrs { 45 // Content to display in the left hand column. 46 // If omitted, this side will be blank. 47 left?: m.Children; 48 // Content to display in the right hand column. 49 // If omitted, this side will be left blank. 50 right?: m.Children; 51 // Content to display in the right hand column when the node is collapsed. 52 // If omitted, the value of `right` shall be shown when collapsed instead. 53 // If the node has no children, this value is never shown. 54 summary?: m.Children; 55 // Whether this node is collapsed or not. 56 // If omitted, collapsed state 'uncontrolled' - i.e. controlled internally. 57 collapsed?: boolean; 58 // Whether the node should start collapsed or not, default: false. 59 startsCollapsed?: boolean; 60 loading?: boolean; 61 showCaret?: boolean; 62 // Optional icon to show to the left of the text. 63 // If this node contains children, this icon is ignored. 64 icon?: string; 65 // Called when the collapsed state is changed, mainly used in controlled mode. 66 onCollapseChanged?: (collapsed: boolean, attrs: TreeNodeAttrs) => void; 67} 68 69export class TreeNode implements m.ClassComponent<TreeNodeAttrs> { 70 private collapsed; 71 72 constructor({attrs}: m.CVnode<TreeNodeAttrs>) { 73 this.collapsed = attrs.startsCollapsed ?? false; 74 } 75 76 view(vnode: m.CVnode<TreeNodeAttrs>): m.Children { 77 const { 78 children, 79 attrs, 80 attrs: {left, onCollapseChanged = () => {}}, 81 } = vnode; 82 return [ 83 m( 84 '.pf-tree-node', 85 { 86 class: classNames(this.getClassNameForNode(vnode)), 87 }, 88 m( 89 '.pf-tree-left', 90 m('span.pf-tree-gutter', { 91 onclick: () => { 92 this.collapsed = !this.isCollapsed(vnode); 93 onCollapseChanged(this.collapsed, attrs); 94 }, 95 }), 96 left, 97 ), 98 this.renderRight(vnode), 99 ), 100 hasChildren(vnode) && m('.pf-tree-children', children), 101 ]; 102 } 103 104 private getClassNameForNode(vnode: m.CVnode<TreeNodeAttrs>) { 105 const {loading = false, showCaret = false} = vnode.attrs; 106 if (loading) { 107 return 'pf-loading'; 108 } else if (hasChildren(vnode) || showCaret) { 109 if (this.isCollapsed(vnode)) { 110 return 'pf-collapsed'; 111 } else { 112 return 'pf-expanded'; 113 } 114 } else { 115 return undefined; 116 } 117 } 118 119 private renderRight(vnode: m.CVnode<TreeNodeAttrs>) { 120 const { 121 attrs: {right, summary}, 122 } = vnode; 123 if (hasChildren(vnode) && this.isCollapsed(vnode)) { 124 return m('.pf-tree-right', summary ?? right); 125 } else { 126 return m('.pf-tree-right', right); 127 } 128 } 129 130 private isCollapsed({attrs}: m.Vnode<TreeNodeAttrs>): boolean { 131 // If collapsed is omitted, use our local collapsed state instead. 132 const {collapsed = this.collapsed} = attrs; 133 134 return collapsed; 135 } 136} 137 138export function dictToTreeNodes(dict: {[key: string]: m.Child}): m.Child[] { 139 const children: m.Child[] = []; 140 for (const key of Object.keys(dict)) { 141 if (dict[key] == undefined) { 142 continue; 143 } 144 children.push( 145 m(TreeNode, { 146 left: key, 147 right: dict[key], 148 }), 149 ); 150 } 151 return children; 152} 153 154// Create a flat tree from a POJO 155export function dictToTree(dict: {[key: string]: m.Child}): m.Children { 156 return m(Tree, dictToTreeNodes(dict)); 157} 158interface LazyTreeNodeAttrs { 159 // Same as TreeNode (see above). 160 left?: m.Children; 161 // Same as TreeNode (see above). 162 right?: m.Children; 163 // Same as TreeNode (see above). 164 icon?: string; 165 // Same as TreeNode (see above). 166 summary?: m.Children; 167 // A callback to be called when the TreeNode is expanded, in order to fetch 168 // child nodes. 169 // The callback must return a promise to a function which returns m.Children. 170 // The reason the promise must return a function rather than the actual 171 // children is to avoid storing vnodes between render cycles, which is a bug 172 // in Mithril. 173 fetchData: () => Promise<() => m.Children>; 174 // Whether to unload children on collapse. 175 // Defaults to false, data will be kept in memory until the node is destroyed. 176 unloadOnCollapse?: boolean; 177} 178 179// This component is a TreeNode which only loads child nodes when it's expanded. 180// This allows us to represent huge trees without having to load all the data 181// up front, and even allows us to represent infinite or recursive trees. 182export class LazyTreeNode implements m.ClassComponent<LazyTreeNodeAttrs> { 183 private collapsed: boolean = true; 184 private loading: boolean = false; 185 private renderChildren?: () => m.Children; 186 187 view({attrs}: m.CVnode<LazyTreeNodeAttrs>): m.Children { 188 const { 189 left, 190 right, 191 icon, 192 summary, 193 fetchData, 194 unloadOnCollapse = false, 195 } = attrs; 196 197 return m( 198 TreeNode, 199 { 200 left, 201 right, 202 icon, 203 summary, 204 showCaret: true, 205 loading: this.loading, 206 collapsed: this.collapsed, 207 onCollapseChanged: (collapsed) => { 208 if (collapsed) { 209 if (unloadOnCollapse) { 210 this.renderChildren = undefined; 211 } 212 } else { 213 // Expanding 214 if (this.renderChildren) { 215 this.collapsed = false; 216 } else { 217 this.loading = true; 218 fetchData().then((result) => { 219 this.loading = false; 220 this.collapsed = false; 221 this.renderChildren = result; 222 }); 223 } 224 } 225 this.collapsed = collapsed; 226 }, 227 }, 228 this.renderChildren && this.renderChildren(), 229 ); 230 } 231} 232