feat: add Paginator component with navigation functionality and integrate into reutilizables test page
This commit is contained in:
30
src/app/core/services/api-paginated-response.interface.ts
Normal file
30
src/app/core/services/api-paginated-response.interface.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
56
src/app/shared/components/paginator/paginator.component.html
Normal file
56
src/app/shared/components/paginator/paginator.component.html
Normal 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>
|
||||
37
src/app/shared/components/paginator/paginator.component.scss
Normal file
37
src/app/shared/components/paginator/paginator.component.scss
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
76
src/app/shared/components/paginator/paginator.component.ts
Normal file
76
src/app/shared/components/paginator/paginator.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user