fix: fecha filtros simultaneos na geral

This commit is contained in:
Eduardo Lopes 2026-03-12 14:42:24 -03:00
parent 0d7186ce59
commit 9a635ac167
5 changed files with 181 additions and 20 deletions

View File

@ -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: `
<app-select [options]="options" [(ngModel)]="firstValue"></app-select>
<app-select [options]="options" [(ngModel)]="secondValue"></app-select>
`,
})
class HostComponent {
options = [
{ label: 'Primeira', value: 'one' },
{ label: 'Segunda', value: 'two' },
];
firstValue = 'one';
secondValue = 'two';
}
describe('CustomSelectComponent', () => {
let fixture: ComponentFixture<HostComponent>;
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<HTMLButtonElement>;
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);
});
});

View File

@ -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<HTMLElement>) {}
constructor(
private host: ElementRef<HTMLElement>,
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();
}
}

View File

@ -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();
});
});

View File

@ -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<HTMLInputElement>;
@ -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) {

View File

@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class DropdownCoordinatorService {
private readonly activeDropdownIdSubject = new BehaviorSubject<string | null>(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);
}
}