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