1import m from 'mithril'; 2 3import {classNames} from '../classnames'; 4import {globals} from '../globals'; 5 6import {Button} from './button'; 7import {Spinner} from './spinner'; 8import {hasChildren} from './utils'; 9 10export enum TreeLayout { 11 // Classic heirachical tree layout with no columnar alignment. 12 // Example: 13 // foo: bar 14 // ├ baz: qux 15 // └ quux: corge 16 // grault: garply 17 Tree = 'tree', 18 19 // Heirachical tree layout but right values are horizontally aligned. 20 // Example: 21 // foo bar 22 // ├ baz qux 23 // └ quux corge 24 // grault garply 25 Grid = 'grid', 26} 27 28interface TreeAttrs { 29 // The style of layout. 30 // Defaults to grid. 31 layout?: TreeLayout; 32 // Space delimited class list applied to our tree element. 33 className?: string; 34} 35 36export class Tree implements m.ClassComponent<TreeAttrs> { 37 view({attrs, children}: m.Vnode<TreeAttrs>): m.Children { 38 const { 39 layout: style = TreeLayout.Grid, 40 className = '', 41 } = attrs; 42 43 if (style === TreeLayout.Grid) { 44 return m('.pf-ptree-grid', {class: className}, children); 45 } else if (style === TreeLayout.Tree) { 46 return m('.pf-ptree', {class: className}, children); 47 } else { 48 return null; 49 } 50 } 51} 52 53interface TreeNodeAttrs { 54 // Content to display in the left hand column. 55 // If omitted, this side will be blank. 56 left?: m.Children; 57 // Content to display in the right hand column. 58 // If omitted, this side will be left blank. 59 right?: m.Children; 60 // Content to display in the right hand column when the node is collapsed. 61 // If omitted, the value of `right` shall be shown when collapsed instead. 62 // If the node has no children, this value is never shown. 63 summary?: m.Children; 64 // Whether this node is collapsed or not. 65 // If omitted, collapsed state 'uncontrolled' - i.e. controlled internally. 66 collapsed?: boolean; 67 // Called when the collapsed state is changed, mainly used in controlled mode. 68 onCollapseChanged?: (collapsed: boolean, attrs: TreeNodeAttrs) => void; 69} 70 71export class TreeNode implements m.ClassComponent<TreeNodeAttrs> { 72 private collapsed = false; 73 view(vnode: m.CVnode<TreeNodeAttrs>): m.Children { 74 return [ 75 m( 76 '.pf-tree-node', 77 this.renderLeft(vnode), 78 this.renderRight(vnode), 79 ), 80 hasChildren(vnode) && this.renderChildren(vnode), 81 ]; 82 } 83 84 private renderLeft(vnode: m.CVnode<TreeNodeAttrs>) { 85 const { 86 attrs: {left}, 87 } = vnode; 88 89 return m( 90 '.pf-tree-left', 91 left, 92 hasChildren(vnode) && this.renderCollapseButton(vnode), 93 ); 94 } 95 96 private renderRight(vnode: m.CVnode<TreeNodeAttrs>) { 97 const {attrs: {right, summary}} = vnode; 98 if (hasChildren(vnode) && this.isCollapsed(vnode)) { 99 return m('.pf-tree-right', summary ?? right); 100 } else { 101 return m('.pf-tree-right', right); 102 } 103 } 104 105 private renderChildren(vnode: m.CVnode<TreeNodeAttrs>) { 106 const {children} = vnode; 107 108 return m( 109 '.pf-tree-children', 110 { 111 class: classNames(this.isCollapsed(vnode) && 'pf-pgrid-hidden'), 112 }, 113 children, 114 ); 115 } 116 117 private renderCollapseButton(vnode: m.Vnode<TreeNodeAttrs>) { 118 const {attrs, attrs: {onCollapseChanged = () => {}}} = vnode; 119 120 return m(Button, { 121 icon: this.isCollapsed(vnode) ? 'chevron_right' : 'expand_more', 122 minimal: true, 123 compact: true, 124 onclick: () => { 125 this.collapsed = !this.isCollapsed(vnode); 126 onCollapseChanged(this.collapsed, attrs); 127 globals.rafScheduler.scheduleFullRedraw(); 128 }, 129 }); 130 } 131 132 private isCollapsed({attrs}: m.Vnode<TreeNodeAttrs>): boolean { 133 // If collapsed is omitted, use our local collapsed state instead. 134 const { 135 collapsed = this.collapsed, 136 } = attrs; 137 138 return collapsed; 139 } 140} 141 142export function dictToTree(dict: {[key: string]: m.Child}): m.Children { 143 const children: m.Child[] = []; 144 for (const key of Object.keys(dict)) { 145 children.push(m(TreeNode, { 146 left: key, 147 right: dict[key], 148 })); 149 } 150 return m(Tree, children); 151} 152 153interface LazyTreeNodeAttrs { 154 // Same as TreeNode (see above). 155 left?: m.Children; 156 // Same as TreeNode (see above). 157 right?: m.Children; 158 // Same as TreeNode (see above). 159 summary?: m.Children; 160 // A callback to be called when the TreeNode is expanded, in order to fetch 161 // child nodes. 162 // The callback must return a promise to a function which returns m.Children. 163 // The reason the promise must return a function rather than the actual 164 // children is to avoid storing vnodes between render cycles, which is a bug 165 // in Mithril. 166 fetchData: () => Promise<() => m.Children>; 167 // Whether to keep child nodes in memory after the node has been collapsed. 168 // Defaults to true 169 hoardData?: boolean; 170} 171 172// This component is a TreeNode which only loads child nodes when it's expanded. 173// This allows us to represent huge trees without having to load all the data 174// up front, and even allows us to represent infinite or recursive trees. 175export class LazyTreeNode implements m.ClassComponent<LazyTreeNodeAttrs> { 176 private collapsed: boolean = true; 177 private renderChildren = this.renderSpinner; 178 179 private renderSpinner(): m.Children { 180 return m(TreeNode, {left: m(Spinner)}); 181 } 182 183 view({attrs}: m.CVnode<LazyTreeNodeAttrs>): m.Children { 184 const { 185 left, 186 right, 187 summary, 188 fetchData, 189 hoardData = true, 190 } = attrs; 191 192 return m( 193 TreeNode, 194 { 195 left, 196 right, 197 summary, 198 collapsed: this.collapsed, 199 onCollapseChanged: (collapsed) => { 200 if (collapsed) { 201 if (!hoardData) { 202 this.renderChildren = this.renderSpinner; 203 } 204 } else { 205 fetchData().then((result) => { 206 if (!this.collapsed) { 207 this.renderChildren = result; 208 globals.rafScheduler.scheduleFullRedraw(); 209 } 210 }); 211 } 212 this.collapsed = collapsed; 213 globals.rafScheduler.scheduleFullRedraw(); 214 }, 215 }, 216 this.renderChildren()); 217 } 218} 219