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