feat(search): enforce minimum character limit for search input and display error message
This commit is contained in:
@@ -21,25 +21,37 @@
|
||||
<div
|
||||
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
|
||||
>
|
||||
<div class="input-group store-layout__search" role="search">
|
||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||
<input
|
||||
id="store-search-input"
|
||||
type="search"
|
||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||
placeholder="Buscar productos"
|
||||
autocomplete="off"
|
||||
[formControl]="searchControl"
|
||||
(keydown.enter)="submitSearch($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||
aria-label="Buscar"
|
||||
(click)="submitSearch()"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="store-layout__search-wrapper">
|
||||
<div class="input-group store-layout__search" role="search">
|
||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||
<input
|
||||
id="store-search-input"
|
||||
type="search"
|
||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||
placeholder="Buscar productos"
|
||||
autocomplete="off"
|
||||
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||
[attr.aria-invalid]="showSearchError()"
|
||||
[minlength]="minSearchLength"
|
||||
[formControl]="searchControl"
|
||||
(input)="clearSearchError()"
|
||||
(keydown.enter)="submitSearch($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||
aria-label="Buscar"
|
||||
(click)="submitSearch()"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showSearchError()) {
|
||||
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
||||
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ticketsMenu(); as menu) {
|
||||
|
||||
@@ -41,11 +41,22 @@
|
||||
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
.store-layout__search-wrapper {
|
||||
width: min(100%, 260px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-layout__search-error {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
color: var(--tenant-danger, #dc3545);
|
||||
}
|
||||
|
||||
.store-layout__search-input {
|
||||
color: #666666;
|
||||
border-color: #cccccc;
|
||||
|
||||
@@ -37,6 +37,8 @@ export class StoreHeaderComponent {
|
||||
readonly searchSubmit = output<string>();
|
||||
|
||||
protected readonly isUserDropdownOpen = signal(false);
|
||||
protected readonly minSearchLength = 3;
|
||||
protected readonly showSearchError = signal(false);
|
||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
@@ -80,8 +82,16 @@ export class StoreHeaderComponent {
|
||||
|
||||
const term = this.searchControl.value.trim();
|
||||
|
||||
if (term.length >= 2) {
|
||||
this.searchSubmit.emit(term);
|
||||
if (term.length < this.minSearchLength) {
|
||||
this.showSearchError.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSearchError.set(false);
|
||||
this.searchSubmit.emit(term);
|
||||
}
|
||||
|
||||
protected clearSearchError(): void {
|
||||
this.showSearchError.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,29 @@ describe('StoreLayoutComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('requires at least three characters to search', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||
const input = header.query(By.css('#store-search-input'));
|
||||
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||
|
||||
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
|
||||
|
||||
(header.componentInstance as any).searchControl.setValue('ab');
|
||||
searchButton.triggerEventHandler('click');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||
'Ingresá al menos 3 caracteres para buscar.',
|
||||
);
|
||||
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders the primary tickets action when the tenant has the tickets menu', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
|
||||
@@ -105,4 +105,20 @@ describe('SearchPageComponent', () => {
|
||||
'Se muestran 1 de 10 resultados.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not search when the query has fewer than three characters', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: {
|
||||
queryParamMap: of(convertToParamMap({ q: 'ab', page: '1' })),
|
||||
},
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(SearchPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(searchCatalog).not.toHaveBeenCalled();
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||
'Ingresá al menos 3 caracteres para buscar.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ interface SearchRouteState {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchPageComponent {
|
||||
private readonly minSearchLength = 3;
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly catalogService = inject(CatalogService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -94,11 +95,15 @@ export class SearchPageComponent {
|
||||
tap(({ query }) => {
|
||||
this.query.set(query);
|
||||
this.results.set(null);
|
||||
this.error.set(query.length >= 2 ? null : 'Ingresá al menos 2 caracteres para buscar.');
|
||||
this.loading.set(query.length >= 2);
|
||||
this.error.set(
|
||||
query.length >= this.minSearchLength
|
||||
? null
|
||||
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
|
||||
);
|
||||
this.loading.set(query.length >= this.minSearchLength);
|
||||
}),
|
||||
switchMap(({ query, page }) => {
|
||||
if (query.length < 2) {
|
||||
if (query.length < this.minSearchLength) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user