UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

367 lines (366 loc) 17.1 kB
import { ObservableArray } from '../Core/Observable'; import { ListBoxItemType } from '../ListBox'; export class GroupedItemProvider { /** * Create a Provider that arranges IListBoxItems by their groupId. * @param initialItems The initial set of items. Items will be arranged in group order, * and dividers and headers will be moved to the top of each group. * @param initialGroups The initial set of groups. Items will be arranged in the order specified by this set. * @param manageHeaders Set to true to have the provider create headers and dividers for groups that don't already have them. * @param omitDividers Set to true to not add dividers between groups. * Headers created this way will have text matching the group's name. */ constructor(initialItems, initialGroups, manageHeaders, omitDividers) { this.listItems = new ObservableArray(); this.internalGroups = [...initialGroups]; // Initialize with one array for the unassigned group. this.groupedItems = [[]]; for (let i = 0; i < this.internalGroups.length; i++) { this.groupedItems.push([]); } this.addItems(initialItems); for (let i = 0; i < this.internalGroups.length; i++) { if (this.internalGroups[i].loading) { this.setGroupLoading(this.internalGroups[i].id, true, this.internalGroups[i].loadingItem); } } this.omitDividers = !!omitDividers; if (manageHeaders) { // Add headers and didviders to groups that don't already have them. this.addHeaders(1, this.groups.length); } this.manageHeaders = !!manageHeaders; } /** * Get the internal array of groups. */ get groups() { return this.internalGroups; } /** * Get the length of the listItems array. */ get length() { return this.listItems.length; } /** * Get the interal array of listItems. */ get value() { return this.listItems.value; } /** * Subscribe to changes in the underlying set of items. * @param observer the delegate to be called when there are updates. * @param action the action on the set to observe. */ subscribe(observer, action) { return this.listItems.subscribe(observer, action); } /** * Unsubscribe from changes in the underlying set of items. * @param observer the delegate that was used to subscribe. * @param action the action that was used to subsribe. */ unsubscribe(observer, action) { return this.listItems.unsubscribe(observer, action); } /** * Add items to the end of whichever group they belong to. * @param items a list of items to add. */ push(...items) { this.addItems(items); return items.length; } /** * Add groups to the end of the group list. If there are items with these group id's already in the * item set they will arrange into these new groups. * @param groups */ pushGroups(...groups) { this.addGroups(this.internalGroups.length, ...groups); return this.internalGroups.length; } /** * Remove the last item in the item set and return it. */ pop() { const removedItem = this.listItems.pop(); if (removedItem) { this.removeItems([removedItem]); } return removedItem; } /** * Remove all items that match the given filter. * @param filter the filter function to run on all items. If this returns true, the item will be deleted. */ removeAll(filter) { const removedItems = this.listItems.removeAll(filter); this.removeItems(removedItems, false); return removedItems; } /** * Remove and add items from a provided index. Added items will be arranged by their groupId. * @param start the index to start insertion and deletion. * @param deleteCount the number of items to delete. * @param itemsToAdd the items to insert at the start index. */ splice(start, deleteCount, ...itemsToAdd) { const removedItems = this.listItems.splice(start, deleteCount); this.removeItems(removedItems, false); this.addItems(itemsToAdd, true, start); return removedItems; } /** * Changes a subsection of the items to a different set of items. Use over splice if you want to optimize by listening to the change event instead. * @param start the index to start the change. * @param itemsToAdd the items to replace the current set with. */ change(start, ...items) { const changedItems = this.listItems.value.slice(start, items.length); this.removeItems(changedItems, false); this.addItems(items, false); this.listItems.change(start, ...items); return items.length; } /** * Remove and add groups from a provided index. All items in deleted groups will be removed from the item set. * @param start the index to start insertion and deletion. * @param deleteCount the number of groups to delete. * @param groupsToAdd the groups to insert at the start index. */ spliceGroups(start, deleteCount, ...groupsToAdd) { for (let i = 0; i < deleteCount; i++) { const groupIndex = start + i + 1; if (this.groupedItems.length > groupIndex) { this.removeItems([...this.groupedItems[groupIndex]]); if (this.manageHeaders) { // If the first item is now a divider, remove it. const firstGroupWithItemsIndex = this.groupedItems.findIndex(items => items.length > 0); if (firstGroupWithItemsIndex > 0 && this.groupedItems[firstGroupWithItemsIndex][0].type === ListBoxItemType.Divider) { this.removeItems([this.groupedItems[firstGroupWithItemsIndex][0]]); } } this.groupedItems.splice(groupIndex, 1); } } const deletedGroups = this.internalGroups.splice(start, deleteCount); this.addGroups(start, ...groupsToAdd); return deletedGroups; } /** * Set a group's loading status to true or false. A loading item will be added or removed from the group. * @param groupId The group to set the loading status of. * @param loading Set to true to add a loading row. Set to false to remove a loading row. * @param loadingItem Provide this to use a custom loading item, otherwise a standard spinner item will be used. */ setGroupLoading(groupId, loading, loadingItem) { let groupIndex = this.groups.findIndex(group => group.id === groupId); if (groupIndex < 0) { if (false) { console.warn("Tried to set loading on group " + groupId + " that is not in the group set."); } return; } // Increase by 1 for the unassigned group. groupIndex++; const groupItems = this.groupedItems[groupIndex]; const loadingItemIndex = groupItems.findIndex(item => item.type === ListBoxItemType.Loading); if (!loading && groupItems.length && loadingItemIndex > -1) { this.removeItems([groupItems[loadingItemIndex]]); } else if (loading && loadingItemIndex === -1) { const newItem = loadingItem || { id: groupId + "-loading", type: ListBoxItemType.Loading, groupId: groupId }; this.addItems([newItem]); } this.groups[groupIndex - 1].loading = loading; } /** * Add headers and didvers to groups that don't already have them. * @param start the groupMap index to add header/dividers to. * This is 1 more than the internalGroup index since there is an unassigned group at index 0. * @param count the number of groups that are new and may need headers. */ addHeaders(start, count = 1) { for (let i = start; i < start + count; i++) { let group = this.groups[i - 1]; if (this.groupedItems[i].length > 0) { if (!this.groupedItems[i].find(item => item.type === ListBoxItemType.Header)) { this.addItems([{ id: group.id + "-header", text: group.name || group.id, type: ListBoxItemType.Header, groupId: group.id }]); } // If there's a group before us with items, add a divider. if (this.groupedItems.some((items, index) => index < i && items.length > 0) && !this.groupedItems[i].find(item => item.type === ListBoxItemType.Divider) && !this.omitDividers) { this.addItems([{ id: group.id + "-divider", type: ListBoxItemType.Divider, groupId: group.id }]); } // If the next group with items doesn't have a divider, add one. const nextGroupWithItemsIndex = this.groupedItems.findIndex((items, index) => index > i && items.length > 0); if (nextGroupWithItemsIndex > 0 && !this.groupedItems[nextGroupWithItemsIndex].find(item => item.type === ListBoxItemType.Divider) && !this.omitDividers) { group = this.groups[nextGroupWithItemsIndex - 1]; this.addItems([{ id: group.id + "-divider", type: ListBoxItemType.Divider, groupId: group.id }]); } } } } /** * Add groups to the internal list of groups. Items from the unsassigned group will be moved to the new groups * If they have a matching groupId. * @param index the index to add the groups at. * @param groups the groups to add. */ addGroups(index, ...groups) { this.internalGroups.splice(index, 0, ...groups); const unassignedItemsToAdd = []; for (let i = 0; i < groups.length; i++) { this.groupedItems.splice(index + i + 1, 0, []); // Move all items from the unassigned group to the new group if they have a matching groupId. for (let j = this.groupedItems[0].length - 1; j >= 0; j--) { if (this.groupedItems[0][j].groupId === groups[i].id) { const unassignedItemToAdd = this.groupedItems[0].splice(j, 1)[0]; this.listItems.splice(j, 1); unassignedItemsToAdd.unshift(unassignedItemToAdd); } } } this.addItems(unassignedItemsToAdd); if (this.manageHeaders) { this.addHeaders(index + 1, groups.length); } for (let i = 0; i < groups.length; i++) { if (groups[i].loading) { this.setGroupLoading(groups[i].id, true, groups[i].loadingItem); } } } /** * Add items to the item set, arranging them according to their groupId. * @param items the items to add. * @param addToListItems set to false to only add to groupedItems and not to full listItems set. * @param index the index to try to add the item to. If the index is out of the bounds of the item's group, * it will be added at the closest index. */ addItems(items, addToListItems = true, index) { // Add in reverse order if we're trying to add at a specific index. if (index !== undefined) { for (let i = items.length - 1; i >= 0; i--) { this.addItem(items[i], index); } } else { for (let i = 0; i < items.length; i++) { this.addItem(items[i], index); } } if (addToListItems) { this.addToListItems(); } } /** * Diff this.groupedItems and this.listItems and splice in the extra items in as few splices as possible. */ addToListItems() { // Flatten this.groupedItems into a single array so it can be compared to this.listItems. const groupedItemsArray = []; for (let i = 0; i < this.groupedItems.length; i++) { groupedItemsArray.push(...this.groupedItems[i]); } let listItemIndex = 0; let listIndexWhereItemsDiffer; const indexToItemMapping = {}; // Iterate through both the groupedItemsArray and this.listItems. When the items differ, add the new items to a map, // keyed off the listItem index where the items will be spliced at. for (let groupedItemIndex = 0; groupedItemIndex < groupedItemsArray.length; groupedItemIndex++) { if (this.listItems.value[listItemIndex] === groupedItemsArray[groupedItemIndex]) { listItemIndex++; listIndexWhereItemsDiffer = undefined; } else { // if listIndexWhereItemsDiffer is defined, we are currently in a run of new items. Add the next to item to the same entry in the mapping. if (listIndexWhereItemsDiffer !== undefined) { indexToItemMapping[listIndexWhereItemsDiffer].push(groupedItemsArray[groupedItemIndex]); } else { // We're starting a new run of new items, add an entry to the mapping. listIndexWhereItemsDiffer = listItemIndex; indexToItemMapping[listItemIndex] = [groupedItemsArray[groupedItemIndex]]; } } } // Iterate through the mapping and add the new items. This is done in reverse order so the new items don't throw off the indices that come after. const keys = Object.keys(indexToItemMapping); for (let i = keys.length - 1; i >= 0; i--) { this.listItems.splice(parseInt(keys[i]), 0, ...indexToItemMapping[parseInt(keys[i])]); } } addItem(item, index) { let groupIndex = 0; let newItemIndex = 0; const groupId = item.groupId; if (groupId !== undefined) { groupIndex = this.internalGroups.findIndex(group => group.id === groupId) + 1; } for (let j = 0; j < groupIndex; j++) { newItemIndex += this.groupedItems[j].length; } // Put dividers at the top, followed by headers. if (item.type === ListBoxItemType.Divider) { this.groupedItems[groupIndex].unshift(item); } else if (item.type === ListBoxItemType.Header) { if (this.groupedItems[groupIndex].length && this.groupedItems[groupIndex][0].type === ListBoxItemType.Divider) { this.groupedItems[groupIndex].splice(1, 0, item); newItemIndex++; } else { this.groupedItems[groupIndex].unshift(item); } } else { let spliceIndex = this.groupedItems[groupIndex].length; if (index !== undefined && index >= newItemIndex && index <= newItemIndex + this.groupedItems[groupIndex].length) { spliceIndex = index - newItemIndex; newItemIndex = index; } else if (index !== undefined && index < newItemIndex) { spliceIndex = 0; } else { newItemIndex += this.groupedItems[groupIndex].length; } this.groupedItems[groupIndex].splice(spliceIndex, 0, item); // If we added an item to an empty group, see if we need to add a header if (groupIndex > 0 && this.groupedItems[groupIndex].length === 1 && this.manageHeaders) { this.addHeaders(groupIndex); } } } /** * Removed items from the internal groupedItems lists and optionally from the actual list of items. * @param items the items to remove. * @param removeFromListItems Whether or not to remove the items from the list of items. */ removeItems(items, removeFromListItems = true) { for (let i = 0; i < items.length; i++) { let groupIndex = 0; let itemIndex = 0; const groupId = items[i].groupId; if (groupId !== undefined) { groupIndex = this.internalGroups.findIndex(group => group.id === groupId) + 1; } for (let j = 0; j < groupIndex; j++) { itemIndex += this.groupedItems[j].length; } const group = this.groupedItems[groupIndex]; const indexInGroup = group.indexOf(items[i]); group.splice(indexInGroup, 1); if (removeFromListItems) { this.listItems.splice(itemIndex + indexInGroup, 1); } } } }