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:
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./access-selector.component";
|
||||
export * from "./access-selector.module";
|
||||
export * from "./access-selector.models";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/web/src/app/organizations/organization.module.ts
Normal file
11
apps/web/src/app/organizations/organization.module.ts
Normal 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 {}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
253
libs/angular/src/utils/form-selection-list.spec.ts
Normal file
253
libs/angular/src/utils/form-selection-list.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
libs/angular/src/utils/form-selection-list.ts
Normal file
201
libs/angular/src/utils/form-selection-list.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./async-actions";
|
||||
export * from "./avatar";
|
||||
export * from "./badge";
|
||||
export * from "./banner";
|
||||
export * from "./button";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user