1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 23:45:37 +00:00

[EC-16] Update Group tab to use table component and show collections.

This commit is contained in:
Shane Melton
2022-09-15 18:16:13 -07:00
parent f1b473f750
commit 2f5c139338
7 changed files with 286 additions and 73 deletions

View File

@@ -1,18 +1,21 @@
<div class="container page-content">
<div class="page-header d-flex">
<h1>{{ "groups" | i18n }}</h1>
<div class="ml-auto d-flex">
<div>
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
<div class="tw-ml-auto tw-flex tw-items-center">
<div class="tw-mr-2">
<label class="sr-only">{{ "search" | i18n }}</label>
<div class="tw-flex tw-items-center">
<i class="bwi bwi-search bwi-fw tw-z-20 -tw-mr-7 tw-text-muted" aria-hidden="true"></i>
<input
bitInput
type="search"
placeholder="{{ 'search' | i18n }}"
class="tw-rounded-l tw-pl-9"
[(ngModel)]="searchText"
/>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="add()">
<button bitButton type="button" buttonType="primary" (click)="add()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newGroup" | i18n }}
</button>
@@ -26,53 +29,105 @@
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="
!loading &&
(isPaging() ? pagedGroups : (groups | search: searchText:'name':'id')) as searchedGroups
"
>
<p *ngIf="!searchedGroups.length">{{ "noGroupsInList" | i18n }}</p>
<table
class="table table-hover table-list"
*ngIf="searchedGroups.length"
infiniteScroll
<ng-container *ngIf="!loading && visibleGroups">
<p *ngIf="!visibleGroups.length">{{ "noGroupsInList" | i18n }}</p>
<bit-table
*ngIf="visibleGroups.length"
infinite-scroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let g of searchedGroups">
<td>
<a href="#" appStopClick (click)="edit(g)">{{ g.name }}</a>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<input
type="checkbox"
class="tw-mr-2"
(change)="toggleAllVisible($event)"
id="selectAll"
/>
<label class="tw-mb-0" for="selectAll">{{ "all" | i18n }}</label>
</th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "collections" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
class="tw-border-none tw-bg-transparent tw-text-muted"
[bitMenuTriggerFor]="headerMenu"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
>
<i aria-hidden="true" class="bwi bwi-ellipsis-v bwi-lg tw-font-bold"></i>
</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</span
>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="users(g)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "users" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(g)">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</a>
</div>
</div>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let g of visibleGroups">
<td bitCell (click)="check(g)">
<input type="checkbox" [(ngModel)]="g.checked" />
</td>
<td bitCell class="tw-text-lg tw-font-bold" (click)="edit(g)">
<button (click)="edit(g)" bitLink>
{{ g.name }}
</button>
</td>
<td bitCell (click)="edit(g)">
<span
*ngFor="let cName of g.collectionNames.slice(0, maxCollections); let last = last"
bitBadge
badgeType="secondary"
class="tw-mx-1"
>
{{ cName }} <span class="sr-only" *ngIf="!last">, </span>
</span>
<span
*ngIf="g.collectionNames.length > maxCollections"
bitBadge
badgeType="secondary"
class="tw-mx-1"
>
{{ "plusNMore" | i18n: (g.collectionNames.length - maxCollections).toString() }}
</span>
</td>
<td bitCell>
<button
class="tw-border-none tw-bg-transparent tw-text-muted"
[bitMenuTriggerFor]="rowMenu"
type="button"
>
<i aria-hidden="true" class="bwi bwi-ellipsis-v bwi-lg tw-font-bold"></i>
</button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="edit(g)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> Edit Info
</button>
<button type="button" bitMenuItem (click)="edit(g)">
<i aria-hidden="true" class="bwi bwi-user"></i> Members
</button>
<button type="button" bitMenuItem (click)="edit(g)">
<i aria-hidden="true" class="bwi bwi-collection"></i> Collections
</button>
<button type="button" bitMenuItem (click)="delete(g)">
<span class="tw-text-danger"
><i aria-hidden="true" class="bwi bwi-trash"></i> Delete</span
>
</button>
</bit-menu>
</td>
</tr>
</tbody>
</table>
</ng-container>
</bit-table>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #usersTemplate></ng-template>

View File

@@ -3,18 +3,34 @@ import { ActivatedRoute } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Utils } from "@bitwarden/common/misc/utils";
import { GroupResponse } from "@bitwarden/common/models/response/groupResponse";
import { CollectionData } from "@bitwarden/common/models/data/collectionData";
import { Collection } from "@bitwarden/common/models/domain/collection";
import { OrganizationUserBulkRequest } from "@bitwarden/common/models/request/organizationUserBulkRequest";
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collectionResponse";
import { GroupDetailsResponse } from "@bitwarden/common/models/response/groupResponse";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { EntityUsersComponent } from "./entity-users.component";
import { GroupAddEditComponent } from "./group-add-edit.component";
type CollectionViewMap = {
[id: string]: CollectionView;
};
type GroupDetailsView = Partial<GroupDetailsResponse> & {
checked?: boolean;
collectionNames?: string[];
};
@Component({
selector: "app-org-groups",
templateUrl: "groups.component.html",
@@ -26,16 +42,45 @@ export class GroupsComponent implements OnInit, OnDestroy {
loading = true;
organizationId: string;
groups: GroupResponse[];
pagedGroups: GroupResponse[];
searchText: string;
groups: GroupDetailsView[];
collectionMap: CollectionViewMap = {};
selectAll = false;
protected didScroll = false;
protected pageSize = 100;
protected maxCollections = 2;
private pagedGroupsCount = 0;
private pagedGroups: GroupDetailsView[];
private searchedGroups: GroupDetailsView[];
private _searchText: string;
private destroy$ = new Subject<void>();
get searchText() {
return this._searchText;
}
set searchText(value: string) {
this._searchText = value;
// Manually update as we are not using the search pipe in the template
this.updateSearchedGroups();
}
/**
* The list of groups that should be visible in the table.
* This is needed as there are two modes (paging/searching) and
* we need a reference to the currently visible groups for
* the Select All checkbox
*/
get visibleGroups() {
if (this.isPaging()) {
return this.pagedGroups;
}
if (this.isSearching()) {
return this.searchedGroups;
}
return this.groups;
}
constructor(
private apiService: ApiService,
private route: ActivatedRoute,
@@ -43,7 +88,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
private modalService: ModalService,
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
private logService: LogService
private logService: LogService,
private collectionService: CollectionService,
private searchPipe: SearchPipe
) {}
async ngOnInit() {
@@ -51,6 +98,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
.pipe(
concatMap(async (params) => {
this.organizationId = params.organizationId;
await this.loadCollections();
await this.load();
}),
takeUntil(this.destroy$)
@@ -76,12 +124,38 @@ export class GroupsComponent implements OnInit, OnDestroy {
async load() {
const response = await this.apiService.getGroups(this.organizationId);
const groups = response.data != null && response.data.length > 0 ? response.data : [];
groups.sort(Utils.getSortFunction(this.i18nService, "name"));
this.groups = groups;
this.groups = groups
.sort(Utils.getSortFunction(this.i18nService, "name"))
.map<GroupDetailsView>((g) => ({
...g,
checked: false,
collectionNames: g.collections
.map((c) => this.collectionMap[c.id]?.name)
.sort(this.i18nService.collator?.compare),
}));
this.resetPaging();
this.updateSearchedGroups();
this.loading = false;
}
private updateSearchedGroups() {
if (this.searchService.isSearchable(this.searchText)) {
// Making use of the pipe in the component as we need know which groups where filtered
this.searchedGroups = this.searchPipe.transform(this.groups, this.searchText, "name", "id");
}
}
async loadCollections() {
const response = await this.apiService.getCollections(this.organizationId);
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
decryptedCollections.forEach((c) => (this.collectionMap[c.id] = c));
}
loadMore() {
if (!this.groups || this.groups.length <= this.pageSize) {
return;
@@ -100,7 +174,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.didScroll = this.pagedGroups.length > this.pageSize;
}
async edit(group: GroupResponse) {
async edit(group: GroupDetailsResponse) {
const [modal] = await this.modalService.openViewRef(
GroupAddEditComponent,
this.addEditModalRef,
@@ -113,7 +187,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
});
comp.onDeletedGroup.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.removeGroup(group);
this.removeGroup(group.id);
});
}
);
@@ -123,7 +197,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.edit(null);
}
async delete(group: GroupResponse) {
async delete(group: GroupDetailsResponse) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteGroupConfirmation"),
group.name,
@@ -142,13 +216,45 @@ export class GroupsComponent implements OnInit, OnDestroy {
null,
this.i18nService.t("deletedGroupId", group.name)
);
this.removeGroup(group);
this.removeGroup(group.id);
} catch (e) {
this.logService.error(e);
}
}
async users(group: GroupResponse) {
async deleteAllSelected() {
const groupsToDelete = this.groups.filter((g) => g.checked);
if (groupsToDelete.length == 0) {
return;
}
const deleteMessage = groupsToDelete.map((g) => g.name).join(", ");
const confirmed = await this.platformUtilsService.showDialog(
deleteMessage,
this.i18nService.t("deleteMultipleGroupsConfirmation", groupsToDelete.length.toString()),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
const result = await this.apiService.deleteManyGroups(
this.organizationId,
new OrganizationUserBulkRequest(groupsToDelete.map((g) => g.id))
);
this.platformUtilsService.showToast("success", null, `Delete ${result.data.length} groups!`);
groupsToDelete.forEach((g) => this.removeGroup(g.id));
} catch (e) {
this.logService.error(e);
}
}
async users(group: GroupDetailsResponse) {
const [modal] = await this.modalService.openViewRef(
EntityUsersComponent,
this.usersModalRef,
@@ -174,6 +280,14 @@ export class GroupsComponent implements OnInit, OnDestroy {
return this.searchService.isSearchable(this.searchText);
}
check(group: GroupDetailsView) {
group.checked = !group.checked;
}
toggleAllVisible(event: Event) {
this.visibleGroups.forEach((g) => (g.checked = (event.target as HTMLInputElement).checked));
}
isPaging() {
const searching = this.isSearching();
if (searching && this.didScroll) {
@@ -182,11 +296,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
return !searching && this.groups && this.groups.length > this.pageSize;
}
private removeGroup(group: GroupResponse) {
const index = this.groups.indexOf(group);
private removeGroup(id: string) {
const index = this.groups.findIndex((g) => g.id === id);
if (index > -1) {
this.groups.splice(index, 1);
this.resetPaging();
this.updateSearchedGroups();
}
}
}

View File

@@ -1,5 +1,5 @@
import { DragDropModule } from "@angular/cdk/drag-drop";
import { DatePipe, CommonModule } from "@angular/common";
import { CommonModule, DatePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
@@ -12,10 +12,12 @@ import {
ButtonModule,
CalloutModule,
FormFieldModule,
SubmitButtonModule,
MenuModule,
TabsModule,
IconModule,
LinkModule,
MenuModule,
SubmitButtonModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
// Register the locales for the application
@@ -48,6 +50,8 @@ import "./locales";
SubmitButtonModule,
IconModule,
TabsModule,
TableModule,
LinkModule,
],
exports: [
CommonModule,
@@ -68,6 +72,8 @@ import "./locales";
SubmitButtonModule,
IconModule,
TabsModule,
TableModule,
LinkModule,
],
providers: [DatePipe],
bootstrap: [],

View File

@@ -2326,6 +2326,15 @@
"deleteGroupConfirmation": {
"message": "Are you sure you want to delete this group?"
},
"deleteMultipleGroupsConfirmation": {
"message": "Are you sure you want to delete the following $QUANTITY$ group(s)?",
"placeholders": {
"quantity": {
"content": "$1",
"example": "3"
}
}
},
"removeUserConfirmation": {
"message": "Are you sure you want to remove this user?"
},
@@ -5370,5 +5379,14 @@
},
"numberOfUsers": {
"message": "Number of users"
},
"plusNMore": {
"message": "+ $QUANTITY$ more",
"placeholders": {
"quantity": {
"content": "$1",
"example": "5"
}
}
}
}

View File

@@ -334,12 +334,16 @@ export abstract class ApiService {
) => Promise<any>;
getGroupDetails: (organizationId: string, id: string) => Promise<GroupDetailsResponse>;
getGroups: (organizationId: string) => Promise<ListResponse<GroupResponse>>;
getGroups: (organizationId: string) => Promise<ListResponse<GroupDetailsResponse>>;
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
postGroup: (organizationId: string, request: GroupRequest) => Promise<GroupResponse>;
putGroup: (organizationId: string, id: string, request: GroupRequest) => Promise<GroupResponse>;
putGroupUsers: (organizationId: string, id: string, request: string[]) => Promise<any>;
deleteGroup: (organizationId: string, id: string) => Promise<any>;
deleteManyGroups: (
organizationId: string,
request: OrganizationUserBulkRequest
) => Promise<ListResponse<GroupResponse>>;
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
getOrganizationUser: (

View File

@@ -161,8 +161,8 @@ import { TwoFactorEmailResponse } from "../models/response/twoFactorEmailRespons
import { TwoFactorProviderResponse } from "../models/response/twoFactorProviderResponse";
import { TwoFactorRecoverResponse } from "../models/response/twoFactorRescoverResponse";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../models/response/twoFactorWebAuthnResponse";
import { TwoFactorYubiKeyResponse } from "../models/response/twoFactorYubiKeyResponse";
import { UserKeyResponse } from "../models/response/userKeyResponse";
@@ -916,7 +916,7 @@ export class ApiService implements ApiServiceAbstraction {
return new GroupDetailsResponse(r);
}
async getGroups(organizationId: string): Promise<ListResponse<GroupResponse>> {
async getGroups(organizationId: string): Promise<ListResponse<GroupDetailsResponse>> {
const r = await this.send(
"GET",
"/organizations/" + organizationId + "/groups",
@@ -924,7 +924,7 @@ export class ApiService implements ApiServiceAbstraction {
true,
true
);
return new ListResponse(r, GroupResponse);
return new ListResponse(r, GroupDetailsResponse);
}
async getGroupUsers(organizationId: string, id: string): Promise<string[]> {
@@ -984,6 +984,20 @@ export class ApiService implements ApiServiceAbstraction {
);
}
async deleteManyGroups(
organizationId: string,
request: OrganizationUserBulkRequest
): Promise<ListResponse<GroupResponse>> {
const r = await this.send(
"DELETE",
"/organizations/" + organizationId + "/groups",
request,
true,
true
);
return new ListResponse(r, GroupResponse);
}
deleteGroupUser(organizationId: string, id: string, organizationUserId: string): Promise<any> {
return this.send(
"DELETE",

View File

@@ -9,5 +9,6 @@ export * from "./dialog";
export * from "./submit-button";
export * from "./link";
export * from "./tabs";
export * from "./table";
export * from "./toggle-group";
export * from "./utils/i18n-mock.service";