wunderbaum
Version:
JavaScript tree/grid/treegrid control.
1,633 lines (1,503 loc) • 88.1 kB
text/typescript
/*!
* 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