1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 11:24:07 +00:00

[PM-32013] Empty state incorrectly rendered (#19033)

This commit is contained in:
Vijay Oommen
2026-02-19 11:12:03 -06:00
committed by GitHub
parent 4f256fee6d
commit d0ccb9cd31
4 changed files with 179 additions and 22 deletions

View File

@@ -84,9 +84,9 @@
class="tw-mb-10"
></app-table-row-scrollable-m11>
@if (emptyTableExplanation()) {
@if (this.dataSource.filteredData?.length === 0) {
<div class="tw-flex tw-mt-10 tw-justify-center">
<span bitTypography="body2">{{ emptyTableExplanation() }}</span>
<span bitTypography="body2">{{ "noApplicationsMatchTheseFilters" | i18n }}</span>
</div>
}
</div>

View File

@@ -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<string>());
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<string>());
this.updatingCriticalApps.set(false);
this.criticalApplicationsCount.set(
response?.data?.summaryData?.totalCriticalApplicationCount ?? 0,
);
},
error: () => {
this.toastService.showToast({

View File

@@ -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<string>;
let fixture: ComponentFixture<TestAppWithDynamicOptionsComponent>;
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<HTMLButtonElement>("[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<HTMLButtonElement>("[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: `
<bit-chip-select
placeholderText="Select an option"
placeholderIcon="bwi-filter"
[options]="options()"
[disabled]="disabled()"
[fullWidth]="fullWidth()"
/>
`,
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);
}

View File

@@ -106,8 +106,19 @@ export class ChipSelectComponent<T = unknown> 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);