1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 17:53:39 +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,136 @@
<div class="tw-flex">
<bit-form-field *ngIf="permissionMode == 'edit'">
<bit-label>{{ "permission" | i18n }}</bit-label>
<select
bitInput
[disabled]="disabled"
[(ngModel)]="initialPermission"
[ngModelOptions]="{ standalone: true }"
(blur)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
</bit-form-field>
<bit-form-field class="tw-ml-3 tw-flex-grow">
<bit-label>{{ selectorLabelText }}</bit-label>
<bit-multi-select
class="tw-w-full"
[baseItems]="selectionList.deselectedItems"
[removeSelectedItems]="true"
[disabled]="disabled"
(onItemsConfirmed)="selectItems($event)"
(blur)="handleBlur()"
></bit-multi-select>
<bit-hint *ngIf="selectorHelpText">{{ selectorHelpText }}</bit-hint>
</bit-form-field>
</div>
<bit-table [formGroup]="formGroup">
<ng-container header>
<tr>
<th bitCell>{{ columnHeader }}</th>
<th bitCell id="permissionColHeading" *ngIf="permissionMode != 'hidden'">
{{ "permission" | i18n }}
</th>
<th bitCell id="roleColHeading" *ngIf="showMemberRoles">{{ "role" | i18n }}</th>
<th bitCell id="groupColHeading" *ngIf="showGroupColumn">{{ "group" | i18n }}</th>
<th bitCell style="width: 50px"></th>
</tr>
</ng-container>
<ng-container body formArrayName="items">
<tr
bitRow
*ngFor="let item of selectionList.selectedItems; let i = index"
[formGroupName]="i"
[ngClass]="{ 'tw-text-muted': item.readonly }"
>
<td bitCell [ngSwitch]="item.type">
<div class="tw-flex tw-items-center" *ngSwitchCase="itemType.Member">
<bit-avatar size="small" class="tw-mr-3" text="{{ item.labelName }}"></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-text-sm">
{{ item.labelName }}
<span *ngIf="item.status == 0" bitBadge badgeType="secondary">
{{ "invited" | i18n }}
</span>
</div>
<div class="tw-text-xs tw-text-muted" *ngIf="item.status != 0">{{ item.email }}</div>
</div>
</div>
<div class="tw-flex tw-items-center tw-text-sm" *ngSwitchDefault>
<i
class="bwi tw-mr-3 tw-px-0.5 tw-text-2xl"
[ngClass]="item.icon || itemIcon(item)"
aria-hidden="true"
></i>
<span>{{ item.labelName }}</span>
</div>
</td>
<td bitCell *ngIf="permissionMode != 'hidden'">
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm">
<label class="sr-only" [for]="'permission' + i"
>{{ item.labelName }} {{ "permission" | i18n }}</label
>
<select
bitInput
class="-tw-ml-1 tw-max-w-36 tw-overflow-ellipsis !tw-rounded tw-border-0 !tw-bg-transparent tw-pl-0 tw-font-bold"
formControlName="permission"
[id]="'permission' + i"
(blur)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
</ng-container>
<ng-template #readOnlyPerm>
<div
*ngIf="item.accessAllItems"
class="tw-max-w-36 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
[appA11yTitle]="accessAllLabelId(item) | i18n"
>
{{ "canEdit" | i18n }}
<i class="bwi bwi-filter tw-ml-1" aria-hidden="true"></i>
</div>
<div
*ngIf="item.readonly"
class="tw-max-w-36 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n"
>
{{ permissionLabelId(item.readonlyPermission) | i18n }}
</div>
</ng-template>
</td>
<td bitCell *ngIf="showMemberRoles">
{{ item.role | userType: "-" }}
</td>
<td bitCell *ngIf="showGroupColumn">
{{ item.viaGroupName ?? "-" }}
</td>
<td bitCell>
<button
*ngIf="!item.readonly"
type="button"
bitIconButton="bwi-close"
buttonType="muted"
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}"
[disabled]="disabled"
(click)="selectionList.deselectItem(item.id); handleBlur()"
></button>
</td>
</tr>
<tr *ngIf="selectionList.selectedItems.length == 0">
<td bitCell>{{ emptySelectionText }}</td>
</tr>
</ng-container>
</bit-table>

View File

@@ -0,0 +1,250 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import {
AvatarModule,
BadgeModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
import { AccessItemType, CollectionPermission } from "./access-selector.models";
import { UserTypePipe } from "./user-type.pipe";
/**
* Helper class that makes it easier to test the AccessSelectorComponent by
* exposing some protected methods/properties
*/
class TestableAccessSelectorComponent extends AccessSelectorComponent {
selectItems(items: SelectItemView[]) {
super.selectItems(items);
}
deselectItem(id: string) {
this.selectionList.deselectItem(id);
}
/**
* Helper used to simulate a user selecting a new permission for a table row
* @param index - "Row" index
* @param perm - The new permission value
*/
changeSelectedItemPerm(index: number, perm: CollectionPermission) {
this.selectionList.formArray.at(index).patchValue({
permission: perm,
});
}
}
describe("AccessSelectorComponent", () => {
let component: TestableAccessSelectorComponent;
let fixture: ComponentFixture<TestableAccessSelectorComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
ButtonModule,
FormFieldModule,
AvatarModule,
BadgeModule,
ReactiveFormsModule,
FormsModule,
TabsModule,
TableModule,
PreloadedEnglishI18nModule,
JslibModule,
IconButtonModule,
],
declarations: [TestableAccessSelectorComponent, UserTypePipe],
providers: [],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestableAccessSelectorComponent);
component = fixture.componentInstance;
component.emptySelectionText = "Nothing selected";
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("item selection", () => {
beforeEach(() => {
component.items = [
{
id: "123",
type: AccessItemType.Group,
labelName: "Group 1",
listName: "Group 1",
},
];
fixture.detectChanges();
});
it("should show the empty row when nothing is selected", () => {
const emptyTableCell = fixture.nativeElement.querySelector("tbody tr td");
expect(emptyTableCell?.textContent).toEqual("Nothing selected");
});
it("should show one row when one value is selected", () => {
component.selectItems([{ id: "123" } as any]);
fixture.detectChanges();
const firstColSpan = fixture.nativeElement.querySelector("tbody tr td span");
expect(firstColSpan.textContent).toEqual("Group 1");
});
it("should emit value change when a value is selected", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Edit;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
});
it("should emit value change when a row is modified", () => {
// Arrange
const mockChange = jest.fn();
component.permissionMode = PermissionMode.Edit;
component.selectItems([{ id: "123" } as any]);
component.registerOnChange(mockChange); // Register change listener after setup
// Act
component.changeSelectedItemPerm(0, CollectionPermission.Edit);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).toHaveProperty(
"[0].permission",
CollectionPermission.Edit
);
});
it("should emit value change when a row is removed", () => {
// Arrange
const mockChange = jest.fn();
component.permissionMode = PermissionMode.Edit;
component.selectItems([{ id: "123" } as any]);
component.registerOnChange(mockChange); // Register change listener after setup
// Act
component.deselectItem("123");
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0].length).toEqual(0);
});
it("should emit permission values when in edit mode", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Edit;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].permission");
});
it("should not emit permission values when not in edit mode", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Hidden;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).not.toHaveProperty("[0].permission");
});
});
describe("column rendering", () => {
beforeEach(() => {
component.items = [
{
id: "g1",
type: AccessItemType.Group,
labelName: "Group 1",
listName: "Group 1",
},
{
id: "m1",
type: AccessItemType.Member,
labelName: "Member 1",
listName: "Member 1 (member1@email.com)",
email: "member1@email.com",
role: OrganizationUserType.Manager,
status: OrganizationUserStatusType.Confirmed,
},
];
fixture.detectChanges();
});
test.each([true, false])("should show the role column when enabled", (columnEnabled) => {
// Act
component.showMemberRoles = columnEnabled;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#roleColHeading");
expect(!!colHeading).toEqual(columnEnabled);
});
test.each([true, false])("should show the group column when enabled", (columnEnabled) => {
// Act
component.showGroupColumn = columnEnabled;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#groupColHeading");
expect(!!colHeading).toEqual(columnEnabled);
});
const permissionColumnCases = [
[PermissionMode.Hidden, false],
[PermissionMode.Edit, true],
[PermissionMode.Readonly, true],
];
test.each(permissionColumnCases)(
"should show the permission column when enabled",
(mode: PermissionMode, shouldShowColumn) => {
// Act
component.permissionMode = mode;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#permissionColHeading");
expect(!!colHeading).toEqual(shouldShowColumn);
}
);
});
});

View File

@@ -0,0 +1,290 @@
import { Component, forwardRef, Input, OnDestroy, OnInit } from "@angular/core";
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
CollectionPermission,
} from "./access-selector.models";
export enum PermissionMode {
/**
* No permission controls or column present. No permission values are emitted.
*/
Hidden = "hidden",
/**
* No permission controls. Column rendered an if available on an item. No permission values are emitted
*/
Readonly = "readonly",
/**
* Permission Controls and column present. Permission values are emitted.
*/
Edit = "edit",
}
@Component({
selector: "bit-access-selector",
templateUrl: "access-selector.component.html",
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AccessSelectorComponent),
multi: true,
},
],
})
export class AccessSelectorComponent implements ControlValueAccessor, OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private notifyOnChange: (v: unknown) => void;
private notifyOnTouch: () => void;
private pauseChangeNotification: boolean;
/**
* The internal selection list that tracks the value of this form control / component.
* It's responsible for keeping items sorted and synced with the rendered form controls
* @protected
*/
protected selectionList = new FormSelectionList<AccessItemView, AccessItemValue>((item) => {
const permissionControl = this.formBuilder.control(this.initialPermission);
const fg = this.formBuilder.group({
id: item.id,
type: item.type,
permission: permissionControl,
});
// Disable entire row form group if readonly
if (item.readonly) {
fg.disable();
}
// Disable permission control if accessAllItems is enabled
if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) {
permissionControl.disable();
}
return fg;
}, this._itemComparator.bind(this));
/**
* Internal form group for this component.
* @protected
*/
protected formGroup = this.formBuilder.group({
items: this.selectionList.formArray,
});
protected itemType = AccessItemType;
protected permissionList = [
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
];
protected initialPermission = CollectionPermission.View;
disabled: boolean;
/**
* List of all selectable items that. Sorted internally.
*/
@Input()
get items(): AccessItemView[] {
return this.selectionList.allItems;
}
set items(val: AccessItemView[]) {
const selected = (this.selectionList.formArray.getRawValue() ?? []).concat(
val.filter((m) => m.readonly)
);
this.selectionList.populateItems(
val.map((m) => {
m.icon = m.icon ?? this.itemIcon(m); // Ensure an icon is set
return m;
}),
selected
);
}
/**
* Permission mode that controls if the permission form controls and column should be present.
*/
@Input()
get permissionMode(): PermissionMode {
return this._permissionMode;
}
set permissionMode(value: PermissionMode) {
this._permissionMode = value;
// Toggle any internal permission controls
for (const control of this.selectionList.formArray.controls) {
if (value == PermissionMode.Edit) {
control.get("permission").enable();
} else {
control.get("permission").disable();
}
}
}
private _permissionMode: PermissionMode = PermissionMode.Hidden;
/**
* Column header for the selected items table
*/
@Input() columnHeader: string;
/**
* Label used for the ng selector
*/
@Input() selectorLabelText: string;
/**
* Helper text displayed under the ng selector
*/
@Input() selectorHelpText: string;
/**
* Text that is shown in the table when no items are selected
*/
@Input() emptySelectionText: string;
/**
* Flag for if the member roles column should be present
*/
@Input() showMemberRoles: boolean;
/**
* Flag for if the group column should be present
*/
@Input() showGroupColumn: boolean;
constructor(
private readonly formBuilder: FormBuilder,
private readonly i18nService: I18nService
) {}
/** Required for NG_VALUE_ACCESSOR */
registerOnChange(fn: any): void {
this.notifyOnChange = fn;
}
/** Required for NG_VALUE_ACCESSOR */
registerOnTouched(fn: any): void {
this.notifyOnTouch = fn;
}
/** Required for NG_VALUE_ACCESSOR */
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
// Keep the internal FormGroup in sync
if (this.disabled) {
this.formGroup.disable();
} else {
this.formGroup.enable();
}
}
/** Required for NG_VALUE_ACCESSOR */
writeValue(selectedItems: AccessItemValue[]): void {
// Modifying the selection list, mistakenly fires valueChanges in the
// internal form array, so we need to know to pause external notification
this.pauseChangeNotification = true;
// Always clear the internal selection list on a new value
this.selectionList.deselectAll();
// We need to also select any read only items to appear in the table
this.selectionList.selectItems(this.items.filter((m) => m.readonly).map((m) => m.id));
// If the new value is null, then we're done
if (selectedItems == null) {
this.pauseChangeNotification = false;
return;
}
// Unable to handle other value types, throw
if (!Array.isArray(selectedItems)) {
throw new Error("The access selector component only supports Array form values!");
}
// Iterate and internally select each item
for (const value of selectedItems) {
this.selectionList.selectItem(value.id, value);
}
this.pauseChangeNotification = false;
}
ngOnInit() {
// Watch the internal formArray for changes and propagate them
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
if (!this.notifyOnChange || this.pauseChangeNotification) {
return;
}
this.notifyOnChange(v);
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected handleBlur() {
if (!this.notifyOnTouch) {
return;
}
this.notifyOnTouch();
}
protected selectItems(items: SelectItemView[]) {
this.pauseChangeNotification = true;
this.selectionList.selectItems(items.map((i) => i.id));
this.pauseChangeNotification = false;
if (this.notifyOnChange != undefined) {
this.notifyOnChange(this.selectionList.formArray.value);
}
}
protected itemIcon(item: AccessItemView) {
switch (item.type) {
case AccessItemType.Collection:
return "bwi-collection";
case AccessItemType.Group:
return "bwi-users";
case AccessItemType.Member:
return "bwi-user";
}
}
protected permissionLabelId(perm: CollectionPermission) {
return this.permissionList.find((p) => p.perm == perm)?.labelId;
}
protected accessAllLabelId(item: AccessItemView) {
return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll";
}
protected canEditItemPermission(item: AccessItemView) {
return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems;
}
private _itemComparator(a: AccessItemView, b: AccessItemView) {
if (a.type != b.type) {
return a.type - b.type;
}
return this.i18nService.collator.compare(
a.listName + a.labelName + a.readonly,
b.listName + b.labelName + b.readonly
);
}
}

View File

@@ -0,0 +1,107 @@
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selectionReadOnlyRequest";
import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selectionReadOnlyResponse";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
/**
* Permission options that replace/correspond with readOnly and hidePassword server fields.
*/
export enum CollectionPermission {
View = "view",
ViewExceptPass = "viewExceptPass",
Edit = "edit",
EditExceptPass = "editExceptPass",
}
export enum AccessItemType {
Collection,
Group,
Member,
}
/**
* A "generic" type that describes an item that can be selected from a
* ng-select list and have its collection permission modified.
*
* Currently, it supports Collections, Groups, and Members. Members require some additional
* details to render in the AccessSelectorComponent so their type is defined separately
* and then joined back with the base type.
*
*/
export type AccessItemView =
| SelectItemView & {
/**
* Flag that this group/member can access all items.
* This will disable the permission editor for this item.
*/
accessAllItems?: boolean;
/**
* Flag that this item cannot be modified.
* This will disable the permission editor and will keep
* the item always selected.
*/
readonly?: boolean;
/**
* Optional permission that will be rendered for this
* item if it set to readonly.
*/
readonlyPermission?: CollectionPermission;
} & (
| {
type: AccessItemType.Collection;
viaGroupName?: string;
}
| {
type: AccessItemType.Group;
}
| {
type: AccessItemType.Member; // Members have a few extra details required to display, so they're added here
email: string;
role: OrganizationUserType;
status: OrganizationUserStatusType;
}
);
/**
* A type that is emitted as a value for the ngControl
*/
export type AccessItemValue = {
id: string;
permission?: CollectionPermission;
type: AccessItemType;
};
/**
* Converts the older SelectionReadOnly interface to one of the new CollectionPermission values
* for the dropdown in the AccessSelectorComponent
* @param value
*/
export const convertToPermission = (value: SelectionReadOnlyResponse) => {
if (value.readOnly) {
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
} else {
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit;
}
};
/**
* Converts an AccessItemValue back into a SelectionReadOnly class using the CollectionPermission
* to determine the values for `readOnly` and `hidePassword`
* @param value
*/
export const convertToSelectionReadOnly = (value: AccessItemValue) => {
return new SelectionReadOnlyRequest(
value.id,
readOnly(value.permission),
hidePassword(value.permission)
);
};
const readOnly = (perm: CollectionPermission) =>
[CollectionPermission.View, CollectionPermission.ViewExceptPass].includes(perm);
const hidePassword = (perm: CollectionPermission) =>
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { AccessSelectorComponent } from "./access-selector.component";
import { UserTypePipe } from "./user-type.pipe";
@NgModule({
imports: [SharedModule],
declarations: [AccessSelectorComponent, UserTypePipe],
exports: [AccessSelectorComponent],
})
export class AccessSelectorModule {}

View File

@@ -0,0 +1,302 @@
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import {
AvatarModule,
BadgeModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent } from "./access-selector.component";
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";
import { UserTypePipe } from "./user-type.pipe";
export default {
title: "Web/Organizations/Access Selector",
decorators: [
moduleMetadata({
declarations: [AccessSelectorComponent, UserTypePipe],
imports: [
ButtonModule,
FormFieldModule,
AvatarModule,
BadgeModule,
ReactiveFormsModule,
FormsModule,
TabsModule,
TableModule,
PreloadedEnglishI18nModule,
JslibModule,
IconButtonModule,
],
providers: [],
}),
],
parameters: {},
argTypes: {
formObj: { table: { disable: true } },
},
} as Meta;
const actionsData = {
onValueChanged: action("onValueChanged"),
onSubmit: action("onSubmit"),
};
/**
* Factory to help build semi-realistic looking items
* @param n - The number of items to build
* @param type - Which type to build
*/
const itemsFactory = (n: number, type: AccessItemType) => {
return [...Array(n)].map((_: unknown, id: number) => {
const item: AccessItemView = {
id: id.toString(),
type: type,
} as AccessItemView;
switch (item.type) {
case AccessItemType.Collection:
item.labelName = item.listName = `Collection ${id}`;
item.id = item.id + "c";
item.parentGrouping = "Collection Parent Group " + ((id % 2) + 1);
break;
case AccessItemType.Group:
item.labelName = item.listName = `Group ${id}`;
item.id = item.id + "g";
break;
case AccessItemType.Member:
item.id = item.id + "m";
item.email = `member${id}@email.com`;
item.status = id % 3 == 0 ? 0 : 2;
item.labelName = item.status == 2 ? `Member ${id}` : item.email;
item.listName = item.status == 2 ? `${item.labelName} (${item.email})` : item.email;
item.role = id % 5;
break;
}
return item;
});
};
const sampleMembers = itemsFactory(10, AccessItemType.Member);
const sampleGroups = itemsFactory(6, AccessItemType.Group);
const StandaloneAccessSelectorTemplate: Story<AccessSelectorComponent> = (
args: AccessSelectorComponent
) => ({
props: {
items: [],
valueChanged: actionsData.onValueChanged,
initialValue: [],
...args,
},
template: `
<bit-access-selector
(ngModelChange)="valueChanged($event)"
[ngModel]="initialValue"
[items]="items"
[disabled]="disabled"
[columnHeader]="columnHeader"
[showGroupColumn]="showGroupColumn"
[selectorLabelText]="selectorLabelText"
[selectorHelpText]="selectorHelpText"
[emptySelectionText]="emptySelectionText"
[permissionMode]="permissionMode"
[showMemberRoles]="showMemberRoles"
></bit-access-selector>
`,
});
const memberCollectionAccessItems = itemsFactory(3, AccessItemType.Collection).concat([
{
id: "c1-group1",
type: AccessItemType.Collection,
labelName: "Collection 1",
listName: "Collection 1",
viaGroupName: "Group 1",
readonlyPermission: CollectionPermission.View,
readonly: true,
},
{
id: "c1-group2",
type: AccessItemType.Collection,
labelName: "Collection 1",
listName: "Collection 1",
viaGroupName: "Group 2",
readonlyPermission: CollectionPermission.ViewExceptPass,
readonly: true,
},
]);
export const MemberCollectionAccess = StandaloneAccessSelectorTemplate.bind({});
MemberCollectionAccess.args = {
permissionMode: "edit",
showMemberRoles: false,
showGroupColumn: true,
columnHeader: "Collection",
selectorLabelText: "Select Collections",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No collections added",
disabled: false,
initialValue: [],
items: memberCollectionAccessItems,
};
MemberCollectionAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for modifying the collections a member has access to.
Includes examples of a readonly group and member that cannot be edited.
`,
},
},
};
export const MemberGroupAccess = StandaloneAccessSelectorTemplate.bind({});
MemberGroupAccess.args = {
permissionMode: "readonly",
showMemberRoles: false,
columnHeader: "Groups",
selectorLabelText: "Select Groups",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No groups added",
disabled: false,
initialValue: [{ id: "3g" }, { id: "0g" }],
items: itemsFactory(4, AccessItemType.Group).concat([
{
id: "admin",
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
},
]),
};
MemberGroupAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which groups an individual member belongs too.
`,
},
},
};
export const GroupMembersAccess = StandaloneAccessSelectorTemplate.bind({});
GroupMembersAccess.args = {
permissionMode: "hidden",
showMemberRoles: true,
columnHeader: "Members",
selectorLabelText: "Select Members",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No members added",
disabled: false,
initialValue: [{ id: "2m" }, { id: "0m" }],
items: sampleMembers,
};
GroupMembersAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which members belong to an specific group.
`,
},
},
};
export const CollectionAccess = StandaloneAccessSelectorTemplate.bind({});
CollectionAccess.args = {
permissionMode: "edit",
showMemberRoles: false,
columnHeader: "Groups/Members",
selectorLabelText: "Select groups and members",
selectorHelpText:
"Permissions set for a member will replace permissions set by that member's group",
emptySelectionText: "No members or groups added",
disabled: false,
initialValue: [
{ id: "3g", permission: CollectionPermission.EditExceptPass },
{ id: "0m", permission: CollectionPermission.View },
],
items: sampleGroups.concat(sampleMembers).concat([
{
id: "admin-group",
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
readonly: true,
},
{
id: "admin-member",
type: AccessItemType.Member,
listName: "Admin Member (admin@email.com)",
labelName: "Admin Member",
status: OrganizationUserStatusType.Confirmed,
role: OrganizationUserType.Admin,
email: "admin@email.com",
accessAllItems: true,
readonly: true,
},
]),
};
GroupMembersAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which members/groups have access to a specific collection.
`,
},
},
};
const fb = new FormBuilder();
const ReactiveFormAccessSelectorTemplate: Story<AccessSelectorComponent> = (
args: AccessSelectorComponent
) => ({
props: {
items: [],
onSubmit: actionsData.onSubmit,
...args,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="onSubmit(formObj.controls.formItems.value)">
<bit-access-selector
formControlName="formItems"
[items]="items"
[columnHeader]="columnHeader"
[selectorLabelText]="selectorLabelText"
[selectorHelpText]="selectorHelpText"
[emptySelectionText]="emptySelectionText"
[permissionMode]="permissionMode"
[showMemberRoles]="showMemberRoles"
></bit-access-selector>
<button type="submit" bitButton buttonType="primary" class="tw-mt-5">Submit</button>
</form>
`,
});
export const ReactiveForm = ReactiveFormAccessSelectorTemplate.bind({});
ReactiveForm.args = {
formObj: fb.group({ formItems: [[{ id: "1g" }]] }),
permissionMode: "edit",
showMemberRoles: false,
columnHeader: "Groups/Members",
selectorLabelText: "Select groups and members",
selectorHelpText:
"Permissions set for a member will replace permissions set by that member's group",
emptySelectionText: "No members or groups added",
items: sampleGroups.concat(sampleMembers),
};

View File

@@ -0,0 +1,3 @@
export * from "./access-selector.component";
export * from "./access-selector.module";
export * from "./access-selector.models";

View File

@@ -0,0 +1,29 @@
import { Pipe, PipeTransform } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
@Pipe({
name: "userType",
})
export class UserTypePipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserType, unknownText?: string): string {
if (value == null) {
return unknownText ?? this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserType.Owner:
return this.i18nService.t("owner");
case OrganizationUserType.Admin:
return this.i18nService.t("admin");
case OrganizationUserType.User:
return this.i18nService.t("user");
case OrganizationUserType.Manager:
return this.i18nService.t("manager");
case OrganizationUserType.Custom:
return this.i18nService.t("custom");
}
}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../shared";
import { AccessSelectorModule } from "./components/access-selector";
import { OrganizationsRoutingModule } from "./organization-routing.module";
@NgModule({
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
})
export class OrganizationModule {}

View File

@@ -28,7 +28,7 @@ import { VerifyRecoverDeleteComponent } from "./accounts/verify-recover-delete.c
import { HomeGuard } from "./guards/home.guard";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
import { OrganizationsRoutingModule } from "./organizations/organization-routing.module";
import { OrganizationModule } from "./organizations/organization.module";
import { AcceptFamilySponsorshipComponent } from "./organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./organizations/sponsorships/families-for-enterprise-setup.component";
import { ReportsModule } from "./reports";
@@ -251,7 +251,7 @@ const routes: Routes = [
},
{
path: "organizations",
loadChildren: () => OrganizationsRoutingModule,
loadChildren: () => OrganizationModule,
},
];

View File

@@ -8,10 +8,12 @@ import { ToastrModule } from "ngx-toastr";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AvatarModule,
BadgeModule,
ButtonModule,
CalloutModule,
FormFieldModule,
IconButtonModule,
IconModule,
AsyncActionsModule,
MenuModule,
@@ -49,6 +51,8 @@ import "./locales";
IconModule,
TabsModule,
TableModule,
AvatarModule,
IconButtonModule,
],
exports: [
CommonModule,
@@ -70,6 +74,8 @@ import "./locales";
IconModule,
TabsModule,
TableModule,
AvatarModule,
IconButtonModule,
],
providers: [DatePipe],
bootstrap: [],

View File

@@ -4125,6 +4125,9 @@
"permissions": {
"message": "Permissions"
},
"permission": {
"message": "Permission"
},
"managerPermissions": {
"message": "Manager Permissions"
},
@@ -5477,5 +5480,29 @@
},
"update": {
"message": "Update"
},
"role": {
"message": "Role"
},
"canView": {
"message": "Can view"
},
"canViewExceptPass": {
"message": "Can view, except passwords"
},
"canEdit": {
"message": "Can edit"
},
"canEditExceptPass": {
"message": "Can edit, except passwords"
},
"group": {
"message": "Group"
},
"groupAccessAll": {
"message": "This group can access and modify all items."
},
"memberAccessAll": {
"message": "This member can access and modify all items."
}
}

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";

View File

@@ -1,4 +1,5 @@
export * from "./async-actions";
export * from "./avatar";
export * from "./badge";
export * from "./banner";
export * from "./button";

View File

@@ -2,6 +2,6 @@ export type SelectItemView = {
id: string; // Unique ID used for comparisons
listName: string; // Default bindValue -> this is what will be displayed in list items
labelName: string; // This is what will be displayed in the selection option badge
icon: string; // Icon to display within the list
parentGrouping: string; // Used to group items by parent
icon?: string; // Icon to display within the list
parentGrouping?: string; // Used to group items by parent
};