-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[AC-2676] Remove paging logic from GroupsComponent (#9705)
* remove infinite scroll, use virtual scroll instead * use TableDataSource for search * allow sorting by name * replacing PlatformUtilsService.showToast with ToastService * misc FIXMEs
- Loading branch information
Showing
6 changed files
with
389 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
109 changes: 109 additions & 0 deletions
109
apps/web/src/app/admin-console/organizations/manage/new-groups.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
<app-header> | ||
<bit-search | ||
[placeholder]="'searchGroups' | i18n" | ||
[formControl]="searchControl" | ||
class="tw-w-80" | ||
></bit-search> | ||
<button bitButton type="button" buttonType="primary" (click)="add()"> | ||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> | ||
{{ "newGroup" | i18n }} | ||
</button> | ||
</app-header> | ||
|
||
<ng-container *ngIf="loading"> | ||
<i | ||
class="bwi bwi-spinner bwi-spin text-muted" | ||
title="{{ 'loading' | i18n }}" | ||
aria-hidden="true" | ||
></i> | ||
<span class="sr-only">{{ "loading" | i18n }}</span> | ||
</ng-container> | ||
<ng-container *ngIf="!loading"> | ||
<p *ngIf="!dataSource.filteredData.length">{{ "noGroupsInList" | i18n }}</p> | ||
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content | ||
from overflowing the <main> element. --> | ||
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8"> | ||
<bit-table *ngIf="dataSource.filteredData.length" [dataSource]="dataSource"> | ||
<ng-container header> | ||
<tr> | ||
<th bitCell class="tw-w-20"> | ||
<input | ||
type="checkbox" | ||
bitCheckbox | ||
class="tw-mr-2" | ||
(change)="toggleAllVisible($event)" | ||
id="selectAll" | ||
/> | ||
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{ | ||
"all" | i18n | ||
}}</label> | ||
</th> | ||
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th> | ||
<th bitCell>{{ "collections" | i18n }}</th> | ||
<th bitCell class="tw-w-10"> | ||
<button | ||
[bitMenuTriggerFor]="headerMenu" | ||
type="button" | ||
bitIconButton="bwi-ellipsis-v" | ||
size="small" | ||
appA11yTitle="{{ 'options' | i18n }}" | ||
></button> | ||
|
||
<bit-menu #headerMenu> | ||
<button type="button" bitMenuItem (click)="deleteAllSelected()"> | ||
<span class="tw-text-danger" | ||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span | ||
> | ||
</button> | ||
</bit-menu> | ||
</th> | ||
</tr> | ||
</ng-container> | ||
<ng-template body let-rows$> | ||
<tr bitRow *cdkVirtualFor="let g of rows$" [ngClass]="rowHeightClass"> | ||
<td bitCell (click)="check(g)" class="tw-cursor-pointer"> | ||
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" /> | ||
</td> | ||
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)"> | ||
<button type="button" bitLink> | ||
{{ g.details.name }} | ||
</button> | ||
</td> | ||
<td bitCell (click)="edit(g, ModalTabType.Collections)" class="tw-cursor-pointer"> | ||
<bit-badge-list | ||
[items]="g.collectionNames" | ||
[maxItems]="3" | ||
variant="secondary" | ||
></bit-badge-list> | ||
</td> | ||
<td bitCell> | ||
<button | ||
[bitMenuTriggerFor]="rowMenu" | ||
type="button" | ||
bitIconButton="bwi-ellipsis-v" | ||
size="small" | ||
appA11yTitle="{{ 'options' | i18n }}" | ||
></button> | ||
|
||
<bit-menu #rowMenu> | ||
<button type="button" bitMenuItem (click)="edit(g)"> | ||
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editInfo" | i18n }} | ||
</button> | ||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Members)"> | ||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "members" | i18n }} | ||
</button> | ||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Collections)"> | ||
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }} | ||
</button> | ||
<button type="button" bitMenuItem (click)="delete(g)"> | ||
<span class="tw-text-danger" | ||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span | ||
> | ||
</button> | ||
</bit-menu> | ||
</td> | ||
</tr> | ||
</ng-template> | ||
</bit-table> | ||
</cdk-virtual-scroll-viewport> | ||
</ng-container> |
255 changes: 255 additions & 0 deletions
255
apps/web/src/app/admin-console/organizations/manage/new-groups.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,255 @@ | ||
import { Component } from "@angular/core"; | ||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; | ||
import { FormControl } from "@angular/forms"; | ||
import { ActivatedRoute } from "@angular/router"; | ||
import { | ||
BehaviorSubject, | ||
combineLatest, | ||
concatMap, | ||
from, | ||
lastValueFrom, | ||
map, | ||
switchMap, | ||
tap, | ||
} from "rxjs"; | ||
import { debounceTime, first } from "rxjs/operators"; | ||
|
||
import { ApiService } from "@bitwarden/common/abstractions/api.service"; | ||
import { ListResponse } from "@bitwarden/common/models/response/list.response"; | ||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; | ||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; | ||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; | ||
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; | ||
import { Collection } from "@bitwarden/common/vault/models/domain/collection"; | ||
import { | ||
CollectionDetailsResponse, | ||
CollectionResponse, | ||
} from "@bitwarden/common/vault/models/response/collection.response"; | ||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; | ||
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components"; | ||
|
||
import { InternalGroupService as GroupService, GroupView } from "../core"; | ||
|
||
import { | ||
GroupAddEditDialogResultType, | ||
GroupAddEditTabType, | ||
openGroupAddEditDialog, | ||
} from "./group-add-edit.component"; | ||
|
||
type GroupDetailsRow = { | ||
/** | ||
* Details used for displaying group information | ||
*/ | ||
details: GroupView; | ||
|
||
/** | ||
* True if the group is selected in the table | ||
*/ | ||
checked?: boolean; | ||
|
||
/** | ||
* A list of collection names the group has access to | ||
*/ | ||
collectionNames?: string[]; | ||
}; | ||
|
||
/** | ||
* Custom filter predicate that filters the groups table by id and name only. | ||
* This is required because the default implementation searches by all properties, which can unintentionally match | ||
* with members' names (who are assigned to the group) or collection names (which the group has access to). | ||
*/ | ||
const groupsFilter = (filter: string) => { | ||
const transformedFilter = filter.trim().toLowerCase(); | ||
return (data: GroupDetailsRow) => { | ||
const group = data.details; | ||
|
||
return ( | ||
group.id.toLowerCase().indexOf(transformedFilter) != -1 || | ||
group.name.toLowerCase().indexOf(transformedFilter) != -1 | ||
); | ||
}; | ||
}; | ||
|
||
@Component({ | ||
templateUrl: "new-groups.component.html", | ||
}) | ||
export class NewGroupsComponent { | ||
loading = true; | ||
organizationId: string; | ||
|
||
protected dataSource = new TableDataSource<GroupDetailsRow>(); | ||
protected searchControl = new FormControl(""); | ||
|
||
// Fixed sizes used for cdkVirtualScroll | ||
protected rowHeight = 46; | ||
protected rowHeightClass = `tw-h-[46px]`; | ||
|
||
protected ModalTabType = GroupAddEditTabType; | ||
private refreshGroups$ = new BehaviorSubject<void>(null); | ||
|
||
constructor( | ||
private apiService: ApiService, | ||
private groupService: GroupService, | ||
private route: ActivatedRoute, | ||
private i18nService: I18nService, | ||
private dialogService: DialogService, | ||
private logService: LogService, | ||
private collectionService: CollectionService, | ||
private toastService: ToastService, | ||
) { | ||
this.route.params | ||
.pipe( | ||
tap((params) => (this.organizationId = params.organizationId)), | ||
switchMap(() => | ||
combineLatest([ | ||
// collectionMap | ||
from(this.apiService.getCollections(this.organizationId)).pipe( | ||
concatMap((response) => this.toCollectionMap(response)), | ||
), | ||
// groups | ||
this.refreshGroups$.pipe( | ||
switchMap(() => this.groupService.getAll(this.organizationId)), | ||
), | ||
]), | ||
), | ||
map(([collectionMap, groups]) => { | ||
return groups.map<GroupDetailsRow>((g) => ({ | ||
id: g.id, | ||
name: g.name, | ||
details: g, | ||
checked: false, | ||
collectionNames: g.collections | ||
.map((c) => collectionMap[c.id]?.name) | ||
.sort(this.i18nService.collator?.compare), | ||
})); | ||
}), | ||
takeUntilDestroyed(), | ||
) | ||
.subscribe((groups) => { | ||
this.dataSource.data = groups; | ||
this.loading = false; | ||
}); | ||
|
||
// Connect the search input to the table dataSource filter input | ||
this.searchControl.valueChanges | ||
.pipe(debounceTime(200), takeUntilDestroyed()) | ||
.subscribe((v) => (this.dataSource.filter = groupsFilter(v))); | ||
|
||
this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { | ||
this.searchControl.setValue(qParams.search); | ||
}); | ||
} | ||
|
||
async edit( | ||
group: GroupDetailsRow, | ||
startingTabIndex: GroupAddEditTabType = GroupAddEditTabType.Info, | ||
) { | ||
const dialogRef = openGroupAddEditDialog(this.dialogService, { | ||
data: { | ||
initialTab: startingTabIndex, | ||
organizationId: this.organizationId, | ||
groupId: group != null ? group.details.id : null, | ||
}, | ||
}); | ||
|
||
const result = await lastValueFrom(dialogRef.closed); | ||
|
||
if (result == GroupAddEditDialogResultType.Saved) { | ||
this.refreshGroups$.next(); | ||
} else if (result == GroupAddEditDialogResultType.Deleted) { | ||
this.removeGroup(group); | ||
} | ||
} | ||
|
||
async add() { | ||
await this.edit(null); | ||
} | ||
|
||
async delete(groupRow: GroupDetailsRow) { | ||
const confirmed = await this.dialogService.openSimpleDialog({ | ||
title: groupRow.details.name, | ||
content: { key: "deleteGroupConfirmation" }, | ||
type: "warning", | ||
}); | ||
if (!confirmed) { | ||
return false; | ||
} | ||
|
||
try { | ||
await this.groupService.delete(this.organizationId, groupRow.details.id); | ||
this.toastService.showToast({ | ||
variant: "success", | ||
title: null, | ||
message: this.i18nService.t("deletedGroupId", groupRow.details.name), | ||
}); | ||
this.removeGroup(groupRow); | ||
} catch (e) { | ||
this.logService.error(e); | ||
} | ||
} | ||
|
||
async deleteAllSelected() { | ||
const groupsToDelete = this.dataSource.data.filter((g) => g.checked); | ||
|
||
if (groupsToDelete.length == 0) { | ||
return; | ||
} | ||
|
||
const deleteMessage = groupsToDelete.map((g) => g.details.name).join(", "); | ||
const confirmed = await this.dialogService.openSimpleDialog({ | ||
title: { | ||
key: "deleteMultipleGroupsConfirmation", | ||
placeholders: [groupsToDelete.length.toString()], | ||
}, | ||
content: deleteMessage, | ||
type: "warning", | ||
}); | ||
if (!confirmed) { | ||
return false; | ||
} | ||
|
||
try { | ||
await this.groupService.deleteMany( | ||
this.organizationId, | ||
groupsToDelete.map((g) => g.details.id), | ||
); | ||
this.toastService.showToast({ | ||
variant: "success", | ||
title: null, | ||
message: this.i18nService.t("deletedManyGroups", groupsToDelete.length.toString()), | ||
}); | ||
|
||
groupsToDelete.forEach((g) => this.removeGroup(g)); | ||
} catch (e) { | ||
this.logService.error(e); | ||
} | ||
} | ||
|
||
check(groupRow: GroupDetailsRow) { | ||
groupRow.checked = !groupRow.checked; | ||
} | ||
|
||
toggleAllVisible(event: Event) { | ||
this.dataSource.filteredData.forEach( | ||
(g) => (g.checked = (event.target as HTMLInputElement).checked), | ||
); | ||
} | ||
|
||
private removeGroup(groupRow: GroupDetailsRow) { | ||
// Assign a new array to dataSource.data to trigger the setters and update the table | ||
this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow); | ||
} | ||
|
||
private async toCollectionMap(response: ListResponse<CollectionResponse>) { | ||
const collections = response.data.map( | ||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)), | ||
); | ||
const decryptedCollections = await this.collectionService.decryptMany(collections); | ||
|
||
// Convert to an object using collection Ids as keys for faster name lookups | ||
const collectionMap: Record<string, CollectionView> = {}; | ||
decryptedCollections.forEach((c) => (collectionMap[c.id] = c)); | ||
|
||
return collectionMap; | ||
} | ||
} |
Oops, something went wrong.