1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

[EC-599] Access Selector Component (#3717)

* Add Access Selector Component and Stories

* Cherry pick FormSelectionList

* Fix some problems caused from cherry-pick

* Fix some Web module problems caused from cherry-pick

* Move AccessSelector out of the root components directory.

Move UserType pipe to AccessSelectorModule

* Fix broken member access selector story

* Add organization feature module

* Undo changes to messages.json

* Fix messages.json

* Remove redundant CommonModule

* [EC-599] Fix avatar/icon sizing

* [EC-599] Remove padding in  permission column

* [EC-599] Make FormSelectionList operations immutable

* [EC-599] Integrate the multi-select component

* [EC-599] Handle readonly/access all edge cases

* [EC-599] Add initial unit tests

Also cleans up public interface for the AccessSelectorComponent. Fixes a bug found during unit test creation.

* [EC-599] Include item name in control labels

* [EC-599] Cleanup member email display

* [EC-599] Review suggestions

- Change PermissionMode to Enum
- Rename permControl to permissionControl to be more clear
- Rename FormSelectionList file to kebab case.
- Move permission row boolean logic to named function for readability

* [EC-599] Cleanup AccessSelectorComponent tests

- Clarify test states
- Add tests for column rendering
- Add tests for permission mode
- Add id to column headers for testing
- Fix small permissionControl bug found during testing

* [EC-599] Add FormSelectionList unit tests

* [EC-599] Fix unit test and linter

* [EC-599] Update Enums to Pascal case

* [EC-599] Undo change to Enum values
This commit is contained in:
Shane Melton
2022-10-19 08:23:03 -07:00
committed by GitHub
parent 31e9c202a9
commit 1ae849c95b
17 changed files with 1634 additions and 5 deletions

View File

@@ -0,0 +1,253 @@
import { FormBuilder } from "@angular/forms";
import { FormSelectionList, SelectionItemId } from "./form-selection-list";
interface TestItemView extends SelectionItemId {
displayName: string;
}
interface TestItemValue extends SelectionItemId {
value: string;
}
const initialTestItems: TestItemView[] = [
{ id: "1", displayName: "1st Item" },
{ id: "2", displayName: "2nd Item" },
{ id: "3", displayName: "3rd Item" },
];
const totalTestItemCount = initialTestItems.length;
describe("FormSelectionList", () => {
let formSelectionList: FormSelectionList<TestItemView, TestItemValue>;
let testItems: TestItemView[];
const formBuilder = new FormBuilder();
const testCompareFn = (a: TestItemView, b: TestItemView) => {
return a.displayName.localeCompare(b.displayName);
};
const testControlFactory = (item: TestItemView) => {
return formBuilder.group({
id: [item.id],
value: [""],
});
};
beforeEach(() => {
formSelectionList = new FormSelectionList<TestItemView, TestItemValue>(
testControlFactory,
testCompareFn
);
testItems = [...initialTestItems];
});
it("should create with empty arrays", () => {
expect(formSelectionList.selectedItems.length).toEqual(0);
expect(formSelectionList.deselectedItems.length).toEqual(0);
expect(formSelectionList.formArray.length).toEqual(0);
});
describe("populateItems()", () => {
it("should have no selected items when populated without a selection", () => {
// Act
formSelectionList.populateItems(testItems, []);
// Assert
expect(formSelectionList.selectedItems.length).toEqual(0);
});
it("should have selected items when populated with a list of selected items", () => {
// Act
formSelectionList.populateItems(testItems, [{ id: "1", value: "test" }]);
// Assert
expect(formSelectionList.selectedItems.length).toEqual(1);
expect(formSelectionList.selectedItems).toHaveProperty("[0].id", "1");
});
});
describe("selectItem()", () => {
beforeEach(() => {
formSelectionList.populateItems(testItems);
});
it("should add item to selectedItems, remove from deselectedItems, and create a form control when called with a valid id", () => {
// Act
formSelectionList.selectItem("1");
// Assert
expect(formSelectionList.selectedItems.length).toEqual(1);
expect(formSelectionList.formArray.length).toEqual(1);
expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount - 1);
});
it("should do nothing when called with a invalid id", () => {
// Act
formSelectionList.selectItem("bad-id");
// Assert
expect(formSelectionList.selectedItems.length).toEqual(0);
expect(formSelectionList.formArray.length).toEqual(0);
expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount);
});
it("should create a form control with an initial value when called with an initial value and valid id", () => {
// Arrange
const testValue = "TestValue";
const idToSelect = "1";
// Act
formSelectionList.selectItem(idToSelect, { value: testValue });
// Assert
expect(formSelectionList.formArray.length).toEqual(1);
expect(formSelectionList.formArray.value).toHaveProperty("[0].id", idToSelect);
expect(formSelectionList.formArray.value).toHaveProperty("[0].value", testValue);
expect(formSelectionList.selectedItems.length).toEqual(1);
expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount - 1);
});
it("should ensure the id value is set for the form control when called with a valid id", () => {
// Arrange
const testValue = "TestValue";
const idToSelect = "1";
const idOverride = "some-other-id";
// Act
formSelectionList.selectItem(idToSelect, { value: testValue, id: idOverride });
// Assert
expect(formSelectionList.formArray.value).toHaveProperty("[0].id", idOverride);
expect(formSelectionList.formArray.value).toHaveProperty("[0].value", testValue);
});
// Ensure Angular's Change Detection will pick up any modifications to the array
it("should create new copies of the selectedItems and deselectedItems arrays when called with a valid id", () => {
// Arrange
const initialSelected = formSelectionList.selectedItems;
const initialdeselected = formSelectionList.deselectedItems;
// Act
formSelectionList.selectItem("1");
// Assert
expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
});
it("should add items to selectedItems array in sorted order when called with a valid id", () => {
// Act
formSelectionList.selectItems(["2", "3", "1"]); // Use out of order ids
// Assert
expect(formSelectionList.selectedItems).toHaveProperty("[0].id", "1");
expect(formSelectionList.selectedItems).toHaveProperty("[1].id", "2");
expect(formSelectionList.selectedItems).toHaveProperty("[2].id", "3");
// Form array values should be in the same order
expect(formSelectionList.formArray.value[0].id).toEqual(
formSelectionList.selectedItems[0].id
);
expect(formSelectionList.formArray.value[1].id).toEqual(
formSelectionList.selectedItems[1].id
);
expect(formSelectionList.formArray.value[2].id).toEqual(
formSelectionList.selectedItems[2].id
);
});
});
describe("deselectItem()", () => {
beforeEach(() => {
formSelectionList.populateItems(testItems, [
{ id: "1", value: "testValue" },
{ id: "2", value: "testValue" },
]);
});
it("should add item to deselectedItems, remove from selectedItems and remove from formArray when called with a valid id", () => {
// Act
formSelectionList.deselectItem("1");
// Assert
expect(formSelectionList.selectedItems.length).toEqual(1);
expect(formSelectionList.formArray.length).toEqual(1);
expect(formSelectionList.deselectedItems.length).toEqual(2);
// Value and View should still be in sync
expect(formSelectionList.formArray.value[0].id).toEqual(
formSelectionList.selectedItems[0].id
);
});
it("should do nothing when called with a invalid id", () => {
// Act
formSelectionList.deselectItem("bad-id");
// Assert
expect(formSelectionList.selectedItems.length).toEqual(2);
expect(formSelectionList.formArray.length).toEqual(2);
expect(formSelectionList.deselectedItems.length).toEqual(1);
});
// Ensure Angular's Change Detection will pick up any modifications to the array
it("should create new copies of the selectedItems and deselectedItems arrays when called with a valid id", () => {
// Arrange
const initialSelected = formSelectionList.selectedItems;
const initialdeselected = formSelectionList.deselectedItems;
// Act
formSelectionList.deselectItem("1");
// Assert
expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
});
it("should add items to deselectedItems array in sorted order when called with a valid id", () => {
// Act
formSelectionList.deselectItems(["2", "1"]); // Use out of order ids
// Assert
expect(formSelectionList.deselectedItems).toHaveProperty("[0].id", "1");
expect(formSelectionList.deselectedItems).toHaveProperty("[1].id", "2");
expect(formSelectionList.deselectedItems).toHaveProperty("[2].id", "3");
});
});
describe("deselectAll()", () => {
beforeEach(() => {
formSelectionList.populateItems(testItems, [
{ id: "1", value: "testValue" },
{ id: "2", value: "testValue" },
]);
});
it("should clear the formArray and selectedItems arrays and populate the deselectedItems array when called", () => {
// Act
formSelectionList.deselectAll();
// Assert
expect(formSelectionList.selectedItems.length).toEqual(0);
expect(formSelectionList.formArray.length).toEqual(0);
expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount);
});
it("should create new arrays for selectedItems and deselectedItems when called", () => {
// Arrange
const initialSelected = formSelectionList.selectedItems;
const initialdeselected = formSelectionList.deselectedItems;
// Act
formSelectionList.deselectAll();
// Assert
expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
});
});
});

View File

@@ -0,0 +1,201 @@
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);
}
}
}

View File

@@ -1,12 +1,12 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
import { DeprecatedVaultFilterService } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { FolderView } from "@bitwarden/common/models/view/folderView";
import { DeprecatedVaultFilterService } from "../../../abstractions/deprecated-vault-filter.service";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";