UNPKG

@mojule/tree-factory

Version:

Takes an adapter/plugins and generates a consistent API over arbitrary tree-like data

791 lines (542 loc) 18.9 kB
# tree-factory Generates a tree API over whatever tree-like backing data you may have. If you don't have a specific tree structure that you'd like to use and just want to stick data into a tree and get the benefits of tree-factory's common API without having to write adapters, plugins etc. consider using the [tree](https://github.com/mojule/tree) package instead The API returned by the factory supports the following methods, documented more extensively later in this document: ```javascript const api = { // adapter isNode, isValue, createRawNode, getChildren, getValue, setValue, remove, add, // miscellaneous get, getRoot, map, clone, // traversal ancestors, childAt, closest, descendants, firstChild, getParent, lastChild, nextSibling, previousSibling, siblings, walk, walkUp, // query accepts, atPath, contains, find, findAll, getMeta, getPath, hasChild, hasChildren, index, isEmpty, meta, nodeType, setMeta, slug, // manipulation append, empty, insertAfter, insertAt, insertBefore, prepend, prune, removeAt, replaceChild, unwrap, value, wrap } ``` ## Usage `npm install @mojule/tree-factory` ```javascript const TreeFactory = require( '@mojule/tree-factory' ) // see below for information on how adapters work and how to create them const adapter = require( './path/to/your/adapter' ) const plugins = require( './path/to/your/plugins' ) const MyTree = TreeFactory( adapter, plugins ) const root = MyTree( 'Root' ) const child = MyTree( 'Child' ) root.add( child ) ``` ## About It takes an `adapter`, which tells the factory how to use your backing data as a tree, then adds a bunch of commonly used functions over the top, for doing things like querying the data, traversing the data and manipulating the data, then any custom functions you may want to provide. Your custom functions can also extend or override the default behaviours. The ability to map from one type of tree to another is also provided. The common functions are modelled on the DOM/jQuery, but they are suitable for working with any tree-like structure, not just DOM elements - for example, all of the following can be treated as trees: - Various markup languages, HTML, XML etc. - Concrete implementations of HTML, eg DOM and virtual DOM nodes - Objects without circular references, eg JSON-compatible objects - Natural language texts - The directory structure of a file system - Code (as parse trees, ASTs etc) - Decision trees, like some flowcharts ## Basic terminology Nodes in our trees have two important properties - their `value`, and their `children`. The `value` is any information that you need to know about a node. The `children` is a list of nodes that descend from the current node. We call the underlying representation of a node in your backing data a `raw node` We use `node` to refer to a object that wraps your raw data in useful functions, such as getting or setting the node's value, adding new children etc. ### value For very simple tree data, a string may suffice as the value, as in this [biology tree example](http://interactivepython.org/runestone/static/pythonds/Trees/ExamplesofTrees.html) A more complex value, like for HTML nodes, might be an object something like this: ```javascript const value = { nodeName: 'div', nodeType: 'element', attributes: { id: 'myDiv' } } ``` ### children Regardless of how the children are actually stored in your raw data, the API expects to always work with an array. This is covered further in the Adapter section below. ### raw node A `raw node` will vary according to your backing structure - let's use the case described above of a biology tree, with the value as a string A raw node described as an object might look like this: ```javascript { value: 'Animalia', children: [ { value: 'Chordate', children: [] // omitted for brevity }, { value: 'Arthropoda', children: [] // omitted for brevity } ] } ``` A raw node described as an array might look like this: ```javascript [ 'Animalia', [ 'Chordate' ], [ 'Arthropoda' ] ] ``` A raw node in text format might look like this: ``` Animalia Chordate Arthropoda ``` ### node Finally, a `node` as created by the API would be used like this: ```javascript const animalia = Tree( 'Animalia' ) const chordate = Tree( 'Chordate' ) const arthropoda = Tree( 'Arthropoda' ) animalia.add( chordate ) animalia.add( arthropoda ) // Animalia console.log( animalia.getValue() ) /* Chordate Arthropoda */ animalia.getChildren().forEach( child => console.log( child.getValue() ) ) ``` ## Adapters An adapter is a function that takes the node API and the current node's state (the raw node), and returns a bag of functions, which is an object where each key is the name of the function and the property is the function itself: ```javascript const adapter = ( api, state ) => { // implement functions here return { $isNode, $isValue, $createRawNode, getChildren, getValue, setValue, remove, add } } ``` For examples of adapters, please see the [test fixtures](/test/fixtures), which include raw tree data for the biology example represented as both arrays and objects, and the adapters for working with either format The $prefix means that the function is static and will be available on your tree API, not just node instances. If you want a better understanding of why this pattern is used (it is used by tree-factory for both adapters and plugins), please read the documentation for [api-factory](https://github.com/mojule/api-factory), on which tree-factory is built. ### Adapter functions Adapters must implement the following functions: `$isNode, $isValue, $createRawNode, getChildren, getValue, setValue, remove, add` The signatures for these are as follows, using similar syntax to typescript or rtype. `RawNode` refers to the raw underlying node data ``` $isNode( rawNode:Any ) => isNode:Boolean $isValue( value:Any ) => isValue:Boolean $createRawNode( value:Any ) => rawNode:RawNode getChildren() => childNodes:[RawNode] getValue() => value:Any setValue( value:Any ) => value:Any remove( rawChild:RawNode ) => removedChild:RawNode add( rawChild:RawNode, reference?:RawNode ) => addedChild:RawNode ``` ### Notes `$isNode`, `$isValue` and `$createRawNode` are treated as static functions, that is, they shouldn't depend on the state passed to your adapter `add` called with a single argument should add the new child to the end of the child node list, but if called with a second `reference` argument, should insert the new child before the `reference` node. ### Adapter wrapper Once you provide your adapter, it will be wrapped in such a way that other functions in the API that work with it can pass it wrapped API nodes and get the same back - this abstracts away the underlying raw implementation to make implementing the various functions much safer and easier. It also wraps `remove` to allow calling remove on a node with no child argument, which instead removes the node from it's parent's children - it will throw if you try and do this with the root node, as it doesn't have a parent. ## Common functions These categories are a bit arbitrary - while some functions clearly fit into one or the other, others were placed where they are by gut feel alone. ```javascript const common = { // miscellaneous get, getRoot, map, clone, // traversal ancestors, childAt, closest, descendants, firstChild, getParent, hasAncestor, lastChild, nextSibling, previousSibling, siblings, walk, walkUp, // query accepts, atPath, contains, find, findAll, getMeta, getPath, hasChild, hasChildren, index, isEmpty, meta, nodeType, setMeta, slug, // manipulation append, empty, insertAfter, insertAt, insertBefore, prepend, prune, removeAt, replaceChild, unwrap, value, wrap } ``` ### Miscellaneous #### get Gets the underlying raw node ```javascript const rawNode = node.get() ``` #### getRoot Gets the node at the root of the tree ```javascript const root = node.getRoot() ``` #### map Maps the tree from one type of tree to another - takes an optional value mapper if the value expected by the target tree differs from that of the source tree ```javascript const Tree1 = require( './path/to/implementation' ) const Tree2 = require( './path/to/implementation' ) const tree1RawData = require( './path/to/data.json' ) // our first imaginary tree's value type is just a string, our second imaginary // tree expects an object with the string in a 'text' property const mapper = value => ({ text: value }) const tree1Root = Tree1( tree1RawData ) const tree2Root = tree1Root.map( Tree2, mapper ) ``` #### clone Creates a true clone of the node and its children - true in the sense that the underlying value of the cloned node will not === the original node, changes to either will not affect the other etc. Only use if your value is JSON serializable! Anything else will throw, as it works behind the scenes by roundtripping `JSON.stringify`/`JSON.parse` ```javascript const cloned = node.clone() ``` ### Traversal #### ancestors Returns an array of the node's parent, the parent's parent etc. all the way to the root. If called on the root node, returns an empty array ```javascript const ancestors = node.ancestors() ``` #### childAt Returns the child at the specified index ```javascript const second = node.childAt( 2 ) ``` #### closest Finds the closest ancestor matching the predicate, or `undefined` if no node is found ```javascript const divAncestor = node.closest( n => n.nodeName() === 'div' ) ``` #### descendants Returns an array of all descendant nodes, or an empty array if the node has no children ```javascript const descendants = node.descendants() ``` #### firstChild Gets the first child of the node, or `undefined` if the node has no children ```javascript const first = node.firstChild() ``` #### getParent Gets the parent of the current node, or `undefined` if the node is the root ```javascript const parent = node.getParent() ``` #### hasAncestor Returns `true` if the given node is an ancestor of the current node, or `false` if not ```javascript const has = node.hasAncestor( otherNode ) ``` #### lastChild Gets the last child of the node, or `undefined` if the node has no children ```javascript const last = node.lastChild() ``` #### nextSibling Gets the node in the parent's children after the current node, or `undefined` if the current node is the last node ```javascript const next = node.nextSibling() ``` #### previousSibling Gets the node in the parent's children before the current node, or `undefined` if the current node is the last node ```javascript const previous = node.previousSibling() ``` #### siblings Gets all of the parent's children excluding the current node ```javascript const siblings = node.siblings() ``` #### walk Does a depth first traversal from the current node, calling the supplied callback for each node including the start node. If your callback returns a truthy value at any point the walk will be terminated. The callback is passed the current node, its parent, and the current depth in the tree (where the root is 0) ```javascript node.walk( ( current, parent, depth ) => { console.log( current.getValue() ) return depth > 5 }) ``` #### walkUp Walks up the tree from the current node, then to its parent, then the parent's parent etc until it reaches the root, or until your callback returns a truthy value ```javascript node.walkUp( current => { const value = current.getValue() console.log( value ) return value.nodeName === 'body' }) ``` ### Query #### accepts Returns a boolean indicating whether the current node will accept the given node as a child Also used by the wrapper around `add` to throw an error if an unacceptable child is added The default implementation just returns the result of `isEmpty`, which in the default implementation always returns false This is provided as a stub that you can override with a plugin, using knowledge about your specific tree, eg text nodes in the DOM can't accept children, UL nodes can only accept LI nodes etc. ```javascript if( !node.accepts( child ) ){ console.log( 'Drat' ) } ``` #### atPath Returns the node in the tree at the specified path. The path is parsed by splitting on the provided separator if provided, or the default separator of '/' if not provided, then walking down from the root and finding the child with a matching `slug` (see slug below) ```javascript const target1 = root.atPath( '/0/4/1/1/0' ) const target2 = root.atPath( '.0.4.1.1.0', '.' ) ``` #### contains Returns a boolean indicating whether the node or any of its descendants matches the predicate ```javascript const hasText = node.contains( n => n.nodeType() === 'text' ) ``` #### find Starting from and including the current node and walking down its descendants, find the first node that matches the predicate ```javascript const text = node.find( n => n.nodeType() === 'text' ) ``` #### findAll Starting from and including the current node and walking down its descendants, find all nodes that matches the predicate and return them in an array ```javascript const textNodes = node.findAll( n => n.nodeType() === 'text' ) ``` #### getMeta Get previously stored metadata about a node - see `setMeta`. Metadata is persisted for the lifetime of the node API, but not stored anywhere in the raw data. Useful for things like displaying a tree in the browser where the user can expand or collapse nodes visually. ```javascript if( node.getMeta( 'collapsed' ) ){ console.log( '' ) } ``` #### getPath Get a string representing the path to the current node - the path is generated by walking down to the node from the root and joining each node's `slug` with the passed in seperator, or '/' if no seperator is defined ```javascript const path1 = node.getPath() const path2 = node.getPath( '.' ) ``` #### hasChild Returns a boolean indicating if the current node has a child matching the predicate ```javascript if( node.hasChild( n => n.getValue() === 'Dang' ) ){ console.log( 'Drat' ) } ``` #### hasChildren Returns a boolean indicating whether the current node has any children, eg the length of its children is > 0 Not the same as whether or not a node *can* have children - see `isEmpty` ```javascript if( !node.hasChildren() ){ console.log( 'So sad' ) } ``` #### index Returns the index of the node within its parent's child list ```javascript const index = node.index() ``` #### isEmpty Returns a boolean indicating whether a node is not allowed to have children. The default implementation always returns false, all nodes can have children. Intended to be overridden by implementations that have leaf-only nodes, like a text node in HTML for example. Named to align with the definition of empty nodes in HTML ```javascript if( node.isEmpty() ){ console.log( 'Too bad' ) } ``` #### meta Convenience function over getMeta and setMeta that does several things depending on the shape of its arguments. Get all metadata associated with this node - note that this is the same object used behind the scenes, so manipulating the returned value will affect the underlying metadata ```javascript const meta = node.meta() ``` Set all metadata associated with this node - node that this uses Object.assign under the hood, so any properties omitted in the object passed to meta will retain their current values. Returns all metadata associated with the node. ```javascript const newMeta = node.meta( { collapsed: true } ) ``` Get the metadata for a named property, same as `getMeta`: ```javascript const isCollapsed = node.meta( 'collapsed' ) ``` Set the metadata for a named property, same as `setMeta`: ```javascript node.meta( 'collapsed', true ) ``` #### nodeType Returns a string representing the type of the node. The default implementation always returns 'node', intended as a stub that can be overridden by implementations ```javascript console.log( node.nodeType() ) ``` #### setMeta Sets some metadata for a node - see `getMeta` for more information on metadata. ```javascript node.setMeta( 'collapsed', true ) ``` #### slug Returns a string that identifies a node uniquely amongst its siblings. By default it returns the node's index within its parent, or if called on the root, and empty string. ```javascript const slug = node.slug() ``` ### Manipulation #### append An alias for `add`, but won't accept a reference argument. Adds the child to the end of the node's child list. ```javascript node.append( child ) ``` #### empty Removes all of a node's children ```javascript node.empty() ``` #### insertAfter Insert the child after the reference node in the node's children ```javascript node.insertAfter( child, reference ) ``` #### insertAt Inserts the child at the specified index within the node's children ```javascript node.insertAt( child, 1 ) ``` #### insertBefore An alias for `add`. Inserts the child into the node's children before the reference node. ```javascript node.insertBefore( child, reference ) ``` #### prepend Adds the child to the beginning of the node's child list ```javascript node.prepend( child ) ``` #### prune Searches downwards from and including the current node for all nodes that match the predicate, and removes them from the tree ```javascript node.prune( n => n.getValue() === 'delet this' ) ``` #### removeAt Removes the child at the specified index ```javascript node.removeAt( 4 ) ``` #### replaceChild Replaces the old child with a new one, at the same position in the child list ```javascript node.replaceChild( child, old ) ``` #### unwrap Removes the current node, while moving all of its children to the position in the parent's child list previously occupied by the current node ```javascript node.unwrap() ``` #### value Convenience method over `getValue` and `setValue`. If called with no arguments, the same as `getValue`. If called with an argument, the same as `setValue` ```javascript const value = node.value() value.text = 'Hi' node.value( value ) ``` #### wrap Adds the current node to the wrapper node, then replaces the current node in its parent's child list with the wrapper node ```javascript node.wrap( wrapper ) ``` ## Tree The function returned by the tree-factory. Takes either a `raw node` or a `value`, and returns a node API, with the wrapped node set as the root of the tree. If you add one of these wrapped nodes to another tree, they'll be demoted from root nodes to ordinary nodes. ```javascript const root = Tree( 'Root' ) const child = Tree( 'Child' ) root.add( child ) ``` Also has the static methods defined in the adapter attached: ```javascript console.log( Tree.isValue( 'Root' ) ) console.log( Tree.isNode( [ 'Root' ] ) ) ```