diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index 27864fa2f87..743f8ff1b68 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -84,9 +84,9 @@ class="tw-mb-10" > - @if (emptyTableExplanation()) { + @if (this.dataSource.filteredData?.length === 0) {
- {{ emptyTableExplanation() }} + {{ "noApplicationsMatchTheseFilters" | i18n }}
} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts index 0020106ba7d..962628584d3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -117,7 +117,6 @@ export class ApplicationsComponent implements OnInit { icon: " ", }, ]); - protected readonly emptyTableExplanation = signal(""); readonly allSelectedAppsAreCritical = computed(() => { if (!this.dataSource.filteredData || this.selectedUrls().size == 0) { @@ -174,6 +173,9 @@ export class ApplicationsComponent implements OnInit { })); this.dataSource.data = tableDataWithIcon; this.totalApplicationsCount.set(report.reportData.length); + this.criticalApplicationsCount.set( + report.reportData.filter((app) => app.isMarkedAsCritical).length, + ); } else { this.dataSource.data = []; } @@ -183,16 +185,6 @@ export class ApplicationsComponent implements OnInit { }, }); - this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: (criticalReport) => { - if (criticalReport != null) { - this.criticalApplicationsCount.set(criticalReport.reportData.length); - } else { - this.criticalApplicationsCount.set(0); - } - }, - }); - combineLatest([ this.searchControl.valueChanges.pipe(startWith("")), this.selectedFilterObservable, @@ -219,12 +211,6 @@ export class ApplicationsComponent implements OnInit { } }); this.selectedUrls.set(filteredUrls); - - if (this.dataSource?.filteredData?.length === 0) { - this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters")); - } else { - this.emptyTableExplanation.set(""); - } }); } @@ -240,7 +226,7 @@ export class ApplicationsComponent implements OnInit { .saveCriticalApplications(Array.from(this.selectedUrls())) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { + next: (response) => { this.toastService.showToast({ variant: "success", title: "", @@ -248,6 +234,9 @@ export class ApplicationsComponent implements OnInit { }); this.selectedUrls.set(new Set()); this.updatingCriticalApps.set(false); + this.criticalApplicationsCount.set( + response?.data?.summaryData?.totalCriticalApplicationCount ?? 0, + ); }, error: () => { this.toastService.showToast({ @@ -267,7 +256,7 @@ export class ApplicationsComponent implements OnInit { .removeCriticalApplications(appsToUnmark) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { + next: (response) => { this.toastService.showToast({ message: this.i18nService.t( "numApplicationsUnmarkedCriticalSuccess", @@ -277,6 +266,9 @@ export class ApplicationsComponent implements OnInit { }); this.selectedUrls.set(new Set()); this.updatingCriticalApps.set(false); + this.criticalApplicationsCount.set( + response?.data?.summaryData?.totalCriticalApplicationCount ?? 0, + ); }, error: () => { this.toastService.showToast({ diff --git a/libs/components/src/chip-select/chip-select.component.spec.ts b/libs/components/src/chip-select/chip-select.component.spec.ts index 3a66b799652..bfabb5ea95e 100644 --- a/libs/components/src/chip-select/chip-select.component.spec.ts +++ b/libs/components/src/chip-select/chip-select.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, signal } from "@angular/core"; +import { ChangeDetectionStrategy, Component, signal, computed } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormControl } from "@angular/forms"; import { By } from "@angular/platform-browser"; @@ -502,3 +502,157 @@ class TestAppComponent { readonly disabled = signal(false); readonly fullWidth = signal(false); } + +describe("ChipSelectComponentWithDynamicOptions", () => { + let component: ChipSelectComponent; + let fixture: ComponentFixture; + + const getChipButton = () => + fixture.debugElement.query(By.css("[data-fvw-target]"))?.nativeElement as HTMLButtonElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestAppWithDynamicOptionsComponent, NoopAnimationsModule], + providers: [{ provide: I18nService, useValue: mockI18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestAppWithDynamicOptionsComponent); + fixture.detectChanges(); + + component = fixture.debugElement.query(By.directive(ChipSelectComponent)).componentInstance; + + fixture.componentInstance.firstCounter.set(0); + fixture.componentInstance.secondCounter.set(0); + + fixture.detectChanges(); + }); + + describe("User-Facing Behavior", () => { + it("should update available options when they change", () => { + const first = 5; + const second = 10; + + const testApp = fixture.componentInstance; + testApp.firstCounter.set(first); + testApp.secondCounter.set(second); + fixture.detectChanges(); + + getChipButton().click(); + fixture.detectChanges(); + + const menuItems = Array.from(document.querySelectorAll("[bitMenuItem]")); + expect(menuItems.some((el) => el.textContent?.includes(`Option - ${first}`))).toBe(true); + expect(menuItems.some((el) => el.textContent?.includes(`Option - ${second}`))).toBe(true); + }); + }); + + describe("Form Integration Behavior", () => { + it("should display selected option when form control value is set", () => { + const testApp = fixture.componentInstance; + testApp.firstCounter.set(1); + testApp.secondCounter.set(2); + + component.writeValue("opt2"); // select second menu option which has dynamic label + fixture.detectChanges(); + + const button = getChipButton(); + expect(button.textContent?.trim()).toContain("Option - 2"); // verify that the label reflects the dynamic value + + // change the dynamic values and verify that the menu still shows the correct labels for the options + // it should also keep opt2 selected since it's the same value, just with an updated label + const first = 10; + const second = 20; + + testApp.firstCounter.set(first); + testApp.secondCounter.set(second); + fixture.detectChanges(); + + // again, verify that the label reflects the dynamic value + expect(button.textContent?.trim()).toContain(`Option - ${second}`); + + // click the button to open the menu + getChipButton().click(); + fixture.detectChanges(); + + // verify that the menu items also reflect the updated dynamic values + const menuItems = Array.from(document.querySelectorAll("[bitMenuItem]")); + expect(menuItems.some((el) => el.textContent?.includes(`Option - ${first}`))).toBe(true); + expect(menuItems.some((el) => el.textContent?.includes(`Option - ${second}`))).toBe(true); + }); + + it("should find and display nested option when form control value is set", () => { + const testApp = fixture.componentInstance; + testApp.firstCounter.set(1); + testApp.secondCounter.set(2); + + component.writeValue("child1"); // select a child menu item + fixture.detectChanges(); + + const button = getChipButton(); + // verify that the label reflects the dynamic value for the child option + expect(button.textContent?.trim()).toContain("Child - 1"); + + const first = 10; + const second = 20; + + testApp.firstCounter.set(first); + testApp.secondCounter.set(second); + fixture.detectChanges(); + + // again, verify that the label reflects the dynamic value + expect(button.textContent?.trim()).toContain(`Child - ${first}`); + }); + + it("should clear selection when form control value is set to null", () => { + const testApp = fixture.componentInstance; + testApp.firstCounter.set(1); + testApp.secondCounter.set(2); + + component.writeValue("opt1"); + fixture.detectChanges(); + + expect(getChipButton().textContent).toContain("Option - 1"); + + component.writeValue(null as any); + fixture.detectChanges(); + expect(getChipButton().textContent).toContain("Select an option"); + }); + }); +}); /* end of ChipSelectComponentWithDynamicOptions tests */ +@Component({ + selector: "test-app-with-dynamic-options", + template: ` + + `, + imports: [ChipSelectComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestAppWithDynamicOptionsComponent { + readonly firstCounter = signal(1); + readonly secondCounter = signal(2); + readonly options = computed(() => { + const first = this.firstCounter(); + const second = this.secondCounter(); + return [ + { label: `Option - ${first}`, value: "opt1", icon: "bwi-folder" }, + { label: `Option - ${second}`, value: "opt2" }, + { + label: "Parent Option", + value: "parent", + children: [ + { label: `Child - ${first}`, value: "child1" }, + { label: `Child - ${second}`, value: "child2" }, + ], + }, + ]; + }); + + readonly disabled = signal(false); + readonly fullWidth = signal(false); +} diff --git a/libs/components/src/chip-select/chip-select.component.ts b/libs/components/src/chip-select/chip-select.component.ts index 50e462dc815..1e988960472 100644 --- a/libs/components/src/chip-select/chip-select.component.ts +++ b/libs/components/src/chip-select/chip-select.component.ts @@ -106,8 +106,19 @@ export class ChipSelectComponent implements ControlValueAccessor { constructor() { // Initialize the root tree whenever options change effect(() => { + const currentSelection = this.selectedOption; + + // when the options change, clear the childParentMap + this.childParentMap.clear(); + this.initializeRootTree(this.options()); + // when the options change, we need to change our selectedOption + // to reflect the changed options. + if (currentSelection?.value != null) { + this.selectedOption = this.findOption(this.rootTree, currentSelection.value); + } + // If there's a pending value, apply it now that options are available if (this.pendingValue !== undefined) { this.selectedOption = this.findOption(this.rootTree, this.pendingValue);