Merge branch 'dev' into producao

This commit is contained in:
Eduardo Lopes 2026-03-12 14:43:40 -03:00
commit 6803ce9337
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 { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subscription } from 'rxjs';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
@Component({ @Component({
selector: 'app-select', 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() options: any[] = [];
@Input() placeholder = 'Selecione uma opção'; @Input() placeholder = 'Selecione uma opção';
@Input() labelKey = 'label'; @Input() labelKey = 'label';
@ -29,11 +33,22 @@ export class CustomSelectComponent implements ControlValueAccessor {
isOpen = false; isOpen = false;
value: any = null; value: any = null;
searchTerm = ''; searchTerm = '';
private readonly dropdownId = `custom-select-${CustomSelectComponent.nextDropdownId++}`;
private readonly dropdownSyncSub: Subscription;
private onChange: (value: any) => void = () => {}; private onChange: (value: any) => void = () => {};
private onTouched: () => 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 { writeValue(value: any): void {
this.value = value; this.value = value;
@ -49,7 +64,7 @@ export class CustomSelectComponent implements ControlValueAccessor {
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled;
if (this.disabled) this.isOpen = false; if (this.disabled) this.close();
} }
get displayLabel(): string { get displayLabel(): string {
@ -65,13 +80,21 @@ export class CustomSelectComponent implements ControlValueAccessor {
toggle(): void { toggle(): void {
if (this.disabled) return; if (this.disabled) return;
this.isOpen = !this.isOpen; if (this.isOpen) {
if (!this.isOpen) this.searchTerm = ''; this.close();
return;
} }
close(): void { this.dropdownCoordinator.requestOpen(this.dropdownId);
this.isOpen = true;
}
close(notifyCoordinator = true): void {
this.isOpen = false; this.isOpen = false;
this.searchTerm = ''; this.searchTerm = '';
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.dropdownId);
}
} }
selectOption(option: any): void { selectOption(option: any): void {
@ -148,4 +171,9 @@ export class CustomSelectComponent implements ControlValueAccessor {
onEsc(): void { onEsc(): void {
if (this.isOpen) this.close(); 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 { provideRouter } from '@angular/router';
import { Geral } from './geral'; import { Geral } from './geral';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
describe('Geral', () => { describe('Geral', () => {
let component: Geral; let component: Geral;
@ -171,4 +172,20 @@ describe('Geral', () => {
expect(params.get('includeAssignedReservaInAll')).toBeNull(); expect(params.get('includeAssignedReservaInAll')).toBeNull();
expect(params.get('skil')).toBe('RESERVA'); 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 MveAuditIssue,
type MveAuditRun, type MveAuditRun,
} from '../../services/mve-audit.service'; } from '../../services/mve-audit.service';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
import { firstValueFrom, Subscription, filter } from 'rxjs'; import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@ -361,9 +362,14 @@ interface MveApplySelectionSummary {
styleUrls: ['./geral.scss'] styleUrls: ['./geral.scss']
}) })
export class Geral implements OnInit, AfterViewInit, OnDestroy { export class Geral implements OnInit, AfterViewInit, OnDestroy {
private static nextFilterDropdownScopeId = 0;
readonly vm = this; readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE; readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = ''; 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('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>; @ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ -385,7 +391,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private tenantSyncService: TenantSyncService, private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService, private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService, private tableExportService: TableExportService,
private mveAuditService: MveAuditService private mveAuditService: MveAuditService,
private dropdownCoordinator: DropdownCoordinatorService
) {} ) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines'); private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
@ -509,6 +516,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private editingId: string | null = null; private editingId: string | null = null;
private searchTimer: any = null; private searchTimer: any = null;
private navigationSub?: Subscription; private navigationSub?: Subscription;
private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false; private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null; private searchResolvedClient: string | null = null;
@ -968,12 +976,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false; let changed = false;
if (this.showClientMenu && !insideClient) { if (this.showClientMenu && !insideClient) {
this.showClientMenu = false; this.closeClientDropdown();
changed = true; changed = true;
} }
if (this.showAdditionalMenu && !insideAdditional) { if (this.showAdditionalMenu && !insideAdditional) {
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
changed = true; changed = true;
} }
@ -999,12 +1007,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false; let changed = false;
if (this.showClientMenu) { if (this.showClientMenu) {
this.showClientMenu = false; this.closeClientDropdown();
changed = true; changed = true;
} }
if (this.showAdditionalMenu) { if (this.showAdditionalMenu) {
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
changed = true; changed = true;
} }
@ -1018,6 +1026,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer); if (this.searchTimer) clearTimeout(this.searchTimer);
this.navigationSub?.unsubscribe(); this.navigationSub?.unsubscribe();
this.dropdownSyncSub?.unsubscribe();
} }
ngOnInit(): void { ngOnInit(): void {
@ -1035,6 +1044,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.selectedAdditionalServices = []; this.selectedAdditionalServices = [];
this.selectedClients = []; 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() { async ngAfterViewInit() {
@ -2536,27 +2555,43 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleClientMenu() { toggleClientMenu() {
if (this.isClientRestricted) return; if (this.isClientRestricted) return;
if (!this.showClientMenu) this.showAdditionalMenu = false; if (this.showClientMenu) {
this.showClientMenu = !this.showClientMenu; this.closeClientDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.clientDropdownId);
this.showClientMenu = true;
} }
toggleAdditionalMenu() { toggleAdditionalMenu() {
if (this.isClientRestricted) return; if (this.isClientRestricted) return;
if (!this.showAdditionalMenu) this.showClientMenu = false; if (this.showAdditionalMenu) {
this.showAdditionalMenu = !this.showAdditionalMenu; this.closeAdditionalDropdown();
return;
} }
closeClientDropdown() { this.dropdownCoordinator.requestOpen(this.additionalDropdownId);
this.showAdditionalMenu = true;
}
closeClientDropdown(notifyCoordinator = true) {
this.showClientMenu = false; this.showClientMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.clientDropdownId);
}
} }
closeAdditionalDropdown() { closeAdditionalDropdown(notifyCoordinator = true) {
this.showAdditionalMenu = false; this.showAdditionalMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.additionalDropdownId);
}
} }
closeFilterDropdowns() { closeFilterDropdowns() {
this.showClientMenu = false; this.closeClientDropdown();
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
} }
selectClient(client: string | null) { 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);
}
}