azure-devops-ui
Version:
React components for building web UI in Azure DevOps
441 lines (439 loc) • 21.5 kB
JavaScript
import { ObservableArray } from '../Core/Observable';
/**
* A TreeItemProvider is designed to store and manage the state of a tree.
* As items are added/removed, expanded/collapsed, the providers "value" will
* represent the current set of ordered items within the tree.
*/
export class TreeItemProvider {
constructor(rootItems = []) {
// Track a map for each ITreeItem being tracked in the tree to its internal ITreeItemEx.
this.itemMap = new Map();
const treeItems = [];
// Make a copy of the initial root items
this.rootItems = [...rootItems];
// Add the root item and its entire sub-tree to the item map and update the items passed to the underlying tree.
this.addItems(rootItems, undefined, treeItems);
// Create the underlying observable we use to manage the underlying table items.
this.tableItems = new ObservableArray(treeItems);
}
get length() {
return this.tableItems.length;
}
get roots() {
return this.rootItems;
}
get value() {
return this.tableItems.value;
}
subscribe(observer, action) {
return this.tableItems.subscribe(observer, action);
}
unsubscribe(observer, action) {
return this.tableItems.unsubscribe(observer, action);
}
/**
* add can be used to add a single item to the tree at a given location. If the
* specified parentItem or insertAfter are not in the tree or the insertAfter is not
* a child of parentItem, the addition will be ignored.
*
* @param item The item to add to the tree.
* @param parentItem The direct parent of the item being added.
* If this is a rootItem leave the parentItem undefined.
* @param insertAfter The sibling item this item should be inserted after.
* If this is the first item within the parent leave insertAfter undefined.
*/
add(item, parentItem, insertAfter) {
return this.splice(parentItem, undefined, [
{
insertAfter,
items: [item]
}
]);
}
/**
* clear can be used to reset the provider. This is an optimized way to
* remove all items from the tree.
*/
clear() {
this.tableItems.splice(0, this.tableItems.length);
this.rootItems.splice(0, this.rootItems.length);
}
/**
* Collapse the node.
*
* @param treeItem The item whose state should be collapsed. If the treeItem is
* not in the tree or if the item is already expanded the method will no-op.
*/
collapse(treeItem) {
treeItem.expanded && this.toggle(treeItem);
}
/**
* Expand the node. Optionally expand all parent nodes.
*
* @param treeItem The item whose state should be expanded. If the treeItem is
* not in the tree the method will no-op.
* @param expandParents If all parent nodes should be expanded. @default false
*/
expand(treeItem, expandParents) {
let treeItemEx = this.itemMap.get(treeItem);
if (!treeItemEx) {
return;
}
const expandNodes = [];
do {
if (!treeItemEx.underlyingItem.expanded) {
expandNodes.push(treeItemEx);
}
treeItemEx = treeItemEx.parentItem;
} while (expandParents && treeItemEx);
for (let index = expandNodes.length - 1; index >= 0; index--) {
this.toggle(expandNodes[index].underlyingItem);
}
}
/**
* Remove the specified item from the tree. If the item doesnt exist the
* remove will be ignored.
*
* @param treeItem The item that should be removed from the tree.
* @param parentItem The parentItem of the item being removed. If the caller
* doesnt have the parentItem readily available it will be computed, but this
* can be expensive. The caller should supply it if they can.
*/
remove(treeItem, parentItem) {
if (!parentItem) {
let itemIndex;
if ((itemIndex = this.indexOf(treeItem)) === -1) {
return;
}
const removeItem = this.tableItems.value[itemIndex];
parentItem = removeItem.parentItem && removeItem.parentItem.underlyingItem;
}
return this.splice(parentItem, [treeItem]);
}
/**
* spliceItems is used to update the direct children of a given item
* in the tree. If the parentItem is supplied and not in the tree the
* changes will be ignored.
*
* @NOTE: Adds are processed before removes, this allows the existing
* elements to be used as an insertAfter for placement. An item can't
* be in both the add and remove set. This will cause duplication.
*
* @param parentItem The item whos children are being updated. If undefined
* is supplied the updates will modify the root.
* @param itemsToRemove The set of items that should removed from the parent.
* @param itemsToAdd The set of items that should be added to the parent.
*/
splice(parentItem, itemsToRemove, itemsToAdd) {
let parentItemEx;
let notifyChange = false;
let parentIndex = -1;
let childItems = this.rootItems;
if (parentItem) {
parentItemEx = this.itemMap.get(parentItem);
if (!parentItemEx) {
return;
}
// Find the location in the table items we need splice changes.
parentIndex = this.indexOf(parentItem);
childItems = parentItemEx.underlyingItem.childItems || [];
}
// Before making any changes make a copy of the removed array, this allows
// the caller to supply the current childItems to clear them.
if (itemsToRemove) {
itemsToRemove = itemsToRemove.slice(0);
}
// We will process the adds before the removes.
if (itemsToAdd) {
for (let index = 0; index < itemsToAdd.length; index++) {
const tableItems = !parentItem || (parentIndex >= 0 && parentItem.expanded) ? [] : undefined;
const itemToAdd = itemsToAdd[index];
let insertIndex = parentIndex;
let childIndex = 0;
// If we are inserting after a specific item find that item. If it
// isn't found, ignore the add.
if (itemToAdd.insertAfter) {
// Ensure the insertAfter is a child of the parentItem.
if ((childIndex = childItems.indexOf(itemToAdd.insertAfter)) === -1) {
continue;
}
// Find the location in the underlying tableItems to insert.
const childInsertIndex = this.indexOf(itemToAdd.insertAfter, parentIndex + 1);
if (childInsertIndex !== -1) {
// Determine the number of elements in table under the insertAfter, we need to insert
// after these elements to ensure the inserted element is a sibling of insertAfter.
insertIndex = childInsertIndex + this.getTableChildCount(itemToAdd.insertAfter);
}
}
else {
// if we add to the front, childIndex needs to be -1 so when we do the +1 later, it will be 0
childIndex--;
}
// Add the items to the overall item map and compute the item being sent to the table.
this.addItems(itemToAdd.items, parentItemEx, tableItems);
// Update the childItems and if this is the first child update the treeItem.
// We will notify if we are adding the first child since the state of the item is changing.
childItems.splice(childIndex + 1, 0, ...itemToAdd.items);
if (parentItemEx && !parentItemEx.underlyingItem.childItems) {
parentItemEx.underlyingItem.childItems = childItems;
notifyChange = true;
}
// Add the new items to the set of table items.
if (tableItems) {
this.tableItems.splice(insertIndex + 1, 0, ...tableItems);
notifyChange = true;
}
}
}
// Now process the removes.
if (itemsToRemove) {
for (let index = 0; index < itemsToRemove.length; index++) {
const itemToRemove = itemsToRemove[index];
const tableItems = !parentItem || parentItem.expanded ? [] : undefined;
let childIndex;
// Ensure the itemToRemove is a child of the parentItem.
if ((childIndex = childItems.indexOf(itemToRemove)) === -1) {
continue;
}
// Remove the items from the underlying map and compute the set of items in the table.
this.removeItem(itemToRemove, tableItems);
// Remove the item from this childItem list. Notify if we removed the last child
// since this is changing the state of the item.
childItems.splice(childIndex, 1);
if (childItems.length === 0 && parentItem) {
delete parentItem.childItems;
notifyChange = true;
}
const removeIndex = this.indexOf(itemToRemove, parentIndex + 1);
if (removeIndex === -1) {
continue;
}
// Remove the items from the underlying observable.
if (tableItems) {
this.tableItems.splice(removeIndex, tableItems.length);
notifyChange = true;
}
}
}
if (notifyChange && parentItemEx && parentIndex !== -1) {
this.tableItems.change(parentIndex, parentItemEx);
}
}
/**
* spliceBatch is used to update the direct children of a given item
* in the tree in batches. It has the same logic as splice, but it
* processes updates sequentially.
* @param batch The array of updates to be applied to the tree sequentially
* one by one.
*/
spliceBatch(batch) {
const orderedBatch = [];
// Everything we will do with a copy of this.tableItems will not trigger any notifications of subscribers
// because creating new ObservableArray doesn't copy subscribers.
const copyOfTableItems = new ObservableArray(this.tableItems.value);
for (const el of batch) {
let parentItemEx;
let notifyChange = false;
let parentIndex = -1;
let childItems = this.rootItems;
if (el.parentItem) {
parentItemEx = this.itemMap.get(el.parentItem);
if (!parentItemEx) {
return;
}
// Find the location in the table items we need splice changes.
parentIndex = this.indexOf(el.parentItem, undefined, copyOfTableItems);
childItems = parentItemEx.underlyingItem.childItems || [];
}
// Before making any changes make a copy of the removed array, this allows
// the caller to supply the current childItems to clear them.
if (el.itemsToRemove) {
el.itemsToRemove = el.itemsToRemove.slice(0);
}
// We will process the adds before the removes.
if (el.itemsToAdd) {
for (let index = 0; index < el.itemsToAdd.length; index++) {
const tableItems = !el.parentItem || (parentIndex >= 0 && el.parentItem.expanded) ? [] : undefined;
const itemToAdd = el.itemsToAdd[index];
let insertIndex = parentIndex;
let childIndex = 0;
// If we are inserting after a specific item find that item. If it
// isn't found, ignore the add.
if (itemToAdd.insertAfter) {
// Ensure the insertAfter is a child of the parentItem.
if ((childIndex = childItems.indexOf(itemToAdd.insertAfter)) === -1) {
continue;
}
// Find the location in the underlying tableItems to insert.
const childInsertIndex = this.indexOf(itemToAdd.insertAfter, parentIndex + 1, copyOfTableItems);
if (childInsertIndex !== -1) {
// Determine the number of elements in table under the insertAfter, we need to insert
// after these elements to ensure the inserted element is a sibling of insertAfter.
insertIndex = childInsertIndex + this.getTableChildCount(itemToAdd.insertAfter);
}
}
else {
// if we add to the front, childIndex needs to be -1 so when we do the +1 later, it will be 0
childIndex--;
}
// Add the items to the overall item map and compute the item being sent to the table.
this.addItems(itemToAdd.items, parentItemEx, tableItems);
// Update the childItems and if this is the first child update the treeItem.
// We will notify if we are adding the first child since the state of the item is changing.
childItems.splice(childIndex + 1, 0, ...itemToAdd.items);
if (parentItemEx && !parentItemEx.underlyingItem.childItems) {
parentItemEx.underlyingItem.childItems = childItems;
notifyChange = true;
}
// Add the new items to the set of table items.
if (tableItems) {
const itemsToInsert = el.ignoreDuplicatesOnAdding
? tableItems.filter(tableItem => !copyOfTableItems.value.some(x => x.underlyingItem == tableItem.underlyingItem))
: tableItems;
copyOfTableItems.splice(insertIndex + 1, 0, ...itemsToInsert);
notifyChange = true;
}
}
}
// Now process the removes.
if (el.itemsToRemove) {
for (let index = 0; index < el.itemsToRemove.length; index++) {
const itemToRemove = el.itemsToRemove[index];
const tableItems = !el.parentItem || el.parentItem.expanded ? [] : undefined;
let childIndex;
// Ensure the itemToRemove is a child of the parentItem.
if ((childIndex = childItems.indexOf(itemToRemove)) === -1) {
continue;
}
// Remove the items from the underlying map and compute the set of items in the table.
this.removeItem(itemToRemove, tableItems);
// Remove the item from this childItem list. Notify if we removed the last child
// since this is changing the state of the item.
childItems.splice(childIndex, 1);
if (childItems.length === 0 && el.parentItem) {
delete el.parentItem.childItems;
notifyChange = true;
}
const removeIndex = this.indexOf(itemToRemove, parentIndex + 1, copyOfTableItems);
if (removeIndex === -1) {
continue;
}
// Remove the items from the underlying observable.
if (tableItems) {
copyOfTableItems.splice(removeIndex, tableItems.length);
notifyChange = true;
}
}
}
if (notifyChange && parentItemEx && parentIndex !== -1) {
orderedBatch.push({ start: parentIndex, items: [parentItemEx] });
}
}
// Update tableItems at a heat.
this.tableItems.splice(0, this.tableItems.length, ...copyOfTableItems.value);
if (orderedBatch.length) {
this.tableItems.changeOrderedBatch(orderedBatch);
}
}
/**
* toggleItem is used to toggle the expand/collapse state of a given tree item.
*
* @param treeItem The item whose state should be toggled. If the treeItem is
* not in the tree the method will no-op.
*/
toggle(treeItem) {
const itemIndex = this.indexOf(treeItem);
// Toggle the expanded state of the treeItem.
treeItem.expanded = !treeItem.expanded;
if (itemIndex >= 0) {
if (treeItem.childItems) {
const tableItems = [];
// Get the set of children being added to the underlying array.
for (let index = 0; index < treeItem.childItems.length; index++) {
this.getTableItems(treeItem.childItems[index], tableItems);
}
// We need to update the underlying observables items.
if (treeItem.expanded) {
this.tableItems.splice(itemIndex + 1, 0, ...tableItems);
}
else {
this.tableItems.splice(itemIndex + 1, tableItems.length);
}
}
}
}
addItems(treeItems, parentItem, tableItems) {
for (let index = 0; index < treeItems.length; index++) {
const treeItem = treeItems[index];
const treeItemEx = { depth: parentItem ? parentItem.depth + 1 : 0, parentItem, underlyingItem: treeItem };
// Add this treeItem and computed treeItemEx to the map.
this.itemMap.set(treeItem, treeItemEx);
// If the caller requested the set of items that should be given to the table.
if (tableItems) {
tableItems.push(treeItemEx);
}
// Go through all the children and add them to the map, and if it is expanded
// we will forward the current tableItems array.
if (treeItem.childItems) {
this.addItems(treeItem.childItems, treeItemEx, treeItem.expanded ? tableItems : undefined);
}
}
}
getTableItems(treeItem, tableItems) {
const treeItemEx = this.itemMap.get(treeItem);
if (treeItemEx) {
tableItems.push(treeItemEx);
if (treeItem.childItems && treeItem.expanded) {
for (const childItem of treeItem.childItems) {
this.getTableItems(childItem, tableItems);
}
}
}
}
getTableChildCount(treeItem) {
let count = 0;
if (treeItem.childItems && treeItem.expanded) {
for (let index = 0; index < treeItem.childItems.length; index++) {
count += this.getTableChildCount(treeItem.childItems[index]) + 1;
}
}
return count;
}
/**
* indexOfItem is used to find the index of a source treeItem. The underlying
* observable indexOf will find instances of ITreeItemEx. This returns the
* index from the set of items passed to the table.
*
* @param treeItem The item to find in the tree.
* @param fromIndex The index to start the search.
* @param tableItems TreeProvider to search at. this.tableItems will be used as default if not provided.
* @returns If the item is found an index >= 0 is returned, if it is not found -1 is returned.
*/
indexOf(treeItem, fromIndex = 0, tableItems) {
if (tableItems === undefined) {
tableItems = this.tableItems;
}
// @TODO: Can we come up with a faster method than this.
for (let index = fromIndex; index < tableItems.length; index++) {
if (treeItem === tableItems.value[index].underlyingItem) {
return index;
}
}
return -1;
}
removeItem(treeItem, tableItems) {
// Add this treeItem and computed treeItemEx to the map.
this.itemMap.delete(treeItem);
// If the caller requested the set of items that should be given to the table.
if (tableItems) {
tableItems.push(treeItem);
}
// Go through all the children and add them to the map, and if it is expanded
// we will forward the current tableItems array.
if (treeItem.childItems) {
for (let childIndex = 0; childIndex < treeItem.childItems.length; childIndex++) {
this.removeItem(treeItem.childItems[childIndex], treeItem.expanded ? tableItems : undefined);
}
}
}
}