UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

1,633 lines (1,503 loc) 88.1 kB
/*! * Wunderbaum - wunderbaum_node * Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license. * @VERSION, @DATE (https://github.com/mar10/wunderbaum) */ import * as util from "./util"; import { Wunderbaum } from "./wunderbaum"; import { AddChildrenOptions, ApplyCommandOptions, ApplyCommandType, ChangeType, CheckboxOption, ColumnDefinition, ColumnEventInfoMap, ExpandAllOptions, IconOption, InsertNodeType, MakeVisibleOptions, MatcherCallback, NavigateOptions, NavigationType, NodeAnyCallback, NodeStatusType, NodeStringCallback, NodeToDictCallback, NodeVisitCallback, NodeVisitResponse, RenderOptions, ResetOrderOptions, ScrollIntoViewOptions, SetActiveOptions, SetExpandedOptions, SetSelectedOptions, SetStatusOptions, SortByPropertyOptions, SortCallback, SortOrderType, SourceType, TooltipOption, TristateType, WbNodeData, } from "./types"; import { decompressSourceData, ICON_WIDTH, KEY_TO_NAVIGATION_MAP, makeNodeTitleMatcher, nodeTitleSorter, RESERVED_TREE_SOURCE_KEYS, TEST_IMG, TITLE_SPAN_PAD_Y, } from "./common"; import { Deferred } from "./deferred"; /** WunderbaumNode properties that can be passed with source data. * (Any other source properties will be stored as `node.data.PROP`.) */ const NODE_PROPS = new Set<string>([ "checkbox", "classes", "expanded", "icon", "iconTooltip", "key", "lazy", "_partsel", "radiogroup", "refKey", "selected", "statusNodeType", "title", "tooltip", "type", "unselectable", ]); /** WunderbaumNode properties that will be returned by `node.toDict()`.) */ const NODE_DICT_PROPS = new Set<string>(NODE_PROPS); NODE_DICT_PROPS.delete("_partsel"); NODE_DICT_PROPS.delete("unselectable"); // /** Node properties that are of type bool (or boolean & string). // * When parsing, we accept 0 for false and 1 for true for better JSON compression. // */ // export const NODE_BOOL_PROPS: Set<string> = new Set([ // "checkbox", // "colspan", // "expanded", // "icon", // "iconTooltip", // "radiogroup", // "selected", // "tooltip", // "unselectable", // ]); /** * A single tree node. * * **NOTE:** <br> * Generally you should not modify properties directly, since this may break * the internal bookkeeping. */ export class WunderbaumNode { static sequence = 0; /** Reference to owning tree. */ public tree: Wunderbaum; /** Parent node (null for the invisible root node `tree.root`). */ public parent: WunderbaumNode; /** Name of the node. * @see Use {@link setTitle} to modify. */ public title: string; /** Unique key. Passed with constructor or defaults to `SEQUENCE`. * @see Use {@link setKey} to modify. */ public readonly key: string; /** Reference key. Unlike {@link key}, a `refKey` may occur multiple * times within a tree (in this case we have 'clone nodes'). * @see Use {@link setKey} to modify. */ public readonly refKey: string | undefined = undefined; /** * Array of child nodes (null for leaf nodes). * For lazy nodes, this is `null` or ùndefined` until the children are loaded * and leaf nodes may be `[]` (empty array). * @see {@link hasChildren}, {@link addChildren}, {@link lazy}. */ public children: WunderbaumNode[] | null = null; /** Render a checkbox or radio button @see {@link selected}. */ public checkbox?: CheckboxOption; /** If true, this node's children are considerd radio buttons. * @see {@link isRadio}. */ public radiogroup?: boolean; /** If true, (in grid mode) no cells are rendered, except for the node title.*/ public colspan?: boolean; /** Icon definition. */ public icon?: IconOption; /** Lazy loading flag. * @see {@link isLazy}, {@link isLoaded}, {@link isUnloaded}. */ public lazy?: boolean; /** Expansion state. * @see {@link isExpandable}, {@link isExpanded}, {@link setExpanded}. */ public expanded?: boolean; /** Selection state. * @see {@link isSelected}, {@link setSelected}, {@link toggleSelected}. */ public selected?: boolean; public unselectable?: boolean; /** Node type (used for styling). * @see {@link Wunderbaum.types}. */ public type?: string; /** Tooltip definition (`true`: use node's title). */ public tooltip?: TooltipOption; /** Icon tooltip definition (`true`: use node's title). */ public iconTooltip?: TooltipOption; /** Additional classes added to `div.wb-row`. * @see {@link hasClass}, {@link setClass}. */ public classes: Set<string> | null = null; //new Set<string>(); /** Custom data that was passed to the constructor */ public data: any = {}; // --- Node Status --- public statusNodeType?: NodeStatusType; _isLoading = false; _requestId = 0; _errorInfo: any | null = null; _partsel = false; _partload = false; // --- FILTER --- /** * > 0 if matched (-1 to keep system nodes visible); * Added and removed by filter code. */ public match?: number; public subMatchCount?: number = 0; // public subMatchBadge?: HTMLElement; /** @internal */ public titleWithHighlight?: string; public _filterAutoExpanded?: boolean; _rowIdx: number | undefined = 0; _rowElem: HTMLDivElement | undefined = undefined; constructor(tree: Wunderbaum, parent: WunderbaumNode, data: any) { util.assert(!parent || parent.tree === tree, `Invalid parent: ${parent}`); util.assert(!data.children, "'children' not allowed here"); this.tree = tree; this.parent = parent; this.key = "" + (data.key ?? ++WunderbaumNode.sequence); this.title = "" + (data.title ?? "<" + this.key + ">"); this.expanded = !!data.expanded; this.lazy = !!data.lazy; // We set the following node properties only if a matching data value is // passed data.refKey != null ? (this.refKey = "" + data.refKey) : 0; data.type != null ? (this.type = "" + data.type) : 0; data.icon != null ? (this.icon = util.intToBool(data.icon)) : 0; data.tooltip != null ? (this.tooltip = util.intToBool(data.tooltip)) : 0; data.iconTooltip != null ? (this.iconTooltip = util.intToBool(data.iconTooltip)) : 0; data.statusNodeType != null ? (this.statusNodeType = ("" + data.statusNodeType) as NodeStatusType) : 0; data.colspan != null ? (this.colspan = !!data.colspan) : 0; // Selection data.checkbox != null ? util.intToBool(data.checkbox) : 0; data.radiogroup != null ? (this.radiogroup = !!data.radiogroup) : 0; data.selected != null ? (this.selected = !!data.selected) : 0; data.unselectable != null ? (this.unselectable = !!data.unselectable) : 0; if (data.classes) { this.setClass(data.classes); } // Store custom fields as `node.data` for (const [key, value] of Object.entries(data)) { if (!NODE_PROPS.has(key)) { this.data[key] = value; } } if (parent && !this.statusNodeType) { // Don't register root node or status nodes tree._registerNode(this); } } /** * Return readable string representation for this instance. * @internal */ toString() { return `WunderbaumNode@${this.key}<'${this.title}'>`; } /** * Iterate all descendant nodes depth-first, pre-order using `for ... of ...` syntax. * More concise, but slightly slower than {@link WunderbaumNode.visit}. * * Example: * ```js * for(const n of node) { * ... * } * ``` */ *[Symbol.iterator](): IterableIterator<WunderbaumNode> { // let node: WunderbaumNode | null = this; const cl = this.children; if (cl) { for (let i = 0, l = cl.length; i < l; i++) { const n = cl[i]; yield n; if (n.children) { yield* n; } } // Slower: // for (let node of this.children) { // yield node; // yield* node : 0; // } } } // /** Return an option value. */ // protected _getOpt( // name: string, // nodeObject: any = null, // treeOptions: any = null, // defaultValue: any = null // ): any { // return evalOption( // name, // this, // nodeObject || this, // treeOptions || this.tree.options, // defaultValue // ); // } /** Call event handler if defined in tree.options. * Example: * ```js * node._callEvent("edit.beforeEdit", {foo: 42}) * ``` */ _callEvent(type: string, extra?: any): any { return this.tree?._callEvent( type, util.extend( { node: this, typeInfo: this.type ? this.tree.types[this.type] : {}, }, extra ) ); } /** * Append (or insert) a list of child nodes. * * Tip: pass `{ before: 0 }` to prepend new nodes as first children. * * @returns first child added */ addChildren( nodeData: WbNodeData | WbNodeData[], options?: AddChildrenOptions ): WunderbaumNode { const tree = this.tree; let { before = null, applyMinExpanLevel = true, _level } = options ?? {}; // let { before, loadLazy=true, _level } = options ?? {}; // const isTopCall = _level == null; _level ??= this.getLevel(); const nodeList = []; try { tree.enableUpdate(false); if (util.isPlainObject(nodeData)) { nodeData = [<WbNodeData>nodeData]; } const forceExpand = applyMinExpanLevel && _level < tree.options.minExpandLevel!; for (const child of <WbNodeData[]>nodeData) { const subChildren = child.children; delete child.children; const n = new WunderbaumNode(tree, this, child); if (forceExpand && !n.isUnloaded()) { n.expanded = true; } nodeList.push(n); if (subChildren) { n.addChildren(subChildren, { _level: _level + 1 }); } } if (!this.children) { this.children = nodeList; } else if (before == null || this.children.length === 0) { this.children = this.children.concat(nodeList); } else { // Returns null if before is not a direct child: before = this.findDirectChild(before)!; const pos = this.children.indexOf(before); util.assert( pos >= 0, `options.before must be a direct child of ${this}` ); // insert nodeList after children[pos] this.children.splice(pos, 0, ...nodeList); } // this.triggerModifyChild("add", nodeList.length === 1 ? nodeList[0] : null); tree.update(ChangeType.structure); } finally { // if (tree.options.selectMode === "hier") { // if (this.parent && this.parent.children) { // this.fixSelection3FromEndNodes(); // } else { // // may happen when loading __root__; // } // } tree.enableUpdate(true); } // if(isTopCall && loadLazy){ // this.logWarn("addChildren(): loadLazy is not yet implemented.") // } return nodeList[0]; } /** * Append or prepend a node, or append a child node. * * This a convenience function that calls addChildren() * * @param nodeData node definition * @param [mode=child] 'before', 'after', 'firstChild', or 'child' ('over' is a synonym for 'child') * @returns new node */ addNode( nodeData: WbNodeData, mode: InsertNodeType = "appendChild" ): WunderbaumNode { if (<string>mode === "over") { mode = "appendChild"; // compatible with drop region } switch (mode) { case "after": return this.parent.addChildren(nodeData, { before: this.getNextSibling(), }); case "before": return this.parent.addChildren(nodeData, { before: this }); case "prependChild": // Insert before the first child if any // let insertBefore = this.children ? this.children[0] : undefined; return this.addChildren(nodeData, { before: 0 }); case "appendChild": return this.addChildren(nodeData); } util.assert(false, `Invalid mode: ${mode}`); return (<unknown>undefined) as WunderbaumNode; } /** * Apply a modification (or navigation) operation. * * @see {@link Wunderbaum.applyCommand} */ applyCommand(cmd: ApplyCommandType, options: ApplyCommandOptions): any { return this.tree.applyCommand(cmd, this, options); } /** * Collapse all expanded sibling nodes if any. * (Automatically called when `autoCollapse` is true.) */ collapseSiblings(options?: SetExpandedOptions): any { for (const node of this.parent.children!) { if (node !== this && node.expanded) { node.setExpanded(false, options); } } } /** * Add/remove one or more classes to `<div class='wb-row'>`. * * This also maintains `node.classes`, so the class will survive a re-render. * * @param className one or more class names. Multiple classes can be passed * as space-separated string, array of strings, or set of strings. */ setClass( className: string | string[] | Set<string>, flag: boolean = true ): void { const cnSet = util.toSet(className); if (flag) { if (this.classes === null) { this.classes = new Set<string>(); } cnSet.forEach((cn) => { this.classes!.add(cn); this._rowElem?.classList.toggle(cn, flag); }); } else { if (this.classes === null) { return; } cnSet.forEach((cn) => { this.classes!.delete(cn); this._rowElem?.classList.toggle(cn, flag); }); if (this.classes.size === 0) { this.classes = null; } } } /** Start editing this node's title. */ startEditTitle(): void { this.tree._callMethod("edit.startEditTitle", this); } /** * Call `setExpanded()` on all descendant nodes. * * @param flag true to expand, false to collapse. * @param options Additional options. * @see {@link Wunderbaum.expandAll} * @see {@link WunderbaumNode.setExpanded} */ async expandAll(flag: boolean = true, options?: ExpandAllOptions) { const tree = this.tree; const { collapseOthers, deep, depth, force, keepActiveNodeVisible = true, loadLazy, resetLazy, } = options ?? {}; // limit expansion level to `depth` (or tree.minExpandLevel). Default: unlimited const treeLevel = this.tree.options.minExpandLevel || null; // 0 -> null const minLevel = depth ?? (force ? null : treeLevel); const expandOpts = { deep: deep, force: force, loadLazy: loadLazy, resetLazy: resetLazy, scrollIntoView: false, // don't scroll every node while iterating }; this.logInfo(`expandAll(${flag}, depth=${depth}, minLevel=${minLevel})`); util.assert( !(flag && deep != null && !collapseOthers), "Expanding with `deep` option is not supported (implied by the `depth` option)." ); // Expand all direct children in parallel: async function _iter(n: WunderbaumNode, level: number) { // n.logInfo(` _iter(level=${level})`); const promises: Promise<unknown>[] = []; n.children?.forEach((cn) => { if (flag) { if ( !cn.expanded && (minLevel == null || level < minLevel) && (cn.children || (loadLazy && cn.lazy)) ) { // Node is collapsed and may be expanded (i.e. has children or is lazy) // Expanding may be async, so we store the promise. // Also the recursion is delayed until expansion finished. const p = cn.setExpanded(true, expandOpts); promises.push(p); if (depth == null) { p.then(async () => { await _iter(cn, level + 1); }); } } else { // We don't expand the node, but still visit descendants. // There we may find lazy nodes, so we promises.push(_iter(cn, level + 1)); } } else { // Collapsing is always synchronous, so no promises required // Do not collapse until minExpandLevel if (minLevel == null || level >= minLevel) { cn.setExpanded(false, expandOpts); } if ((minLevel != null && level < minLevel) || deep) { _iter(cn, level + 1); // recursion, even if cn was already collapsed } } }); return new Promise((resolve) => { Promise.all(promises).then(() => { resolve(true); }); }); } const tag = tree.logTime(`${this}.expandAll(${flag}, depth=${depth})`); try { tree.enableUpdate(false); await _iter(this, 0); if (collapseOthers) { util.assert(flag, "Option `collapseOthers` requires flag=true"); util.assert( minLevel != null, "Option `collapseOthers` requires `depth` or `minExpandLevel`" ); this.expandAll(false, { depth: minLevel! }); } } finally { tree.enableUpdate(true); tree.logTimeEnd(tag); } if (tree.activeNode && keepActiveNodeVisible) { tree.activeNode.scrollIntoView(); } } /** * Find all descendant nodes that match condition (excluding self). * * If `match` is a string, search for exact node title. * If `match` is a RegExp expression, apply it to node.title, using * [RegExp.test()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test). * If `match` is a callback, match all nodes for that the callback(node) returns true. * * Returns an empty array if no nodes were found. * * Examples: * ```js * // Match all node titles that match exactly 'Joe': * nodeList = node.findAll("Joe") * // Match all node titles that start with 'Joe' case sensitive: * nodeList = node.findAll(/^Joe/) * // Match all node titles that contain 'oe', case insensitive: * nodeList = node.findAll(/oe/i) * // Match all nodes with `data.price` >= 99: * nodeList = node.findAll((n) => { * return n.data.price >= 99; * }) * ``` */ findAll(match: string | RegExp | MatcherCallback): WunderbaumNode[] { const matcher = typeof match === "function" ? match : makeNodeTitleMatcher(match); const res: WunderbaumNode[] = []; this.visit((n) => { if (matcher(n)) { res.push(n); } }); return res; } /** Return the direct child with a given key, index or null. */ findDirectChild( ptr: number | string | WunderbaumNode ): WunderbaumNode | null { const cl = this.children; if (!cl) { return null; } if (typeof ptr === "string") { for (let i = 0, l = cl.length; i < l; i++) { if (cl[i].key === ptr) { return cl[i]; } } } else if (typeof ptr === "number") { return cl[ptr]; } else if (ptr.parent === this) { // Return null if `ptr` is not a direct child return ptr; } return null; } /** * Find first descendant node that matches condition (excluding self) or null. * * @see {@link WunderbaumNode.findAll} for examples. */ findFirst(match: string | RegExp | MatcherCallback): WunderbaumNode | null { const matcher = typeof match === "function" ? match : makeNodeTitleMatcher(match); let res = null; this.visit((n) => { if (matcher(n)) { res = n; return false; } }); return res; } /** Find a node relative to self. * * @see {@link Wunderbaum.findRelatedNode|tree.findRelatedNode()} */ findRelatedNode(where: NavigationType, includeHidden = false) { return this.tree.findRelatedNode(this, where, includeHidden); } /** * Iterator version of {@link WunderbaumNode.format}. */ *format_iter( name_cb?: NodeStringCallback, connectors?: string[] ): IterableIterator<string> { connectors ??= [" ", " | ", " ╰─ ", " ├─ "]; name_cb ??= (node: WunderbaumNode) => "" + node; function _is_last(node: WunderbaumNode): boolean { const ca = node.parent.children!; return node === ca[ca.length - 1]; } const _format_line = (node: WunderbaumNode) => { // https://www.measurethat.net/Benchmarks/Show/12196/0/arr-unshift-vs-push-reverse-small-array const parts = [name_cb!(node)]; parts.unshift(connectors![_is_last(node) ? 2 : 3]); let p = node.parent; while (p && p !== this) { // `this` is the top node parts.unshift(connectors![_is_last(p) ? 0 : 1]); p = p.parent; } return parts.join(""); }; yield name_cb(this); for (const node of this) { yield _format_line(node); } } /** * Return a multiline string representation of a node/subnode hierarchy. * Mostly useful for debugging. * * Example: * ```js * console.info(tree.getActiveNode().format((n)=>n.title)); * ``` * logs * ``` * Books * ├─ Art of War * ╰─ Don Quixote * ``` * @see {@link WunderbaumNode.format_iter} */ format(name_cb?: NodeStringCallback, connectors?: string[]): string { const a = []; for (const line of this.format_iter(name_cb, connectors)) { a.push(line); } return a.join("\n"); } /** Return the `<span class='wb-col'>` element with a given index or id. * @returns {WunderbaumNode | null} */ getColElem(colIdx: number | string) { if (typeof colIdx === "string") { colIdx = this.tree.columns.findIndex((value) => value.id === colIdx); } const colElems = this._rowElem?.querySelectorAll("span.wb-col"); return colElems ? (colElems[colIdx] as HTMLSpanElement) : null; } /** * Return all nodes with the same refKey. * * @param includeSelf Include this node itself. * @see {@link Wunderbaum.findByRefKey} */ getCloneList(includeSelf = false): WunderbaumNode[] { if (!this.refKey) { return []; } const clones = this.tree.findByRefKey(this.refKey); if (includeSelf) { return clones; } return [...clones].filter((n) => n !== this); } /** Return the first child node or null. * @returns {WunderbaumNode | null} */ getFirstChild() { return this.children ? this.children[0] : null; } /** Return the last child node or null. * @returns {WunderbaumNode | null} */ getLastChild() { return this.children ? this.children[this.children.length - 1] : null; } /** Return node depth (starting with 1 for top level nodes). */ getLevel(): number { let i = 0, p = this.parent; while (p) { i++; p = p.parent; } return i; } /** Return the successive node (under the same parent) or null. */ getNextSibling(): WunderbaumNode | null { const ac = this.parent.children!; const idx = ac.indexOf(this); return ac[idx + 1] || null; } /** Return the parent node (null for the system root node). */ getParent(): WunderbaumNode | null { // TODO: return null for top-level nodes? return this.parent; } /** Return an array of all parent nodes (top-down). * @param includeRoot Include the invisible system root node. * @param includeSelf Include the node itself. */ getParentList(includeRoot = false, includeSelf = false) { const l = []; let dtn = includeSelf ? this : this.parent; while (dtn) { if (includeRoot || dtn.parent) { l.unshift(dtn); } dtn = dtn.parent; } return l; } /** Return a string representing the hierachical node path, e.g. "a/b/c". * @param includeSelf * @param part property name or callback * @param separator */ getPath( includeSelf: boolean = true, part: keyof WunderbaumNode | NodeAnyCallback = "title", separator: string = "/" ) { // includeSelf = includeSelf !== false; // part = part || "title"; // separator = separator || "/"; let val; const path: string[] = []; const isFunc = typeof part === "function"; this.visitParents((n) => { if (n.parent) { val = isFunc ? (<NodeAnyCallback>part)(n) : n[<keyof WunderbaumNode>part]; path.unshift(val); } return undefined; // TODO remove this line }, includeSelf); return path.join(separator); } /** Return the preceeding node (under the same parent) or null. */ getPrevSibling(): WunderbaumNode | null { const ac = this.parent.children!; const idx = ac.indexOf(this); return ac[idx - 1] || null; } /** Return true if node has children. * Return undefined if not sure, i.e. the node is lazy and not yet loaded. */ hasChildren() { if (this.lazy) { if (this.children == null) { return undefined; // null or undefined: Not yet loaded } else if (this.children.length === 0) { return false; // Loaded, but response was empty } else if ( this.children.length === 1 && this.children[0].isStatusNode() ) { return undefined; // Currently loading or load error } return true; // One or more child nodes } return !!(this.children && this.children.length); } /** Return true if node has className set. */ hasClass(className: string): boolean { return this.classes ? this.classes.has(className) : false; } /** Return true if node ist the currently focused node. @since 0.9.0 */ hasFocus(): boolean { return this.tree.focusNode === this; } /** Return true if this node is the currently active tree node. */ isActive() { return this.tree.activeNode === this; } /** Return true if this node is a direct or indirect parent of `other`. * @see {@link WunderbaumNode.isParentOf} */ isAncestorOf(other: WunderbaumNode) { return other && other.isDescendantOf(this); } /** Return true if this node is a **direct** subnode of `other`. * @see {@link WunderbaumNode.isDescendantOf} */ isChildOf(other: WunderbaumNode) { return other && this.parent === other; } /** Return true if this node's refKey is used by at least one other node. */ isClone() { return !!this.refKey && this.tree.findByRefKey(this.refKey).length > 1; } /** Return true if this node's title spans all columns, i.e. the node has no * grid cells. */ isColspan() { return !!this.getOption("colspan"); } /** Return true if this node is a direct or indirect subnode of `other`. * @see {@link WunderbaumNode.isChildOf} */ isDescendantOf(other: WunderbaumNode) { if (!other || other.tree !== this.tree) { return false; } let p = this.parent; while (p) { if (p === other) { return true; } if (p === p.parent) { util.error(`Recursive parent link: ${p}`); } p = p.parent; } return false; } /** Return true if this node has children, i.e. the node is generally expandable. * If `andCollapsed` is set, we also check if this node is collapsed, i.e. * an expand operation is currently possible. */ isExpandable(andCollapsed = false): boolean { // `false` is never expandable (unoffical) if ((andCollapsed && this.expanded) || <any>this.children === false) { return false; } if (this.children == null) { return !!this.lazy; // null or undefined can trigger lazy load } if (this.children.length === 0) { return !!this.tree.options.emptyChildListExpandable; } return true; } /** Return true if _this_ node is currently in edit-title mode. * * See {@link WunderbaumNode.startEditTitle}. */ isEditingTitle(): boolean { return this.tree._callMethod("edit.isEditingTitle", this); } /** Return true if this node is currently expanded. */ isExpanded(): boolean { return !!this.expanded; } /** Return true if this node is the first node of its parent's children. */ isFirstSibling(): boolean { const p = this.parent; return !p || p.children![0] === this; } /** Return true if this node is the last node of its parent's children. */ isLastSibling(): boolean { const p = this.parent; return !p || p.children![p.children!.length - 1] === this; } /** Return true if this node is lazy (even if data was already loaded) */ isLazy(): boolean { return !!this.lazy; } /** Return true if node is lazy and loaded. For non-lazy nodes always return true. */ isLoaded(): boolean { return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node } /** Return true if node is currently loading, i.e. a GET request is pending. */ isLoading(): boolean { return this._isLoading; } /** Return true if this node is a temporarily generated status node of type 'paging'. */ isPagingNode(): boolean { return this.statusNodeType === "paging"; } /** Return true if this node is a **direct** parent of `other`. * @see {@link WunderbaumNode.isAncestorOf} */ isParentOf(other: WunderbaumNode) { return other && other.parent === this; } /** Return true if this node is partially loaded. @experimental */ isPartload(): boolean { return !!this._partload; } /** Return true if this node is partially selected (tri-state). */ isPartsel(): boolean { return !this.selected && !!this._partsel; } /** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */ isRadio(): boolean { return !!this.parent.radiogroup || this.getOption("checkbox") === "radio"; } /** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */ isRendered(): boolean { return !!this._rowElem; } /** Return true if this node is the (invisible) system root node. * @see {@link WunderbaumNode.isTopLevel} */ isRootNode(): boolean { return this.tree.root === this; } /** Return true if this node is selected, i.e. the checkbox is set. * `undefined` if partly selected (tri-state), false otherwise. */ isSelected(): TristateType { return this.selected ? true : this._partsel ? undefined : false; } /** Return true if this node is a temporarily generated system node like * 'loading', 'paging', or 'error' (node.statusNodeType contains the type). */ isStatusNode(): boolean { return !!this.statusNodeType; } /** Return true if this a top level node, i.e. a direct child of the (invisible) system root node. */ isTopLevel(): boolean { return this.tree.root === this.parent; } /** Return true if node is marked lazy but not yet loaded. * For non-lazy nodes always return false. */ isUnloaded(): boolean { // Also checks if the only child is a status node: return this.hasChildren() === undefined; } /** Return true if all parent nodes are expanded. Note: this does not check * whether the node is scrolled into the visible part of the screen or viewport. */ isVisible(): boolean { const hasFilter = this.tree.filterMode === "hide"; const parents = this.getParentList(false, false); // TODO: check $(n.span).is(":visible") // i.e. return false for nodes (but not parents) that are hidden // by a filter if (hasFilter && !this.match && !this.subMatchCount) { // this.debug( "isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")" ); return false; } for (let i = 0, l = parents.length; i < l; i++) { const n = parents[i]; if (!n.expanded) { // this.debug("isVisible: HIDDEN (parent collapsed)"); return false; } // if (hasFilter && !n.match && !n.subMatchCount) { // this.debug("isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")"); // return false; // } } // this.debug("isVisible: VISIBLE"); return true; } protected _loadSourceObject(source: any, level?: number) { const tree = this.tree; level ??= this.getLevel(); // Let caller modify the parsed JSON response: const res = this._callEvent("receive", { response: source }); if (res != null) { source = res; } if (util.isArray(source)) { source = { children: source }; } util.assert( util.isPlainObject(source), `Expected an array or plain object: ${source}` ); const format: string = source.format ?? "nested"; util.assert( format === "nested" || format === "flat", `Expected source.format = 'nested' or 'flat': ${format}` ); // Pre-rocess for 'nested' or 'flat' format decompressSourceData(source); util.assert( source.children, "If `source` is an object, it must have a `children` property" ); if (source.types) { tree.logInfo("Redefine types", source.columns); tree.setTypes(source.types, false); delete source.types; } if (source.columns) { tree.logInfo("Redefine columns", source.columns); tree.columns = source.columns; delete source.columns; tree.update(ChangeType.colStructure); } this.addChildren(source.children); // Add extra data to `tree.data` for (const [key, value] of Object.entries(source)) { if (!RESERVED_TREE_SOURCE_KEYS.has(key)) { tree.data[key] = value; // tree.logDebug(`Add source.${key} to tree.data.${key}`); } } if (tree.options.selectMode === "hier") { this.fixSelection3FromEndNodes(); } // Allow to un-sort nodes after sorting this.resetNativeChildOrder(); this._callEvent("load"); } async _fetchWithOptions(source: any) { // Either a URL string or an object with a `.url` property. let url: string, params, body, options, rest; let fetchOpts: RequestInit = {}; if (typeof source === "string") { // source is a plain URL string: assume GET request url = source; fetchOpts.method = "GET"; } else if (util.isPlainObject(source)) { // source is a plain object with `.url` property. ({ url, params, body, options, ...rest } = source); util.assert( !rest || Object.keys(rest).length === 0, `Unexpected source properties: ${Object.keys( rest )}. Use 'options' instead.` ); util.assert(typeof url === "string", `expected source.url as string`); if (util.isPlainObject(options)) { fetchOpts = options; } if (util.isPlainObject(body)) { // we also accept 'body' as object... util.assert( !fetchOpts.body, "options.body should be passed as source.body" ); fetchOpts.body = JSON.stringify(fetchOpts.body); fetchOpts.method ??= "POST"; // set default } if (util.isPlainObject(params)) { url += "?" + new URLSearchParams(params); fetchOpts.method ??= "GET"; // set default } } else { url = ""; // keep linter happy util.error(`Unsupported source format: ${source}`); } this.setStatus(NodeStatusType.loading); const response = await fetch(url, fetchOpts); if (!response.ok) { util.error(`GET ${url} returned ${response.status}, ${response}`); } return await response.json(); } /** Download data from the cloud, then call `.update()`. */ async load(source: SourceType) { const tree = this.tree; const requestId = Date.now(); const prevParent = this.parent; const start = Date.now(); let elap = 0, elapLoad = 0, elapProcess = 0; // Check for overlapping requests if (this._requestId) { this.logWarn( `Recursive load request #${requestId} while #${this._requestId} is pending. ` + "The previous request will be ignored." ); } this._requestId = requestId; // const timerLabel = tree.logTime(this + ".load()"); try { const url: string = typeof source === "string" ? source : (<any>source).url; if (!url) { // An array or a plain object (that does NOT contain a `.url` property) // will be treated as native Wunderbaum data if (typeof (<any>source).then === "function") { const msg = tree.logTime(`Resolve thenable ${source}`); source = await Promise.resolve(source); tree.logTimeEnd(msg); } this._loadSourceObject(source); elapProcess = Date.now() - start; } else { // Either a URL string or an object with a `.url` property. const data = await this._fetchWithOptions(source); elapLoad = Date.now() - start; if (this._requestId && this._requestId > requestId) { this.logWarn( `Ignored load response #${requestId} because #${this._requestId} is pending.` ); return; } else { this.logDebug(`Received response for load request #${requestId}`); } if (this.parent === null && prevParent !== null) { this.logWarn( "Lazy parent node was removed while loading: discarding response." ); return; } this.setStatus(NodeStatusType.ok); // if (data.columns) { // tree.logInfo("Re-define columns", data.columns); // util.assert(!this.parent); // tree.columns = data.columns; // delete data.columns; // tree.updateColumns({ calculateCols: false }); // } const startProcess = Date.now(); this._loadSourceObject(data); elapProcess = Date.now() - startProcess; } } catch (error) { this.logError("Error during load()", source, error); this._callEvent("error", { error: error }); this.setStatus(NodeStatusType.error, { message: "" + error }); throw error; } finally { this._requestId = 0; elap = Date.now() - start; if (tree.options.debugLevel! >= 3) { tree.logInfo( `Load source took ${elap / 1000} seconds ` + `(transfer: ${elapLoad / 1000}s, ` + `processing: ${elapProcess / 1000}s)` ); } } } /** * Load content of a lazy node. * If the node is already loaded, nothing happens. * @param [forceReload=false] If true, reload even if already loaded. */ async loadLazy(forceReload: boolean = false) { const wasExpanded = this.expanded; util.assert(this.lazy, "load() requires a lazy node"); if (!forceReload && !this.isUnloaded()) { return; // Already loaded: nothing to do } if (this.isLoading()) { this.logWarn("loadLazy() called while already loading: ignored."); return; // Already loading: prevent duplicate requests } if (this.isLoaded()) { this.resetLazy(); // Also collapses if currently expanded } // `lazyLoad` may be long-running, so mark node as loading now. `this.load()` // will reset the status later. this.setStatus(NodeStatusType.loading); try { const source = await this._callEvent("lazyLoad"); if (source === false) { this.setStatus(NodeStatusType.ok); return; } util.assert( util.isArray(source) || (source && source.url), "The lazyLoad event must return a node list, `{url: ...}`, or false." ); await this.load(source); this.setStatus(NodeStatusType.ok); // Also resets `this._isLoading` if (wasExpanded) { this.expanded = true; this.tree.update(ChangeType.structure); } else { this.update(); // Fix expander icon to 'loaded' } } catch (e) { this.logError("Error during loadLazy()", e); this._callEvent("error", { error: e }); // Also resets `this._isLoading`: this.setStatus(NodeStatusType.error, { message: "" + e }); } return; } /** Write to `console.log` with node name as prefix if opts.debugLevel >= 4. * @see {@link WunderbaumNode.logDebug} */ log(...args: any[]) { if (this.tree.options.debugLevel! >= 4) { console.log(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.debug` with node name as prefix if opts.debugLevel >= 4 * and browser console level includes debug/verbose messages. * @see {@link WunderbaumNode.log} */ logDebug(...args: any[]) { if (this.tree.options.debugLevel! >= 4) { console.debug(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.error` with node name as prefix if opts.debugLevel >= 1. */ logError(...args: any[]) { if (this.tree.options.debugLevel! >= 1) { console.error(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.info` with node name as prefix if opts.debugLevel >= 3. */ logInfo(...args: any[]) { if (this.tree.options.debugLevel! >= 3) { console.info(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.warn` with node name as prefix if opts.debugLevel >= 2. */ logWarn(...args: any[]) { if (this.tree.options.debugLevel! >= 2) { console.warn(this.toString(), ...args); // eslint-disable-line no-console } } /** Expand all parents and optionally scroll into visible area as neccessary. * Promise is resolved, when lazy loading and animations are done. * @param {object} [options] passed to `setExpanded()`. * Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true} */ async makeVisible(options?: MakeVisibleOptions) { let i; const dfd = new Deferred(); const deferreds = []; const parents = this.getParentList(false, false); const len = parents.length; const noAnimation = util.getOption(options, "noAnimation", false); const scroll = util.getOption(options, "scrollIntoView", true); // Expand bottom-up, so only the top node is animated for (i = len - 1; i >= 0; i--) { // self.debug("pushexpand" + parents[i]); const seOpts = { noAnimation: noAnimation }; deferreds.push(parents[i].setExpanded(true, seOpts)); } Promise.all(deferreds).then(() => { // All expands have finished // self.debug("expand DONE", scroll); // Note: this.tree may be none when switching demo trees if (scroll && this.tree) { // Make sure markup and _rowIdx is updated before we do the scroll calculations this.tree.updatePendingModifications(); this.scrollIntoView().then(() => { // self.debug("scroll DONE"); dfd.resolve(); }); } else { dfd.resolve(); } }); return dfd.promise(); } /** Move this node to targetNode. */ moveTo( targetNode: WunderbaumNode, mode: InsertNodeType = "appendChild", map?: NodeAnyCallback ) { if (<string>mode === "over") { mode = "appendChild"; // compatible with drop region } if (mode === "prependChild") { if (targetNode.children && targetNode.children.length) { mode = "before"; targetNode = targetNode.children[0]; } else { mode = "appendChild"; } } let pos; const tree = this.tree; const prevParent = this.parent; const targetParent = mode === "appendChild" ? targetNode : targetNode.parent; if (this === targetNode) { return; } else if (!this.parent) { util.error("Cannot move system root"); } else if (targetParent.isDescendantOf(this)) { util.error("Cannot move a node to its own descendant"); } if (targetParent !== prevParent) { prevParent.triggerModifyChild("remove", this); } // Unlink this node from current parent if (this.parent.children!.length === 1) { if (this.parent === targetParent) { return; // #258 } this.parent.children = this.parent.lazy ? [] : null; this.parent.expanded = false; } else { pos = this.parent.children!.indexOf(this); util.assert(pos >= 0, "invalid source parent"); this.parent.children!.splice(pos, 1); } // Insert this node to target parent's child list this.parent = targetParent; if (targetParent.hasChildren()) { switch (mode) { case "appendChild": // Append to existing target children targetParent.children!.push(this); break; case "before": // Insert this node before target node pos = targetParent.children!.indexOf(targetNode); util.assert(pos >= 0, "invalid target parent"); targetParent.children!.splice(pos, 0, this); break; case "after": // Insert this node after target node pos = targetParent.children!.indexOf(targetNode); util.assert(pos >= 0, "invalid target parent"); targetParent.children!.splice(pos + 1, 0, this); break; default: util.error(`Invalid mode '${mode}'.`); } } else { targetParent.children = [this]; } // Let caller modify the nodes if (map) { targetNode.visit(map, true); } if (targetParent === prevParent) { targetParent.triggerModifyChild("move", this); } else { // prevParent.triggerModifyChild("remove", this); targetParent.triggerModifyChild("add", this); } // Handle cross-tree moves if (tree !== targetNode.tree) { // Fix node.tree for all source nodes // util.assert(false, "Cross-tree move is not yet implemented."); this.logWarn("Cross-tree moveTo is experimental!"); this.visit((n) => { // TODO: fix selection state and activation, ... n.tree = targetNode.tree; }, true); } // Make sure we update async, because discarding the markup would prevent // DragAndDrop to generate a dragend event on the source node setTimeout(() => { // Even indentation may have changed: tree.update(ChangeType.any); }, 0); // TODO: fix selection state // TODO: fix active state } /** Set focus relative to this node and optionally activate. * * 'left' collapses the node if it is expanded, or move to the parent * otherwise. * 'right' expands the node if it is collapsed, or move to the first * child otherwise. * * @param where 'down', 'first', 'last', 'left', 'parent', 'right', or 'up'. * (Alternatively the `event.key` that would normally trigger this move, * e.g. `ArrowLeft` = 'left'. * @param options */ async navigate(where: NavigationType | string, options?: NavigateOptions) { // Allow to pass 'ArrowLeft' instead of 'left' const navType = (KEY_TO_NAVIGATION_MAP[where] ?? where) as NavigationType; // Otherwise activate or focus the related node const node = this.findRelatedNode(navType); if (!node) { this.logWarn(`Could not find related node '${where}'.`); return Promise.resolve(this); } // setFocus/setActive will scroll later (if autoScroll is specified) try { node.makeVisible({ scrollIntoView: false }); } catch (e) { // ignore } node.setFocus(); if (options?.activate === false) { return Promise.resolve(this); } return node.setActive(true, { event: options?.event }); } /** Delete this node and all descendants. */ remove() { const tree = this.tree; const pos = this.parent.children!.indexOf(this); this.triggerModify("remove"); this.parent.children!.splice(pos, 1); this.visit((n) => { n.removeMarkup(); tree._unregisterNode(n); }, true); tree.update(ChangeType.structure); } /** Remove all descendants of this node. */ removeChildren() { const tree = this.tree; if (!this.children) { return; } if (tree.activeNode?.isDescendantOf(this)) { tree.activeNode.setActive(false); // TODO: don't fire events } if (tree.focusNode?.isDescendantOf(this)) { tree._setFocusNode(null); } // TODO: persist must take care to clear select and expand cookies // Unlink children to support GC // TODO: also delete this.children (not possible using visit()) this.triggerModifyChild("remove", null); this.visit((n) => { tree._unregisterNode(n); }); if (this.lazy) { // 'undefined' would be interpreted as 'not yet loaded' for lazy nodes this.children = []; } else { this.children = null; } // util.assert(this.parent); // don't call this for root node if (!this.isRootNode()) { this.expanded = false; } this.tree.update(ChangeType.structure); } /** Remove all HTML markup from the DOM. */ removeMarkup() { if (this._rowElem) { delete (<any>this._rowElem)._wb_node; this._rowElem.remove(); this._rowElem = undefined; } } protected _getRenderInfo(): any { const allColInfosById: ColumnEventInfoMap = {}; const renderColInfosById: ColumnEventInfoMap = {}; const isColspan = this.isColspan(); const colElems = this._rowElem ? ((<unknown>( this._rowElem.querySelectorAll("span.wb-col") )) as HTMLSpanElement[]) : null; let idx = 0; for (const col of this.tree.columns) { allColInfosById[col.id] = { id: col.id, idx: idx, elem: colElems ? colElems[idx] : null, info: col, }; // renderColInfosById only contains columns that need rendering: if (!isColspan && col.id !== "*") { renderColInfosById[col.id] = allColInfosById[col.id]; } idx++; } return { allColInfosById: allColInfosById, renderColInfosById: renderColInfosById, }; } protected _createIcon( parentElem: HTMLElement, replaceChild: HTMLElement | null, showLoading: boolean ): HTMLElement | null { const iconElem = this.tree._createNodeIcon(this, showLoading, true); if (iconElem) { if (replaceChild) { parentElem.replaceChild(iconEl