PS-515 Fix/org users list performance (#1673)

* [PS-515] Use virtual scroll to speed up long user lists

WIP: this is currently showing a large blank area under the last user. Need to figure out why virtual-scroll-spacer is sized too large.

* Fix cdk-virtual-scroll styling

* Format csp for readability

* Set Viewport height

The viewport height was

* Calculate viewport height from item size

Virtual scroll viewports need set heights, we can emulate the old modal behavior by calculating an approximate height required by the viewport to display all items. It will not go beyond the window due to the `.modal-dialog-scrollable` class

* Remove modal css changes

* pr review
This commit is contained in:
Matt Gibson 2022-05-13 15:52:58 -04:00 committed by GitHub
parent 85aa4274f3
commit ca35ccbd35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 131 additions and 54 deletions

View File

@ -35,7 +35,6 @@ import { BulkStatusComponent as OrgBulkStatusComponent } from "../organizations/
import { CollectionAddEditComponent as OrgCollectionAddEditComponent } from "../organizations/manage/collection-add-edit.component"; import { CollectionAddEditComponent as OrgCollectionAddEditComponent } from "../organizations/manage/collection-add-edit.component";
import { CollectionsComponent as OrgManageCollectionsComponent } from "../organizations/manage/collections.component"; import { CollectionsComponent as OrgManageCollectionsComponent } from "../organizations/manage/collections.component";
import { EntityEventsComponent as OrgEntityEventsComponent } from "../organizations/manage/entity-events.component"; import { EntityEventsComponent as OrgEntityEventsComponent } from "../organizations/manage/entity-events.component";
import { EntityUsersComponent as OrgEntityUsersComponent } from "../organizations/manage/entity-users.component";
import { EventsComponent as OrgEventsComponent } from "../organizations/manage/events.component"; import { EventsComponent as OrgEventsComponent } from "../organizations/manage/events.component";
import { GroupAddEditComponent as OrgGroupAddEditComponent } from "../organizations/manage/group-add-edit.component"; import { GroupAddEditComponent as OrgGroupAddEditComponent } from "../organizations/manage/group-add-edit.component";
import { GroupsComponent as OrgGroupsComponent } from "../organizations/manage/groups.component"; import { GroupsComponent as OrgGroupsComponent } from "../organizations/manage/groups.component";
@ -245,7 +244,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
OrgCollectionAddEditComponent, OrgCollectionAddEditComponent,
OrgCollectionsComponent, OrgCollectionsComponent,
OrgEntityEventsComponent, OrgEntityEventsComponent,
OrgEntityUsersComponent,
OrgEventsComponent, OrgEventsComponent,
OrgExportComponent, OrgExportComponent,
OrgExposedPasswordsReportComponent, OrgExposedPasswordsReportComponent,
@ -406,7 +404,6 @@ import { OrganizationBadgeModule } from "./vault/modules/organization-badge/orga
OrgCollectionAddEditComponent, OrgCollectionAddEditComponent,
OrgCollectionsComponent, OrgCollectionsComponent,
OrgEntityEventsComponent, OrgEntityEventsComponent,
OrgEntityUsersComponent,
OrgEventsComponent, OrgEventsComponent,
OrgExportComponent, OrgExportComponent,
OrgExposedPasswordsReportComponent, OrgExposedPasswordsReportComponent,

View File

@ -29,52 +29,52 @@
></i> ></i>
<span class="sr-only">{{ "loading" | i18n }}</span> <span class="sr-only">{{ "loading" | i18n }}</span>
</div> </div>
<div <cdk-virtual-scroll-viewport
class="modal-body" itemSize="46"
*ngIf=" minBufferPx="600"
!loading && users && (users | search: searchText:'name':'email':'id') as searchedUsers maxBufferPx="1200"
" [style]="scrollViewportStyle"
> >
<div class="d-flex"> <div class="modal-body" *ngIf="!loading && users && searchedUsers">
<div class="mr-3"> <div class="d-flex">
<label class="sr-only" for="search">{{ "search" | i18n }}</label> <div class="mr-3">
<input <label class="sr-only" for="search">{{ "search" | i18n }}</label>
type="search" <input
class="form-control form-control-sm" type="search"
id="search" class="form-control form-control-sm"
placeholder="{{ 'search' | i18n }}" id="search"
name="SearchText" placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText" name="SearchText"
/> [(ngModel)]="searchText"
/>
</div>
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: !showSelected }"
(click)="filterSelected(false)"
>
{{ "all" | i18n }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: showSelected }"
(click)="filterSelected(true)"
>
{{ "selected" | i18n }}
<span class="badge badge-pill badge-info" *ngIf="selectedCount">{{
selectedCount
}}</span>
</button>
</div>
</div> </div>
<div class="btn-group btn-group-sm" role="group"> <ng-container *ngIf="!searchedUsers.length">
<button <hr />
type="button" {{ "noUsersInList" | i18n }}
class="btn btn-outline-secondary" </ng-container>
[ngClass]="{ active: !showSelected }" <table class="table table-hover table-list mb-0" [hidden]="!searchedUsers.length">
(click)="filterSelected(false)"
>
{{ "all" | i18n }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: showSelected }"
(click)="filterSelected(true)"
>
{{ "selected" | i18n }}
<span class="badge badge-pill badge-info" *ngIf="selectedCount">{{
selectedCount
}}</span>
</button>
</div>
</div>
<ng-container *ngIf="!searchedUsers.length">
<hr />
{{ "noUsersInList" | i18n }}
</ng-container>
<ng-container *ngIf="searchedUsers.length">
<table class="table table-hover table-list mb-0">
<thead> <thead>
<tr> <tr>
<th>&nbsp;</th> <th>&nbsp;</th>
@ -91,7 +91,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let u of searchedUsers"> <tr *cdkVirtualFor="let u of searchedUsers" class="">
<td class="table-list-checkbox" (click)="check(u)"> <td class="table-list-checkbox" (click)="check(u)">
<input <input
type="checkbox" type="checkbox"
@ -164,8 +164,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</ng-container> </div>
</div> </cdk-virtual-scroll-viewport>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> <button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> <i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>

View File

@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { SearchPipe } from "jslib-angular/pipes/search.pipe";
import { ApiService } from "jslib-common/abstractions/api.service"; import { ApiService } from "jslib-common/abstractions/api.service";
import { I18nService } from "jslib-common/abstractions/i18n.service"; import { I18nService } from "jslib-common/abstractions/i18n.service";
import { LogService } from "jslib-common/abstractions/log.service"; import { LogService } from "jslib-common/abstractions/log.service";
@ -13,6 +14,7 @@ import { OrganizationUserUserDetailsResponse } from "jslib-common/models/respons
@Component({ @Component({
selector: "app-entity-users", selector: "app-entity-users",
templateUrl: "entity-users.component.html", templateUrl: "entity-users.component.html",
providers: [SearchPipe],
}) })
export class EntityUsersComponent implements OnInit { export class EntityUsersComponent implements OnInit {
@Input() entity: "group" | "collection"; @Input() entity: "group" | "collection";
@ -33,6 +35,7 @@ export class EntityUsersComponent implements OnInit {
private allUsers: OrganizationUserUserDetailsResponse[] = []; private allUsers: OrganizationUserUserDetailsResponse[] = [];
constructor( constructor(
private search: SearchPipe,
private apiService: ApiService, private apiService: ApiService,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
@ -52,6 +55,14 @@ export class EntityUsersComponent implements OnInit {
} }
} }
get searchedUsers() {
return this.search.transform(this.users, this.searchText, "name", "email", "id");
}
get scrollViewportStyle() {
return `min-height: 120px; height: ${120 + this.searchedUsers.length * 46}px`;
}
async loadUsers() { async loadUsers() {
const users = await this.apiService.getOrganizationUsers(this.organizationId); const users = await this.apiService.getOrganizationUsers(this.organizationId);
this.allUsers = users.data.map((r) => r).sort(Utils.getSortFunction(this.i18nService, "email")); this.allUsers = users.data.map((r) => r).sort(Utils.getSortFunction(this.i18nService, "email"));

View File

@ -0,0 +1,13 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core";
import { SharedModule } from "../../shared.module";
import { EntityUsersComponent } from "./entity-users.component";
@NgModule({
imports: [SharedModule, ScrollingModule],
declarations: [EntityUsersComponent],
exports: [EntityUsersComponent],
})
export class OrganizationManageModule {}

View File

@ -20,8 +20,9 @@ import {
import { ListResponse } from "jslib-common/models/response/listResponse"; import { ListResponse } from "jslib-common/models/response/listResponse";
import { CollectionView } from "jslib-common/models/view/collectionView"; import { CollectionView } from "jslib-common/models/view/collectionView";
import { EntityUsersComponent } from "../../modules/organizations/manage/entity-users.component";
import { CollectionAddEditComponent } from "./collection-add-edit.component"; import { CollectionAddEditComponent } from "./collection-add-edit.component";
import { EntityUsersComponent } from "./entity-users.component";
@Component({ @Component({
selector: "app-org-manage-collections", selector: "app-org-manage-collections",

View File

@ -12,7 +12,8 @@ import { SearchService } from "jslib-common/abstractions/search.service";
import { Utils } from "jslib-common/misc/utils"; import { Utils } from "jslib-common/misc/utils";
import { GroupResponse } from "jslib-common/models/response/groupResponse"; import { GroupResponse } from "jslib-common/models/response/groupResponse";
import { EntityUsersComponent } from "./entity-users.component"; import { EntityUsersComponent } from "../../modules/organizations/manage/entity-users.component";
import { GroupAddEditComponent } from "./group-add-edit.component"; import { GroupAddEditComponent } from "./group-add-edit.component";
@Component({ @Component({

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { LooseComponentsModule } from "./modules/loose-components.module"; import { LooseComponentsModule } from "./modules/loose-components.module";
import { OrganizationManageModule } from "./modules/organizations/manage/organization-manage.module";
import { PipesModule } from "./modules/pipes/pipes.module"; import { PipesModule } from "./modules/pipes/pipes.module";
import { SharedModule } from "./modules/shared.module"; import { SharedModule } from "./modules/shared.module";
import { VaultFilterModule } from "./modules/vault-filter/vault-filter.module"; import { VaultFilterModule } from "./modules/vault-filter/vault-filter.module";
@ -13,6 +14,7 @@ import { OrganizationBadgeModule } from "./modules/vault/modules/organization-ba
VaultFilterModule, VaultFilterModule,
OrganizationBadgeModule, OrganizationBadgeModule,
PipesModule, PipesModule,
OrganizationManageModule,
], ],
exports: [LooseComponentsModule, VaultFilterModule, OrganizationBadgeModule, PipesModule], exports: [LooseComponentsModule, VaultFilterModule, OrganizationBadgeModule, PipesModule],
bootstrap: [], bootstrap: [],

View File

@ -204,8 +204,60 @@ const devServer =
return [ return [
{ {
key: "Content-Security-Policy", key: "Content-Security-Policy",
value: value: `
"default-src 'self'; script-src 'self' 'sha256-ryoU+5+IUZTuUyTElqkrQGBJXr1brEv6r2CA62WUw8w=' https://js.stripe.com https://js.braintreegateway.com https://www.paypalobjects.com; style-src 'self' https://assets.braintreegateway.com https://*.paypal.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-JVRXyYPueLWdwGwY9m/7u4QlZ1xeQdqUj2t8OVIzZE4='; img-src 'self' data: https://icons.bitwarden.net https://*.paypal.com https://www.paypalobjects.com https://q.stripe.com https://haveibeenpwned.com https://www.gravatar.com; child-src 'self' https://js.stripe.com https://assets.braintreegateway.com https://*.paypal.com https://*.duosecurity.com; frame-src 'self' https://js.stripe.com https://assets.braintreegateway.com https://*.paypal.com https://*.duosecurity.com; connect-src 'self' wss://notifications.bitwarden.com https://notifications.bitwarden.com https://cdn.bitwarden.net https://api.pwnedpasswords.com https://2fa.directory/api/v3/totp.json https://api.stripe.com https://www.paypal.com https://api.braintreegateway.com https://client-analytics.braintreegateway.com https://*.braintree-api.com https://*.blob.core.windows.net https://app.simplelogin.io/api/alias/random/new https://app.anonaddy.com/api/v1/aliases; object-src 'self' blob:;", default-src 'self';
script-src
'self'
'sha256-ryoU+5+IUZTuUyTElqkrQGBJXr1brEv6r2CA62WUw8w='
https://js.stripe.com
https://js.braintreegateway.com
https://www.paypalobjects.com;
style-src
'self'
https://assets.braintreegateway.com
https://*.paypal.com
'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
'sha256-JVRXyYPueLWdwGwY9m/7u4QlZ1xeQdqUj2t8OVIzZE4=';
'sha256-0xHKHIT3+e2Gknxsm/cpErSprhL+o254L/y5bljg74U='
img-src
'self'
data:
https://icons.bitwarden.net
https://*.paypal.com
https://www.paypalobjects.com
https://q.stripe.com
https://haveibeenpwned.com
https://www.gravatar.com;
child-src
'self'
https://js.stripe.com
https://assets.braintreegateway.com
https://*.paypal.com
https://*.duosecurity.com;
frame-src
'self'
https://js.stripe.com
https://assets.braintreegateway.com
https://*.paypal.com
https://*.duosecurity.com;
connect-src
'self'
wss://notifications.bitwarden.com
https://notifications.bitwarden.com
https://cdn.bitwarden.net
https://api.pwnedpasswords.com
https://2fa.directory/api/v3/totp.json
https://api.stripe.com
https://www.paypal.com
https://api.braintreegateway.com
https://client-analytics.braintreegateway.com
https://*.braintree-api.com
https://*.blob.core.windows.net
https://app.simplelogin.io/api/alias/random/new
https://app.anonaddy.com/api/v1/aliases;
object-src
'self'
blob:;`,
}, },
]; ];
} }