216 lines
6.9 KiB
TypeScript
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]);
|
|
}
|
|
}
|
|
}
|