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