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