diff --git a/src/app/components/custom-select/custom-select.spec.ts b/src/app/components/custom-select/custom-select.spec.ts
new file mode 100644
index 0000000..d13564d
--- /dev/null
+++ b/src/app/components/custom-select/custom-select.spec.ts
@@ -0,0 +1,56 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { FormsModule } from '@angular/forms';
+
+import { CustomSelectComponent } from './custom-select';
+
+@Component({
+ standalone: true,
+ imports: [FormsModule, CustomSelectComponent],
+ template: `
+
+
+ `,
+})
+class HostComponent {
+ options = [
+ { label: 'Primeira', value: 'one' },
+ { label: 'Segunda', value: 'two' },
+ ];
+ firstValue = 'one';
+ secondValue = 'two';
+}
+
+describe('CustomSelectComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(HostComponent);
+ fixture.detectChanges();
+ });
+
+ it('should keep only one select panel open at a time', () => {
+ const selectComponents = fixture.debugElement
+ .queryAll(By.directive(CustomSelectComponent))
+ .map((debugEl) => debugEl.componentInstance as CustomSelectComponent);
+ const triggerButtons = fixture.nativeElement.querySelectorAll('.app-select-trigger') as NodeListOf;
+
+ triggerButtons[0].click();
+ fixture.detectChanges();
+
+ expect(selectComponents[0].isOpen).toBeTrue();
+ expect(selectComponents[1].isOpen).toBeFalse();
+
+ triggerButtons[1].click();
+ fixture.detectChanges();
+
+ expect(selectComponents[0].isOpen).toBeFalse();
+ expect(selectComponents[1].isOpen).toBeTrue();
+ expect(fixture.nativeElement.querySelectorAll('.app-select-panel').length).toBe(1);
+ });
+});
diff --git a/src/app/components/custom-select/custom-select.ts b/src/app/components/custom-select/custom-select.ts
index 3ef4bcb..820eef0 100644
--- a/src/app/components/custom-select/custom-select.ts
+++ b/src/app/components/custom-select/custom-select.ts
@@ -1,6 +1,8 @@
-import { Component, ElementRef, HostListener, Input, forwardRef } from '@angular/core';
+import { Component, ElementRef, HostListener, Input, OnDestroy, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { Subscription } from 'rxjs';
+import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
@Component({
selector: 'app-select',
@@ -16,7 +18,9 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
},
],
})
-export class CustomSelectComponent implements ControlValueAccessor {
+export class CustomSelectComponent implements ControlValueAccessor, OnDestroy {
+ private static nextDropdownId = 0;
+
@Input() options: any[] = [];
@Input() placeholder = 'Selecione uma opção';
@Input() labelKey = 'label';
@@ -29,11 +33,22 @@ export class CustomSelectComponent implements ControlValueAccessor {
isOpen = false;
value: any = null;
searchTerm = '';
+ private readonly dropdownId = `custom-select-${CustomSelectComponent.nextDropdownId++}`;
+ private readonly dropdownSyncSub: Subscription;
private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => {};
- constructor(private host: ElementRef) {}
+ constructor(
+ private host: ElementRef,
+ private dropdownCoordinator: DropdownCoordinatorService
+ ) {
+ this.dropdownSyncSub = this.dropdownCoordinator.activeDropdownId$.subscribe((activeId) => {
+ if (this.isOpen && activeId !== this.dropdownId) {
+ this.close(false);
+ }
+ });
+ }
writeValue(value: any): void {
this.value = value;
@@ -49,7 +64,7 @@ export class CustomSelectComponent implements ControlValueAccessor {
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
- if (this.disabled) this.isOpen = false;
+ if (this.disabled) this.close();
}
get displayLabel(): string {
@@ -65,13 +80,21 @@ export class CustomSelectComponent implements ControlValueAccessor {
toggle(): void {
if (this.disabled) return;
- this.isOpen = !this.isOpen;
- if (!this.isOpen) this.searchTerm = '';
+ if (this.isOpen) {
+ this.close();
+ return;
+ }
+
+ this.dropdownCoordinator.requestOpen(this.dropdownId);
+ this.isOpen = true;
}
- close(): void {
+ close(notifyCoordinator = true): void {
this.isOpen = false;
this.searchTerm = '';
+ if (notifyCoordinator) {
+ this.dropdownCoordinator.clear(this.dropdownId);
+ }
}
selectOption(option: any): void {
@@ -148,4 +171,9 @@ export class CustomSelectComponent implements ControlValueAccessor {
onEsc(): void {
if (this.isOpen) this.close();
}
+
+ ngOnDestroy(): void {
+ this.close();
+ this.dropdownSyncSub.unsubscribe();
+ }
}
diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts
index a16d540..4f94d6b 100644
--- a/src/app/pages/geral/geral.spec.ts
+++ b/src/app/pages/geral/geral.spec.ts
@@ -5,6 +5,7 @@ import { HttpParams } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { Geral } from './geral';
+import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
describe('Geral', () => {
let component: Geral;
@@ -171,4 +172,20 @@ describe('Geral', () => {
expect(params.get('includeAssignedReservaInAll')).toBeNull();
expect(params.get('skil')).toBe('RESERVA');
});
+
+ it('should close custom filter dropdowns when another filter dropdown opens', () => {
+ const dropdownCoordinator = TestBed.inject(DropdownCoordinatorService);
+
+ component.toggleAdditionalMenu();
+ expect(component.showAdditionalMenu).toBeTrue();
+
+ component.toggleClientMenu();
+ expect(component.showClientMenu).toBeTrue();
+ expect(component.showAdditionalMenu).toBeFalse();
+
+ dropdownCoordinator.requestOpen('external-filter');
+
+ expect(component.showClientMenu).toBeFalse();
+ expect(component.showAdditionalMenu).toBeFalse();
+ });
});
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts
index 7f13668..06efc75 100644
--- a/src/app/pages/geral/geral.ts
+++ b/src/app/pages/geral/geral.ts
@@ -31,6 +31,7 @@ import {
type MveAuditIssue,
type MveAuditRun,
} from '../../services/mve-audit.service';
+import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@@ -361,9 +362,14 @@ interface MveApplySelectionSummary {
styleUrls: ['./geral.scss']
})
export class Geral implements OnInit, AfterViewInit, OnDestroy {
+ private static nextFilterDropdownScopeId = 0;
+
readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = '';
+ private readonly filterDropdownScopeId = Geral.nextFilterDropdownScopeId++;
+ private readonly clientDropdownId = `geral-client-filter-${this.filterDropdownScopeId}`;
+ private readonly additionalDropdownId = `geral-additional-filter-${this.filterDropdownScopeId}`;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef;
@@ -385,7 +391,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService,
- private mveAuditService: MveAuditService
+ private mveAuditService: MveAuditService,
+ private dropdownCoordinator: DropdownCoordinatorService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
@@ -509,6 +516,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private editingId: string | null = null;
private searchTimer: any = null;
private navigationSub?: Subscription;
+ private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null;
@@ -968,12 +976,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false;
if (this.showClientMenu && !insideClient) {
- this.showClientMenu = false;
+ this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu && !insideAdditional) {
- this.showAdditionalMenu = false;
+ this.closeAdditionalDropdown();
changed = true;
}
@@ -999,12 +1007,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false;
if (this.showClientMenu) {
- this.showClientMenu = false;
+ this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu) {
- this.showAdditionalMenu = false;
+ this.closeAdditionalDropdown();
changed = true;
}
@@ -1018,6 +1026,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.navigationSub?.unsubscribe();
+ this.dropdownSyncSub?.unsubscribe();
}
ngOnInit(): void {
@@ -1035,6 +1044,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.selectedAdditionalServices = [];
this.selectedClients = [];
}
+
+ this.dropdownSyncSub = this.dropdownCoordinator.activeDropdownId$.subscribe((activeId) => {
+ if (activeId !== this.clientDropdownId) {
+ this.closeClientDropdown(false);
+ }
+
+ if (activeId !== this.additionalDropdownId) {
+ this.closeAdditionalDropdown(false);
+ }
+ });
}
async ngAfterViewInit() {
@@ -2536,27 +2555,43 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleClientMenu() {
if (this.isClientRestricted) return;
- if (!this.showClientMenu) this.showAdditionalMenu = false;
- this.showClientMenu = !this.showClientMenu;
+ if (this.showClientMenu) {
+ this.closeClientDropdown();
+ return;
+ }
+
+ this.dropdownCoordinator.requestOpen(this.clientDropdownId);
+ this.showClientMenu = true;
}
toggleAdditionalMenu() {
if (this.isClientRestricted) return;
- if (!this.showAdditionalMenu) this.showClientMenu = false;
- this.showAdditionalMenu = !this.showAdditionalMenu;
+ if (this.showAdditionalMenu) {
+ this.closeAdditionalDropdown();
+ return;
+ }
+
+ this.dropdownCoordinator.requestOpen(this.additionalDropdownId);
+ this.showAdditionalMenu = true;
}
- closeClientDropdown() {
+ closeClientDropdown(notifyCoordinator = true) {
this.showClientMenu = false;
+ if (notifyCoordinator) {
+ this.dropdownCoordinator.clear(this.clientDropdownId);
+ }
}
- closeAdditionalDropdown() {
+ closeAdditionalDropdown(notifyCoordinator = true) {
this.showAdditionalMenu = false;
+ if (notifyCoordinator) {
+ this.dropdownCoordinator.clear(this.additionalDropdownId);
+ }
}
closeFilterDropdowns() {
- this.showClientMenu = false;
- this.showAdditionalMenu = false;
+ this.closeClientDropdown();
+ this.closeAdditionalDropdown();
}
selectClient(client: string | null) {
diff --git a/src/app/services/dropdown-coordinator.service.ts b/src/app/services/dropdown-coordinator.service.ts
new file mode 100644
index 0000000..bb3cd73
--- /dev/null
+++ b/src/app/services/dropdown-coordinator.service.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class DropdownCoordinatorService {
+ private readonly activeDropdownIdSubject = new BehaviorSubject(null);
+ readonly activeDropdownId$ = this.activeDropdownIdSubject.asObservable();
+
+ get activeDropdownId(): string | null {
+ return this.activeDropdownIdSubject.value;
+ }
+
+ requestOpen(id: string): void {
+ if (!id || this.activeDropdownIdSubject.value === id) return;
+ this.activeDropdownIdSubject.next(id);
+ }
+
+ clear(id?: string): void {
+ if (id && this.activeDropdownIdSubject.value !== id) return;
+ if (this.activeDropdownIdSubject.value === null) return;
+ this.activeDropdownIdSubject.next(null);
+ }
+}