feat(search): enforce minimum character limit for search input and display error message
This commit is contained in:
@@ -21,25 +21,37 @@
|
|||||||
<div
|
<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"
|
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">
|
<div class="store-layout__search-wrapper">
|
||||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
<div class="input-group store-layout__search" role="search">
|
||||||
<input
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
id="store-search-input"
|
<input
|
||||||
type="search"
|
id="store-search-input"
|
||||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
type="search"
|
||||||
placeholder="Buscar productos"
|
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||||
autocomplete="off"
|
placeholder="Buscar productos"
|
||||||
[formControl]="searchControl"
|
autocomplete="off"
|
||||||
(keydown.enter)="submitSearch($event)"
|
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||||
/>
|
[attr.aria-invalid]="showSearchError()"
|
||||||
<button
|
[minlength]="minSearchLength"
|
||||||
type="button"
|
[formControl]="searchControl"
|
||||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
(input)="clearSearchError()"
|
||||||
aria-label="Buscar"
|
(keydown.enter)="submitSearch($event)"
|
||||||
(click)="submitSearch()"
|
/>
|
||||||
>
|
<button
|
||||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
type="button"
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
@if (ticketsMenu(); as menu) {
|
@if (ticketsMenu(); as menu) {
|
||||||
|
|||||||
@@ -41,11 +41,22 @@
|
|||||||
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__search {
|
.store-layout__search-wrapper {
|
||||||
width: min(100%, 260px);
|
width: min(100%, 260px);
|
||||||
min-width: 0;
|
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 {
|
.store-layout__search-input {
|
||||||
color: #666666;
|
color: #666666;
|
||||||
border-color: #cccccc;
|
border-color: #cccccc;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class StoreHeaderComponent {
|
|||||||
readonly searchSubmit = output<string>();
|
readonly searchSubmit = output<string>();
|
||||||
|
|
||||||
protected readonly isUserDropdownOpen = signal(false);
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
|
protected readonly minSearchLength = 3;
|
||||||
|
protected readonly showSearchError = signal(false);
|
||||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
@@ -80,8 +82,16 @@ export class StoreHeaderComponent {
|
|||||||
|
|
||||||
const term = this.searchControl.value.trim();
|
const term = this.searchControl.value.trim();
|
||||||
|
|
||||||
if (term.length >= 2) {
|
if (term.length < this.minSearchLength) {
|
||||||
this.searchSubmit.emit(term);
|
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', () => {
|
it('renders the primary tickets action when the tenant has the tickets menu', () => {
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
vi.spyOn(router, 'navigate');
|
vi.spyOn(router, 'navigate');
|
||||||
|
|||||||
@@ -105,4 +105,20 @@ describe('SearchPageComponent', () => {
|
|||||||
'Se muestran 1 de 10 resultados.',
|
'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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SearchPageComponent {
|
export class SearchPageComponent {
|
||||||
|
private readonly minSearchLength = 3;
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -94,11 +95,15 @@ export class SearchPageComponent {
|
|||||||
tap(({ query }) => {
|
tap(({ query }) => {
|
||||||
this.query.set(query);
|
this.query.set(query);
|
||||||
this.results.set(null);
|
this.results.set(null);
|
||||||
this.error.set(query.length >= 2 ? null : 'Ingresá al menos 2 caracteres para buscar.');
|
this.error.set(
|
||||||
this.loading.set(query.length >= 2);
|
query.length >= this.minSearchLength
|
||||||
|
? null
|
||||||
|
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
|
||||||
|
);
|
||||||
|
this.loading.set(query.length >= this.minSearchLength);
|
||||||
}),
|
}),
|
||||||
switchMap(({ query, page }) => {
|
switchMap(({ query, page }) => {
|
||||||
if (query.length < 2) {
|
if (query.length < this.minSearchLength) {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user