diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.html b/apps/web/src/app/organizations/components/access-selector/access-selector.component.html new file mode 100644 index 00000000000..10390b3e053 --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.html @@ -0,0 +1,136 @@ +
+ + {{ "permission" | i18n }} + + + + + {{ selectorLabelText }} + + {{ selectorHelpText }} + +
+ + + + + {{ columnHeader }} + + {{ "permission" | i18n }} + + {{ "role" | i18n }} + {{ "group" | i18n }} + + + + + + +
+ +
+
+ {{ item.labelName }} + + {{ "invited" | i18n }} + +
+
{{ item.email }}
+
+
+ +
+ + {{ item.labelName }} +
+ + + + + + + + + +
+ {{ "canEdit" | i18n }} + +
+ +
+ {{ permissionLabelId(item.readonlyPermission) | i18n }} +
+
+ + + + {{ item.role | userType: "-" }} + + + + {{ item.viaGroupName ?? "-" }} + + + + + + + + {{ emptySelectionText }} + +
+
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts new file mode 100644 index 00000000000..3b2ba911aac --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts @@ -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; + + 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); + } + ); + }); +}); diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts new file mode 100644 index 00000000000..98a49d5c3ab --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts @@ -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(); + 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((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 + ); + } +} diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts new file mode 100644 index 00000000000..5dfbacebe13 --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts @@ -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); diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts new file mode 100644 index 00000000000..cbb01137b4d --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts @@ -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 {} diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts new file mode 100644 index 00000000000..059fb1c430c --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts @@ -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 = ( + args: AccessSelectorComponent +) => ({ + props: { + items: [], + valueChanged: actionsData.onValueChanged, + initialValue: [], + ...args, + }, + template: ` + +`, +}); + +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 = ( + args: AccessSelectorComponent +) => ({ + props: { + items: [], + onSubmit: actionsData.onSubmit, + ...args, + }, + template: ` +
+ + +
+`, +}); + +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), +}; diff --git a/apps/web/src/app/organizations/components/access-selector/index.ts b/apps/web/src/app/organizations/components/access-selector/index.ts new file mode 100644 index 00000000000..86624f8e941 --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/index.ts @@ -0,0 +1,3 @@ +export * from "./access-selector.component"; +export * from "./access-selector.module"; +export * from "./access-selector.models"; diff --git a/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts b/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts new file mode 100644 index 00000000000..6ef78cb65ea --- /dev/null +++ b/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts @@ -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"); + } + } +} diff --git a/apps/web/src/app/organizations/organization.module.ts b/apps/web/src/app/organizations/organization.module.ts new file mode 100644 index 00000000000..f32e4f53104 --- /dev/null +++ b/apps/web/src/app/organizations/organization.module.ts @@ -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 {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 6e1c4e569ef..f77043a777e 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -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, }, ]; diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 380cbcb6667..7e3d96c68d0 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -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: [], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9ed873eae42..3d3fbd5ea5b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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." } } diff --git a/libs/angular/src/utils/form-selection-list.spec.ts b/libs/angular/src/utils/form-selection-list.spec.ts new file mode 100644 index 00000000000..9d1a3c4e185 --- /dev/null +++ b/libs/angular/src/utils/form-selection-list.spec.ts @@ -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; + 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( + 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); + }); + }); +}); diff --git a/libs/angular/src/utils/form-selection-list.ts b/libs/angular/src/utils/form-selection-list.ts new file mode 100644 index 00000000000..026ef367a4c --- /dev/null +++ b/libs/angular/src/utils/form-selection-list.ts @@ -0,0 +1,201 @@ +import { AbstractControl, FormArray } from "@angular/forms"; + +export type SelectionItemId = { + id: string; +}; + +function findSortedIndex(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, 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, 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 | 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) { + 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); + } + } +} diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 43cd46c45d9..55f1ba09ed8 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -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"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 2e5b2862599..bfc2ef495da 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,4 +1,5 @@ export * from "./async-actions"; +export * from "./avatar"; export * from "./badge"; export * from "./banner"; export * from "./button"; diff --git a/libs/components/src/multi-select/models/select-item-view.ts b/libs/components/src/multi-select/models/select-item-view.ts index 45cf47d73f4..f66680c4406 100644 --- a/libs/components/src/multi-select/models/select-item-view.ts +++ b/libs/components/src/multi-select/models/select-item-view.ts @@ -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 };