mirror of
https://github.com/bitwarden/browser
synced 2026-02-19 19:04:01 +00:00
[PM-30543] Add select all checkbox to Access Intelligance Applications table (#18682)
Adds a "Select all" checkbox to the table in the Access Intelligence applications tab. This allows users to quickly select or deselect all applications currently showing in the table for marking as critical apps.
This commit is contained in:
@@ -1247,6 +1247,9 @@
|
||||
"selectAll": {
|
||||
"message": "Select all"
|
||||
},
|
||||
"deselectAll": {
|
||||
"message": "Deselect all"
|
||||
},
|
||||
"unselectAll": {
|
||||
"message": "Unselect all"
|
||||
},
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
<app-table-row-scrollable-m11
|
||||
[dataSource]="dataSource"
|
||||
[showRowMenuForCriticalApps]="false"
|
||||
[selectedUrls]="selectedUrls()"
|
||||
[openApplication]="drawerDetails.invokerId || ''"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
|
||||
@@ -172,6 +172,15 @@ export class ApplicationsComponent implements OnInit {
|
||||
filterFunction(app) &&
|
||||
app.applicationName.toLowerCase().includes(searchText.toLowerCase());
|
||||
|
||||
// filter selectedUrls down to only applications showing with active filters
|
||||
const filteredUrls = new Set<string>();
|
||||
this.dataSource.filteredData?.forEach((row) => {
|
||||
if (this.selectedUrls().has(row.applicationName)) {
|
||||
filteredUrls.add(row.applicationName);
|
||||
}
|
||||
});
|
||||
this.selectedUrls.set(filteredUrls);
|
||||
|
||||
if (this.dataSource?.filteredData?.length === 0) {
|
||||
this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters"));
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<ng-container>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<bit-table-scroll [dataSource]="dataSource()" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th></th>
|
||||
<th bitCell>
|
||||
<input
|
||||
data-testid="selectAll"
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="allAppsSelected()"
|
||||
[bitTooltip]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)"
|
||||
(change)="selectAllChanged($event.target)"
|
||||
[attr.aria-label]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)"
|
||||
/>
|
||||
</th>
|
||||
<th bitCell></th>
|
||||
<th bitSortable="applicationName" bitCell tabindex="0">{{ "application" | i18n }}</th>
|
||||
<th bitSortable="atRiskPasswordCount" bitCell default="desc" tabindex="0">
|
||||
@@ -20,17 +30,17 @@
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="selectedUrls.has(row.applicationName)"
|
||||
(change)="checkboxChange(row.applicationName, $event)"
|
||||
[checked]="selectedUrls().has(row.applicationName)"
|
||||
(change)="checkboxChange()(row.applicationName, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -42,9 +52,9 @@
|
||||
<td
|
||||
class="tw-cursor-pointer tw-align-middle"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -62,9 +72,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -77,9 +87,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -92,9 +102,9 @@
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
@@ -108,36 +118,15 @@
|
||||
data-testid="total-membership"
|
||||
class="tw-cursor-pointer"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
(click)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers(row.applicationName)"
|
||||
(click)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.enter)="showAppAtRiskMembers()(row.applicationName)"
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
>
|
||||
{{ row.memberCount }}
|
||||
</td>
|
||||
@if (showRowMenuForCriticalApps) {
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
tabindex="0"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="unmarkAsCritical(row.applicationName)">
|
||||
{{ "unmarkAsCritical" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { AppTableRowScrollableM11Component } from "./app-table-row-scrollable-m11.component";
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const mockTableData: ApplicationHealthReportDetailEnriched[] = [
|
||||
{
|
||||
applicationName: "google.com",
|
||||
passwordCount: 5,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1" as any, "cipher-2" as any],
|
||||
memberCount: 3,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [
|
||||
{
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@google.com",
|
||||
cipherId: "cipher-1",
|
||||
},
|
||||
],
|
||||
atRiskMemberDetails: [
|
||||
{
|
||||
userGuid: "user-2",
|
||||
userName: "Jane Smith",
|
||||
email: "jane@google.com",
|
||||
cipherId: "cipher-2",
|
||||
},
|
||||
],
|
||||
cipherIds: ["cipher-1" as any, "cipher-2" as any],
|
||||
isMarkedAsCritical: true,
|
||||
},
|
||||
{
|
||||
applicationName: "facebook.com",
|
||||
passwordCount: 3,
|
||||
atRiskPasswordCount: 1,
|
||||
atRiskCipherIds: ["cipher-3" as any],
|
||||
memberCount: 2,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [
|
||||
{
|
||||
userGuid: "user-3",
|
||||
userName: "Alice Johnson",
|
||||
email: "alice@facebook.com",
|
||||
cipherId: "cipher-3",
|
||||
},
|
||||
],
|
||||
atRiskMemberDetails: [
|
||||
{
|
||||
userGuid: "user-4",
|
||||
userName: "Bob Wilson",
|
||||
email: "bob@facebook.com",
|
||||
cipherId: "cipher-4",
|
||||
},
|
||||
],
|
||||
cipherIds: ["cipher-3" as any, "cipher-4" as any],
|
||||
isMarkedAsCritical: false,
|
||||
},
|
||||
{
|
||||
applicationName: "twitter.com",
|
||||
passwordCount: 4,
|
||||
atRiskPasswordCount: 0,
|
||||
atRiskCipherIds: [],
|
||||
memberCount: 4,
|
||||
atRiskMemberCount: 0,
|
||||
memberDetails: [],
|
||||
atRiskMemberDetails: [],
|
||||
cipherIds: ["cipher-5" as any, "cipher-6" as any],
|
||||
isMarkedAsCritical: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("AppTableRowScrollableM11Component", () => {
|
||||
let fixture: ComponentFixture<AppTableRowScrollableM11Component>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockI18nService = mock<I18nService>();
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppTableRowScrollableM11Component],
|
||||
providers: [
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppTableRowScrollableM11Component);
|
||||
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
describe("select all checkbox", () => {
|
||||
let selectAllCheckboxEl: DebugElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
selectAllCheckboxEl = fixture.debugElement.query(By.css('[data-testid="selectAll"]'));
|
||||
});
|
||||
|
||||
it("should check all rows in table when checked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>();
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// act
|
||||
selectAllCheckboxEl.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectedUrls.has("google.com")).toBe(true);
|
||||
expect(selectedUrls.has("facebook.com")).toBe(true);
|
||||
expect(selectedUrls.has("twitter.com")).toBe(true);
|
||||
expect(selectedUrls.size).toBe(3);
|
||||
});
|
||||
|
||||
it("should uncheck all rows in table when unchecked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com", "twitter.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// act
|
||||
selectAllCheckboxEl.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectedUrls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should become checked when all rows in table are checked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com", "twitter.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectAllCheckboxEl.nativeElement.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("should become unchecked when any row in table is unchecked", () => {
|
||||
// arrange
|
||||
const selectedUrls = new Set<string>(["google.com", "facebook.com"]);
|
||||
const dataSource = new TableDataSource<ApplicationHealthReportDetailEnriched>();
|
||||
dataSource.data = mockTableData;
|
||||
|
||||
fixture.componentRef.setInput("selectedUrls", selectedUrls);
|
||||
fixture.componentRef.setInput("dataSource", dataSource);
|
||||
fixture.detectChanges();
|
||||
|
||||
// assert
|
||||
expect(selectAllCheckboxEl.nativeElement.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components";
|
||||
import { MenuModule, TableDataSource, TableModule, TooltipDirective } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
@@ -11,34 +11,52 @@ import { ApplicationTableDataSource } from "./app-table-row-scrollable.component
|
||||
//TODO: Rename this component to AppTableRowScrollableComponent once milestone 11 is fully rolled out
|
||||
//TODO: Move definition of ApplicationTableDataSource to this file from app-table-row-scrollable.component.ts
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-table-row-scrollable-m11",
|
||||
imports: [CommonModule, JslibModule, TableModule, SharedModule, PipesModule, MenuModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
TableModule,
|
||||
SharedModule,
|
||||
PipesModule,
|
||||
MenuModule,
|
||||
TooltipDirective,
|
||||
],
|
||||
templateUrl: "./app-table-row-scrollable-m11.component.html",
|
||||
})
|
||||
export class AppTableRowScrollableM11Component {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
dataSource!: TableDataSource<ApplicationTableDataSource>;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showRowMenuForCriticalApps: boolean = false;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() selectedUrls: Set<string> = new Set<string>();
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() openApplication: string = "";
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() unmarkAsCritical!: (applicationName: string) => void;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;
|
||||
readonly dataSource = input<TableDataSource<ApplicationTableDataSource>>();
|
||||
readonly selectedUrls = input<Set<string>>();
|
||||
readonly openApplication = input<string>("");
|
||||
readonly showAppAtRiskMembers = input<(applicationName: string) => void>();
|
||||
readonly checkboxChange = input<(applicationName: string, $event: Event) => void>();
|
||||
|
||||
allAppsSelected(): boolean {
|
||||
const tableData = this.dataSource()?.filteredData;
|
||||
const selectedUrls = this.selectedUrls();
|
||||
|
||||
if (!tableData || !selectedUrls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return tableData.length > 0 && tableData.every((row) => selectedUrls.has(row.applicationName));
|
||||
}
|
||||
|
||||
selectAllChanged(target: HTMLInputElement) {
|
||||
const checked = target.checked;
|
||||
|
||||
const tableData = this.dataSource()?.filteredData;
|
||||
const selectedUrls = this.selectedUrls();
|
||||
|
||||
if (!tableData || !selectedUrls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
tableData.forEach((row) => selectedUrls.add(row.applicationName));
|
||||
} else {
|
||||
selectedUrls.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user