azure-devops-ui
Version:
React components for building web UI in Azure DevOps
367 lines (366 loc) • 17.1 kB
JavaScript
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);
}
}
}
}