UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

441 lines (439 loc) 21.5 kB
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); } } } }