bitwarden-estensione-browser/libs/angular/src/utils/form-selection-list.ts

216 lines
6.9 KiB
TypeScript

import { AbstractControl, FormArray } from "@angular/forms";
export type SelectionItemId = {
id: string;
};
function findSortedIndex<T>(sortedArray: T[], val: T, compareFn: (a: T, b: T) => number) {
let low = 0;
let high = sortedArray.length || 0;
let mid = -1,
c = 0;
while (low < high) {
mid = Math.floor((low + high) / 2);
c = compareFn(sortedArray[mid], val);
if (c < 0) {
low = mid + 1;
} else if (c > 0) {
high = mid;
} else {
return mid;
}
}
return low;
}
/**
* Utility to help manage a list of selectable items for use with Reactive Angular forms and FormArrays.
*
* It supports selecting/deselecting items, keeping items sorted, and synchronizing the selected items
* with an array of FormControl.
*
* The first type parameter TItem represents the item being selected/deselected, it must have an `id`
* property for identification/comparison. The second type parameter TControlValue represents the value
* type of the form control.
*/
export class FormSelectionList<
TItem extends SelectionItemId,
TControlValue extends SelectionItemId
> {
allItems: TItem[] = [];
/**
* Sorted list of selected items
* Immutable and should be recreated whenever a modification is made
*/
selectedItems: TItem[] = [];
/**
* Sorted list of deselected items
* Immutable and should be recreated whenever a modification is made
*/
deselectedItems: TItem[] = [];
/**
* Sorted FormArray that corresponds and stays in sync with the selectedItems
*/
formArray: FormArray<AbstractControl<Partial<TControlValue>, TControlValue>> = new FormArray([]);
/**
* Construct a new FormSelectionList
* @param controlFactory - Factory responsible for creating initial form controls for each selected item. It is
* provided a copy of the selected item for any form control initialization logic. Specify any additional form
* control options or validators here.
* @param compareFn - Comparison function used for sorting the items.
*/
constructor(
private controlFactory: (item: TItem) => AbstractControl<Partial<TControlValue>, TControlValue>,
private compareFn: (a: TItem, b: TItem) => number
) {}
/**
* Select multiple items by their ids at once. Optionally provide an initial form control value.
* @param ids - List of ids to select
* @param initialValue - Value that will be applied to the corresponding form controls
* The provided `id` arguments will be automatically assigned to each form control value
*/
selectItems(ids: string[], initialValue?: Partial<TControlValue> | undefined) {
for (const id of ids) {
this.selectItem(id, initialValue);
}
}
/**
* Deselect multiple items by their ids at once
* @param ids - List of ids to deselect
*/
deselectItems(ids: string[]) {
for (const id of ids) {
this.deselectItem(id);
}
}
deselectAll() {
this.formArray.clear();
this.selectedItems = [];
this.deselectedItems = [...this.allItems];
}
/**
* Select a single item by id.
*
* Maintains list order for both selected items, deselected items, and the FormArray.
*
* @param id - Id of the item to select
* @param initialValue - Value that will be applied to the corresponding form control for the selected item.
* The provided `id` argument will be automatically assigned unless explicitly set in the initialValue.
*/
selectItem(id: string, initialValue?: Partial<TControlValue>) {
const index = this.deselectedItems.findIndex((o) => o.id === id);
if (index === -1) {
return;
}
const selectedOption = this.deselectedItems[index];
// Note: Changes to the deselected/selected arrays must create a new copy of the array
// in order for Angular's Change Detection to pick up the modification (i.e. treat the arrays as immutable)
// Remove from the list of deselected options
this.deselectedItems = [
...this.deselectedItems.slice(0, index),
...this.deselectedItems.slice(index + 1),
];
// Insert into the sorted selected options list
const sortedInsertIndex = findSortedIndex(this.selectedItems, selectedOption, this.compareFn);
this.selectedItems = [
...this.selectedItems.slice(0, sortedInsertIndex),
selectedOption,
...this.selectedItems.slice(sortedInsertIndex),
];
const newControl = this.controlFactory(selectedOption);
// Patch the value and ensure the `id` is set
newControl.patchValue({
id,
...initialValue,
});
this.formArray.insert(sortedInsertIndex, newControl);
}
/**
* Deselect a single item by id.
*
* Maintains list order for both selected items, deselected items, and the FormArray.
*
* @param id - Id of the item to deselect
*/
deselectItem(id: string) {
const index = this.selectedItems.findIndex((o) => o.id === id);
if (index === -1) {
return;
}
const deselectedOption = this.selectedItems[index];
// Note: Changes to the deselected/selected arrays must create a new copy of the array
// in order for Angular's Change Detection to pick up the modification (i.e. treat the arrays as immutable)
// Remove from the list of selected items (and FormArray)
this.selectedItems = [
...this.selectedItems.slice(0, index),
...this.selectedItems.slice(index + 1),
];
this.formArray.removeAt(index);
// Insert into the sorted deselected array
const sortedInsertIndex = findSortedIndex(
this.deselectedItems,
deselectedOption,
this.compareFn
);
this.deselectedItems = [
...this.deselectedItems.slice(0, sortedInsertIndex),
deselectedOption,
...this.deselectedItems.slice(sortedInsertIndex),
];
}
/**
* Populate the list of deselected items, and optional specify which items should be selected and with what initial
* value for their Form Control
* @param items - A list of all items. (Will be sorted internally)
* @param selectedItems - The items to select initially
*/
populateItems(items: TItem[], selectedItems: TControlValue[] = []) {
this.formArray.clear();
this.allItems = [...items].sort(this.compareFn);
this.selectedItems = [];
this.deselectedItems = [...this.allItems];
for (const selectedItem of selectedItems) {
this.selectItem(selectedItem.id, selectedItem);
}
}
/**
* Helper method to iterate over each "selected" form control and its corresponding item
* @param fn - The function to call for each form control and its corresponding item
*/
forEachControlItem(
fn: (control: AbstractControl<Partial<TControlValue>, TControlValue>, value: TItem) => void
) {
for (let i = 0; i < this.formArray.length; i++) {
// The selectedItems array and formArray are explicitly kept in sync,
// so we can safely assume the index of the form control and item are the same
fn(this.formArray.at(i), this.selectedItems[i]);
}
}
}