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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user