/**
* A lightweight component to display data in a simple tree structure using a
* {@link Ext.data.TreeStore}.
*
* Simple Treelist using inline data:
*
* @example
* Ext.create({
* xtype: 'treelist',
* store: {
* root: {
* expanded: true,
* children: [{
* text: 'detention',
* leaf: true,
* iconCls: 'x-fa fa-frown-o'
* }, {
* text: 'homework',
* expanded: true,
* iconCls: 'x-fa fa-folder',
* children: [{
* text: 'book report',
* leaf: true,
* iconCls: 'x-fa fa-book'
* }, {
* text: 'algebra',
* leaf: true,
* iconCls: 'x-fa fa-graduation-cap'
* }]
* }, {
* text: 'buy lottery tickets',
* leaf: true,
* iconCls: 'x-fa fa-usd'
* }]
* }
* },
* renderTo: Ext.getBody()
* });
*
* To collapse the Treelist for use in a smaller navigation view see {@link #micro}.
* Parent Treelist node expansion may be refined using the {@link #singleExpand} and
* {@link #expanderOnly} config options. Treelist nodes will be selected when clicked /
* tapped excluding clicks on the expander unless {@link #selectOnExpander} is set to
* `true`.
*
* @since 6.0.0
*/
Ext.define('Ext.list.Tree', {
extend: 'Ext.Gadget',
xtype: 'treelist',
mixins: [
'Ext.mixin.ItemRippler'
],
requires: [
'Ext.list.RootTreeItem'
],
expanderFirstCls: Ext.baseCSSPrefix + 'treelist-expander-first',
expanderOnlyCls: Ext.baseCSSPrefix + 'treelist-expander-only',
highlightPathCls: Ext.baseCSSPrefix + 'treelist-highlight-path',
microCls: Ext.baseCSSPrefix + 'treelist-micro',
uiPrefix: Ext.baseCSSPrefix + 'treelist-',
/**
* @property element
* @inheritdoc
*/
element: {
reference: 'element',
cls: Ext.baseCSSPrefix + 'treelist ' + Ext.baseCSSPrefix + 'unselectable',
listeners: {
click: 'onClick',
touchstart: 'onTouchStart',
touchend: 'onTouchEnd',
mouseenter: 'onMouseEnter',
mouseleave: 'onMouseLeave',
mouseover: 'onMouseOver'
},
children: [{
reference: 'toolsElement',
cls: Ext.baseCSSPrefix + 'treelist-toolstrip',
listeners: {
click: 'onToolStripClick',
mouseover: 'onToolStripMouseOver'
}
}]
},
cachedConfig: {
animation: {
duration: 500,
easing: 'ease'
},
/**
* @cfg {Boolean} expanderFirst
* `true` to display the expander to the left of the item text.
* `false` to display the expander to the right of the item text.
*/
expanderFirst: true,
/**
* @cfg {Boolean} expanderOnly
* `true` to expand only on the click of the expander element. Setting this to
* `false` will allow expansion on click of any part of the element.
*/
expanderOnly: true
},
config: {
/**
* @cfg {Boolean} floatLeafItems
* `true` to allow the popout to show on leaf items on click/tap. This is the same popout
* (menu) non-leaf items show their child items in. `false` to prevent the popout
* from showing for leaf items.
*/
floatLeafItems: false,
/**
* @cfg {Object} [defaults]
* The default configuration for the widgets created for tree items.
*
* @cfg {String} [defaults.xtype="treelistitem"]
* The type of item to create. By default, items are
* `{@link Ext.list.TreeItem treelistitem}` instances. This can be customized but this
* `xtype` must reference a class that ultimately derives from the
* `{@link Ext.list.AbstractTreeItem}` base class.
*/
defaults: {
xtype: 'treelistitem'
},
/**
* @cfg {Boolean}
* Set as `true` to highlight all items on the path to the currently selected
* node.
*/
highlightPath: null,
iconSize: null,
/**
* @cfg {Number} [indent=null]
*
* The number of pixels to offset each level of tree nodes.
*/
indent: null,
/**
* @cfg {Boolean}
*
* Set to `true` to collapse the Treelist UI to display only the
* {@link Ext.data.NodeInterface#cfg-iconCls icons} of the root nodes. Hovering
* the cursor (or tapping on a touch-enabled device) shows the child nodes beside
* the icon.
*/
micro: false,
overItem: null,
/**
* @cfg {Ext.data.TreeModel/Number/String} selection
*
* The current selected node or its ID.
*/
selection: null,
/**
* @cfg {Boolean} selectOnExpander
* `true` to select the node when clicking the expander.
*/
selectOnExpander: false,
/**
* @cfg {Boolean} [singleExpand=false]
* `true` if only 1 node per branch may be expanded.
*/
singleExpand: null,
/**
* @cfg {String/Object/Ext.data.TreeStore} store
* The data source to which this component is bound.
*/
store: null,
/**
* @cfg ui
* @inheritdoc
*/
ui: null
},
/**
* @event selectionchange
* This event fires when {@link Ext.list.Tree#selection} changes
* @param {Ext.list.Tree} treelist The component firing this event.
* @param {Ext.data.TreeModel} record The currently selected node.
*/
/**
* @cfg twoWayBindable
* @inheritdoc
*/
twoWayBindable: {
selection: 1
},
/**
* @cfg publishes
* @inheritdoc
*/
publishes: {
selection: 1
},
/**
* @property defaultBindProperty
* @inheritdoc
*/
defaultBindProperty: 'store',
constructor: function(config) {
this.callParent([config]);
// Important to publish the value here, so the vm can
// will know our intial state.
this.publishState('selection', this.getSelection());
},
focusable: true,
tabIndex: 0,
/**
* @cfg keyMap
* @inheritdoc
*/
keyMap: {
scope: 'this',
UP: 'onKeyUp',
DOWN: 'onKeyDown',
LEFT: 'onKeyLeft',
RIGHT: 'onKeyRight'
},
/**
* Called when the up arrow key is clicked
* Facilitates keyboard navigation by selecting next item on the list.
* @param {Ext.event.Event} keyEvent
* @param {Ext.list.Tree} treeList Current instance of treeList
*/
onKeyUp: function(keyEvent, treeList) {
var selectedModel = treeList.getSelection(),
previousSibling = selectedModel ? selectedModel.previousSibling : null;
if (!selectedModel) {
return;
}
if (previousSibling) {
// Go to the last child of last expanded child of previous sibling.
// Otherwise go to the previous sibling.
selectedModel = previousSibling;
while (treeList.getItem(selectedModel).getExpanded() && selectedModel.lastChild) {
selectedModel = selectedModel.lastChild;
}
}
else if (!selectedModel.parentNode.isRoot()) {
// No change in selection if first item of the list is selected
selectedModel = selectedModel.parentNode;
}
treeList.setSelection(selectedModel);
},
/**
* Called when the down arrow key is clicked
* Facilitates keyboard navigation by selecting next item on the list.
* @param {Ext.event.Event} keyEvent
* @param {Ext.list.Tree} treeList Current instance of treeList
*/
onKeyDown: function(keyEvent, treeList) {
var currentModel,
isLastNode = false,
selectedModel = treeList.getSelection(),
selectedItem = treeList.getItem(selectedModel);
if (!selectedItem) {
return;
}
if (selectedItem.getExpanded() && selectedModel.firstChild) {
// If selected item is expanded go it its first child
selectedModel = selectedModel.firstChild;
}
else if (selectedModel.nextSibling) {
// Select next sibling otherwise
selectedModel = selectedModel.nextSibling;
}
else {
currentModel = treeList.getSelection();
// Find node that contains next sibling, up to
// the first level in the heirarchy. If no such
// node found till root, it means this is the
// last node in the list.
while (!selectedModel.parentNode.nextSibling) {
if (selectedModel.parentNode.isRoot()) {
selectedModel = currentModel;
isLastNode = true;
break;
}
selectedModel = selectedModel.parentNode;
}
if (!isLastNode) {
selectedModel = selectedModel.parentNode.nextSibling;
}
}
treeList.setSelection(selectedModel);
},
/**
* Called when the left arrow key is clicked
* Collapses the selected list item.
* @param {Ext.event.Event} keyEvent
* @param {Ext.list.Tree} treeList Current instance of treeList
*/
onKeyLeft: function(keyEvent, treeList) {
var selectedModel = treeList.getSelection(),
selectedItem = treeList.getItem(selectedModel);
if (selectedItem) {
selectedItem.collapse();
}
},
/**
* Called when the right arrow key is clicked
* Expands the selected list item.
* @param {Ext.event.Event} keyEvent
* @param {Ext.list.Tree} treeList Current instance of treeList
*/
onKeyRight: function(keyEvent, treeList) {
var selectedModel = treeList.getSelection(),
selectedItem = treeList.getItem(selectedModel);
if (selectedItem) {
selectedItem.expand();
}
},
destroy: function() {
var me = this;
me.unfloatAll();
me.activeFloater = null;
me.setSelection(null);
me.setStore(null);
me.callParent();
},
updateOverItem: function(over, wasOver) {
var map = {},
state = 2,
c, node;
// Walk up the node hierarchy starting at the "over" item and set their "over"
// config appropriately (2 when over that row, 1 when over a descendant).
//
for (c = over; c; c = this.getItem(node.parentNode)) {
node = c.getNode();
map[node.internalId] = true;
c.setOver(state);
state = 1;
}
// There are some cases, like tree filtering where it's possible that the whole tree
// gets refreshed on expand, so wasOver may be destroyed. In that case, we have nothing to
// do since the nodes are in a new state
if (wasOver && !wasOver.destroyed) {
// If we wasOver something else previously, walk up that node hierarchy and
// set their "over" to 0... until we encounter some node that we are still
// "over" (as determined in previous loop).
//
for (c = wasOver; c; c = this.getItem(node.parentNode)) {
node = c.getNode();
if (map[node.internalId]) {
break;
}
c.setOver(0);
}
}
},
applyMicro: function(micro) {
return Boolean(micro);
},
applySelection: function(selection, oldSelection) {
var store = this.getStore();
if (!store) {
selection = null;
}
if (store && selection !== null && !(selection instanceof Ext.data.Model)) {
selection = store.getNodeById(selection);
}
if (selection && selection.get('selectable') === false) {
selection = oldSelection;
}
return selection;
},
updateSelection: function(selection, oldSelection) {
var me = this,
item,
parent;
if (!me.destroying) {
// getItem has guards around null, so we don't
// need to check for oldSelection/selection here
item = me.getItem(oldSelection);
if (item) {
item.setSelected(false);
}
item = me.getItem(selection);
if (item) {
item.setSelected(true);
while (parent = item.getParentItem()) { // eslint-disable-line no-cond-assign
parent.setExpanded(true);
item = parent;
}
}
me.fireEvent('selectionchange', me, selection);
}
},
applyStore: function(store) {
return store && Ext.StoreManager.lookup(store, 'tree');
},
updateStore: function(store, oldStore) {
var me = this,
root;
if (oldStore) {
// Store could be already destroyed upstream
if (!oldStore.destroyed) {
if (oldStore.getAutoDestroy()) {
oldStore.destroy();
}
else {
me.storeListeners.destroy();
}
}
me.removeRoot();
me.storeListeners = null;
}
if (store) {
me.storeListeners = store.on({
destroyable: true,
scope: me,
nodeappend: 'onNodeAppend',
nodecollapse: 'onNodeCollapse',
nodeexpand: 'onNodeExpand',
nodeinsert: 'onNodeInsert',
noderemove: 'onNodeRemove',
rootchange: 'onRootChange',
update: 'onNodeUpdate',
refresh: 'onRefresh'
});
root = store.getRoot();
if (root) {
me.createRootItem(root);
}
}
if (!me.destroying) {
me.updateLayout();
}
},
updateExpanderFirst: function(expanderFirst) {
this.element.toggleCls(this.expanderFirstCls, expanderFirst);
},
updateExpanderOnly: function(value) {
this.element.toggleCls(this.expanderOnlyCls, !value);
},
updateHighlightPath: function(updatePath) {
this.element.toggleCls(this.highlightPathCls, updatePath);
},
updateMicro: function(micro) {
var me = this;
if (!micro) {
me.unfloatAll();
me.activeFloater = null;
}
me.element.toggleCls(me.microCls, micro);
},
updateUi: function(ui, oldValue) {
var me = this,
el = me.element,
uiPrefix = me.uiPrefix;
if (oldValue) {
el.removeCls(uiPrefix + oldValue);
}
if (ui) {
el.addCls(uiPrefix + ui);
}
// Ensure that the cached iconSize is read from the style.
delete me.iconSize;
me.syncIconSize();
},
/**
* Get a child {@link Ext.list.AbstractTreeItem item} by node.
* @param {Ext.data.TreeModel} node The node.
* @return {Ext.list.AbstractTreeItem} The item. `null` if not found.
*/
getItem: function(node) {
var map = this.itemMap,
ret;
if (node && map) {
ret = map[node.internalId];
}
return ret || null;
},
/**
* This method is called to populate and return a config object for new nodes. This
* can be overridden by derived classes to manipulate properties or `xtype` of the
* returned object. Upon return, the object is passed to `{@link Ext#method!create}` and the
* reference is stored as part of this tree.
*
* The base class implementation will apply any configured `{@link #defaults}` to the
* object it returns.
*
* @param {Ext.data.TreeModel} node The node backing the item.
* @param {Ext.list.AbstractTreeItem} parent The parent item. This is never `null` but
* may be an instance of `{@link Ext.list.RootTreeItem}`.
* @return {Object} The config object to pass to `{@link Ext#method!create}` for the item.
* @template
*/
getItemConfig: function(node, parent) {
return Ext.apply({
parentItem: parent.isRootListItem ? null : parent,
owner: this,
node: node,
indent: this.getIndent()
}, this.getDefaults());
},
privates: {
checkForOutsideClick: function(e) {
var floater = this.activeFloater;
if (!floater.element.contains(e.target)) {
this.unfloatAll();
}
},
collapsingForExpand: false,
/**
* Create a new list item.
* @param {Ext.data.TreeModel} node The node backing the item.
* @param {Ext.list.AbstractTreeItem} parent The parent item.
* @return {Ext.list.AbstractTreeItem} The list item.
*
* @private
*/
createItem: function(node, parent) {
var me = this,
item = Ext.create(me.getItemConfig(node, parent)),
toolsElement = me.toolsElement,
toolEl, previousSibling;
if (parent.isRootListItem) {
toolEl = item.getToolElement();
if (toolEl) {
previousSibling = me.findVisiblePreviousSibling(node);
if (!previousSibling) {
toolsElement.insertFirst(toolEl);
}
else {
previousSibling = me.getItem(previousSibling);
toolEl.insertAfter(previousSibling.getToolElement());
}
toolEl.dom.setAttribute('data-recordId', node.internalId);
toolEl.isTool = true;
}
}
me.itemMap[node.internalId] = item;
return item;
},
/**
* Create a root item for this list.
* @param {Ext.data.TreeModel} root The root node.
*
* @private
*/
createRootItem: function(root) {
var me = this,
item;
me.itemMap = {};
me.rootItem = item = new Ext.list.RootTreeItem({
indent: me.getIndent(),
node: root,
owner: me
});
me.element.appendChild(item.element);
me.itemMap[root.internalId] = item;
},
findVisiblePreviousSibling: function(node) {
var sibling = node.previousSibling;
while (sibling) {
if (sibling.data.visible) {
return sibling;
}
sibling = sibling.previousSibling;
}
return null;
},
floatItem: function(item, byHover) {
var me = this,
floater;
if (item.getFloated()) {
return;
}
// Cancel any mouseout timer,
if (me.toolMouseListeners) {
me.toolMouseListeners.destroy();
me.floaterMouseListeners.destroy();
me.floaterMouseListeners = me.toolMouseListeners = null;
}
me.unfloatAll();
if (!byHover && !me.getFloatLeafItems() && item.getNode().isLeaf()) {
return;
}
me.activeFloater = floater = item;
me.floatedByHover = byHover;
item.setFloated(true);
if (byHover) {
// monitorMouseLeave allows straying out for the specified short time
me.toolMouseListeners =
item.getToolElement().monitorMouseLeave(300, me.checkForMouseLeave, me);
me.floaterMouseListeners =
(item.floater || item).el.monitorMouseLeave(300, me.checkForMouseLeave, me);
floater.element.on('mouseover', 'onMouseOver', me);
}
else {
Ext.on('mousedown', 'checkForOutsideClick', me);
}
},
shouldRippleItem: function(item, e) {
if (item && item.getSelected()) {
return false;
}
return this.mixins.itemrippler.shouldRippleItem.call(this, item, e);
},
onTouchStart: function(e) {
this.doItemRipple(e);
},
onTouchEnd: function(e) {
this.doItemRipple(e);
},
doItemRipple: function(e) {
var me = this,
item = e.getTarget('[data-recordId]'),
id;
if (item) {
id = item.getAttribute('data-recordId');
item = me.itemMap[id];
if (item && me.shouldRippleItem(item, e)) {
this.rippleItem(item, e);
}
}
},
/**
* Handles when this element is clicked.
* @param {Ext.event.Event} e The event.
*
* @private
*/
onClick: function(e) {
var item = e.getTarget('[data-recordId]'),
id;
if (item) {
id = item.getAttribute('data-recordId');
item = this.itemMap[id];
if (item) {
item.onClick(e);
}
}
},
onMouseEnter: function(e) {
this.onMouseOver(e);
},
onMouseLeave: function() {
this.setOverItem(null);
},
onMouseOver: function(e) {
var comp = Ext.Component.from(e);
this.setOverItem(comp && comp.isTreeListItem && comp);
},
checkForMouseLeave: function(e) {
var floater = this.activeFloater,
relatedTarget = e.getRelatedTarget();
if (floater) {
if (relatedTarget !== floater.getToolElement().dom &&
!floater.element.contains(relatedTarget)) {
this.unfloatAll();
}
}
},
/**
* Handles a node being appended to a parent.
* @param {Ext.data.TreeModel} parentNode The parent node.
* @param {Ext.data.TreeModel} node The appended node.
*
* @private
*/
onNodeAppend: function(parentNode, node) {
var item;
// If it's a root we'll handle it on rootchange
if (parentNode) {
item = this.itemMap[parentNode.internalId];
if (item) {
item.nodeInsert(node, null);
}
}
},
/**
* Handles when a node collapses.
* @param {Ext.data.TreeModel} node The node.
*
* @private
*/
onNodeCollapse: function(node) {
var item = this.itemMap[node.internalId];
if (item) {
item.nodeCollapse(node, this.collapsingForExpand);
}
},
/**
* Handles when a node expands.
* @param {Ext.data.TreeModel} node The node.
*
* @private
*/
onNodeExpand: function(node) {
var me = this,
item = me.itemMap[node.internalId],
childNodes, len, i, parentNode, child;
if (item) {
if (!item.isRootItem && me.getSingleExpand()) {
me.collapsingForExpand = true;
parentNode = (item.getParentItem() || me.rootItem).getNode();
childNodes = parentNode.childNodes;
for (i = 0, len = childNodes.length; i < len; ++i) {
child = childNodes[i];
if (child !== node) {
child.collapse();
}
}
me.collapsing = false;
}
item.nodeExpand(node);
}
},
/**
* Handles a node being inserted into a parent.
* @param {Ext.data.TreeModel} parentNode The parent node.
* @param {Ext.data.TreeModel} node The inserted node.
* @param {Ext.data.TreeModel} refNode The node this was inserted before.
*
* @private
*/
onNodeInsert: function(parentNode, node, refNode) {
var item = this.itemMap[parentNode.internalId];
if (item) {
item.nodeInsert(node, refNode);
}
},
/**
* Handles a node being removed from a parent.
* @param {Ext.data.TreeModel} parentNode The parent node.
* @param {Ext.data.TreeModel} node The removed node.
* @param {Boolean} isMove `true` if this node is moving inside the tree.
*
* @private
*/
onNodeRemove: function(parentNode, node, isMove) {
var item;
// If it's a move we don't need to do anything, we won't process it
// as a removal, the addition will handle it all.
// Also if the node being removed is the root we'll handle it in rootchange
if (parentNode && !isMove) {
item = this.itemMap[parentNode.internalId];
if (item) {
item.nodeRemove(node);
}
}
},
/**
* Handles when a node updates.
* @param {Ext.data.TreeStore} store The store.
* @param {Ext.data.TreeModel} node The node.
* @param {String} type The update type.
* @param {String[]} modifiedFieldNames The modified field names, if known.
*
* @private
*/
onNodeUpdate: function(store, node, type, modifiedFieldNames) {
var item = this.itemMap[node.internalId];
if (item) {
item.nodeUpdate(node, modifiedFieldNames);
}
},
/**
* Handles before a root node loads
* @param {Ext.data.TreeStore} store The store.
* @private
*/
onRefresh: function(store) {
// T-28372 : Ignore root change on expand, if child node is available.
if (this.skipNextRefresh) {
return;
}
// Because the tree can use bottom up or top down filtering (or reload),
// don't try and figure out, what changed here
// just do a global refresh
this.onRootChange(store.getRoot());
},
/**
* Handles when the root node in the tree changes.
* @param {Ext.data.TreeModel} root The root.
*
* @private
*/
onRootChange: function(root) {
var me = this;
me.removeRoot();
if (root) {
me.createRootItem(root);
}
me.updateLayout();
me.fireEvent('refresh', me);
},
/**
* Removes a list item.
* @param {Ext.data.TreeModel} node The node backing the item.
*
* @private
*/
removeItem: function(node) {
var map = this.itemMap,
id = node.internalId,
item, toolEl;
if (map) {
item = map[id];
// If it's null, it means it's a root level item
if (item.getParentItem() === null) {
toolEl = item.getToolElement();
if (toolEl) {
this.toolsElement.removeChild(toolEl);
}
}
delete map[id];
}
},
removeRoot: function() {
var me = this,
rootItem = me.rootItem;
if (rootItem) {
me.element.removeChild(rootItem.element);
me.rootItem = me.itemMap = Ext.destroy(rootItem);
}
},
/**
* Handles when the toolstrip has a click.
* @param {Ext.event.Event} e The event.
*
* @private
*/
onToolStripClick: function(e) {
var item = e.getTarget('[data-recordId]'),
id;
if (item) {
id = item.getAttribute('data-recordId');
item = this.itemMap[id];
if (item) {
if (item === this.activeFloater) {
this.unfloatAll();
}
else {
this.floatItem(item, false);
}
}
}
},
/**
* Handles when the toolstrip has a mouseover.
* @param {Ext.event.Event} e The event.
*
* @private
*/
onToolStripMouseOver: function(e) {
var item = e.getTarget('[data-recordId]'),
id;
if (item) {
id = item.getAttribute('data-recordId');
item = this.itemMap[id];
if (item) {
this.floatItem(item, true);
}
}
},
syncIconSize: function() {
var me = this,
size = me.iconSize ||
(me.iconSize = parseInt(me.element.getStyle('background-position'), 10));
me.setIconSize(size);
},
unfloatAll: function() {
var me = this,
floater = me.activeFloater;
if (floater) {
floater.setFloated(false);
me.activeFloater = null;
if (me.floatedByHover) {
if (me.toolMouseListeners) {
me.toolMouseListeners.destroy();
me.floaterMouseListeners.destroy();
me.floaterMouseListeners = me.toolMouseListeners = null;
}
floater.element.un('mouseover', 'onMouseOver', me);
}
else {
Ext.un('mousedown', 'checkForOutsideClick', me);
}
}
},
defaultIconSize: 22,
updateIconSize: function(value) {
this.setIndent(value || this.defaultIconSize);
},
updateIndent: function(value) {
var rootItem = this.rootItem;
if (rootItem) {
rootItem.setIndent(value);
}
}
}
});