feat: add Paginator component with navigation functionality and integrate into reutilizables test page

This commit is contained in:
2026-06-29 16:04:07 -03:00
parent 286e56c979
commit fe4eebb685
9 changed files with 338 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
import { ApiResponse } from './api-response.interface';
export interface ApiPaginationMetaLink {
url: string | null;
label: string;
active: boolean;
}
export interface ApiPaginationMeta {
current_page: number;
from: number | null;
last_page: number;
links: ApiPaginationMetaLink[];
path: string;
per_page: number;
to: number | null;
total: number;
}
export interface ApiPaginationLinks {
first: string;
last: string;
prev: string | null;
next: string | null;
}
export interface ApiPaginatedResponse<T> extends ApiResponse<T> {
meta: ApiPaginationMeta;
links: ApiPaginationLinks;
}

View File

@@ -174,6 +174,20 @@
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
</div>
</div>
<hr class="section-divider" />
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
[totalPages]="paginatorTotalPages"
(pageChange)="currentPage = $event"
/>
</div>
</div>
</section>

View File

@@ -55,6 +55,13 @@ h1 {
font-weight: 700;
}
.paginator-showcase {
display: grid;
justify-items: center;
gap: 0.75rem;
text-align: center;
}
.button-grid--single-char app-button {
min-width: 3rem;
}

View File

@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
@@ -63,4 +64,24 @@ describe('ReutilizablesTestPageComponent', () => {
expect(element.textContent).toContain(tenant.secondary_color);
expect(element.textContent).toContain(tenant.danger_color);
});
it('renders the paginator demo with the initial page status', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
});
});

View File

@@ -1,13 +1,14 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-reutilizables-test-page',
imports: [ButtonComponent, InputComponent, ProductCardComponent, StoreSectionComponent],
imports: [ButtonComponent, InputComponent, PaginatorComponent, ProductCardComponent, StoreSectionComponent],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss'
})
@@ -29,6 +30,8 @@ export class ReutilizablesTestPageComponent {
protected fileName = '';
protected clearTrigger = 0;
protected editableDisabled = true;
protected currentPage = 1;
protected readonly paginatorTotalPages = 8;
protected readonly testProducts = [
{

View File

@@ -0,0 +1,56 @@
<nav
class="d-inline-flex align-items-center justify-content-center gap-3 text-decoration-none"
aria-label="Paginacion"
>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-first"
aria-label="Ir a la primera pagina"
[disabled]="isFirstStepDisabled()"
(click)="changePage('first')"
>
<i class="fa-solid fa-angles-left" aria-hidden="true"></i>
</button>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-previous"
aria-label="Ir a la pagina anterior"
[disabled]="isFirstStepDisabled()"
(click)="changePage('previous')"
>
<i class="fa-solid fa-angle-left" aria-hidden="true"></i>
</button>
<span
class="paginator__status d-inline-block text-center fw-semibold lh-1"
data-testid="paginator-status"
aria-live="polite"
>
{{ displayPage() }}/{{ displayTotalPages() }}
</span>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-next"
aria-label="Ir a la pagina siguiente"
[disabled]="isLastStepDisabled()"
(click)="changePage('next')"
>
<i class="fa-solid fa-angle-right" aria-hidden="true"></i>
</button>
<button
type="button"
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
data-testid="paginator-last"
aria-label="Ir a la ultima pagina"
[disabled]="isLastStepDisabled()"
(click)="changePage('last')"
>
<i class="fa-solid fa-angles-right" aria-hidden="true"></i>
</button>
</nav>

View File

@@ -0,0 +1,37 @@
:host {
display: block;
width: fit-content;
color: var(--tenant-primary);
}
.paginator__button {
width: 2rem;
height: 2rem;
color: inherit;
font-size: 1rem;
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover:not(:disabled) {
background-color: color-mix(in srgb, white 84%, var(--tenant-primary));
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, white 55%, var(--tenant-primary));
outline-offset: 2px;
}
&:disabled {
color: color-mix(in srgb, white 45%, var(--tenant-primary));
cursor: default;
}
}
.paginator__status {
min-width: 3rem;
font-size: 1rem;
letter-spacing: 0.04em;
}

View File

@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PaginatorComponent } from './paginator.component';
describe('PaginatorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PaginatorComponent]
}).compileComponents();
});
function setup(page: number, totalPages: number, disabled = false) {
const fixture = TestBed.createComponent(PaginatorComponent);
fixture.componentRef.setInput('page', page);
fixture.componentRef.setInput('totalPages', totalPages);
fixture.componentRef.setInput('disabled', disabled);
fixture.detectChanges();
return {
fixture,
element: fixture.nativeElement as HTMLElement
};
}
it('renders the current page status', () => {
const { element } = setup(1, 8);
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
});
it('emits the next page when clicking next', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(2);
});
it('emits the last page when clicking last', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-last"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(8);
});
it('emits the first page when clicking first from a middle page', () => {
const { fixture, element } = setup(4, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-first"]') as HTMLButtonElement).click();
expect(pageChangeSpy).toHaveBeenCalledWith(1);
});
it('does not emit when trying to go before the first page', () => {
const { fixture, element } = setup(1, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-previous"]') as HTMLButtonElement).click();
expect(pageChangeSpy).not.toHaveBeenCalled();
});
it('does not emit when trying to go after the last page', () => {
const { fixture, element } = setup(8, 8);
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
expect(pageChangeSpy).not.toHaveBeenCalled();
});
it('disables all buttons when disabled is true', () => {
const { element } = setup(2, 8, true);
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
});
it('disables all buttons when there is only one page', () => {
const { element } = setup(1, 1);
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
});
});

View File

@@ -0,0 +1,76 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
type PaginatorAction = 'first' | 'previous' | 'next' | 'last';
@Component({
selector: 'app-paginator',
standalone: true,
templateUrl: './paginator.component.html',
styleUrl: './paginator.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PaginatorComponent {
readonly page = input.required<number>();
readonly totalPages = input.required<number>();
readonly disabled = input(false);
readonly pageChange = output<number>();
protected readonly displayPage = computed(() => {
const totalPages = this.displayTotalPages();
if (totalPages < 1) {
return 0;
}
return Math.min(Math.max(this.page(), 1), totalPages);
});
protected readonly displayTotalPages = computed(() => Math.max(this.totalPages(), 0));
protected readonly isNavigationDisabled = computed(() => {
const page = this.page();
const totalPages = this.totalPages();
return (
this.disabled() ||
!Number.isInteger(page) ||
!Number.isInteger(totalPages) ||
totalPages <= 1 ||
page < 1 ||
page > totalPages
);
});
protected readonly isFirstStepDisabled = computed(
() => this.isNavigationDisabled() || this.displayPage() <= 1
);
protected readonly isLastStepDisabled = computed(
() => this.isNavigationDisabled() || this.displayPage() >= this.displayTotalPages()
);
protected changePage(action: PaginatorAction): void {
if (this.isNavigationDisabled()) {
return;
}
const currentPage = this.displayPage();
const totalPages = this.displayTotalPages();
const nextPageByAction: Record<PaginatorAction, number> = {
first: 1,
previous: currentPage - 1,
next: currentPage + 1,
last: totalPages
};
const nextPage = nextPageByAction[action];
if (nextPage === currentPage || nextPage < 1 || nextPage > totalPages) {
return;
}
this.pageChange.emit(nextPage);
}
}