1
0
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:
Brad
2026-02-06 10:04:48 -08:00
committed by jaasen-livefront
parent 98c742e891
commit f56592ad09
6 changed files with 270 additions and 71 deletions

View File

@@ -1247,6 +1247,9 @@
"selectAll": {
"message": "Select all"
},
"deselectAll": {
"message": "Deselect all"
},
"unselectAll": {
"message": "Unselect all"
},

View File

@@ -46,7 +46,6 @@
<app-table-row-scrollable-m11
[dataSource]="dataSource"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls()"
[openApplication]="drawerDetails.invokerId || ''"
[checkboxChange]="onCheckboxChange"

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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);
});
});
});

View File

@@ -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();
}
}
}