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>
|
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paginator-showcase {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.button-grid--single-char app-button {
|
.button-grid--single-char app-button {
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
|
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
|
||||||
import { Tenant } from '../../../../core/services/tenant.interface';
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
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.secondary_color);
|
||||||
expect(element.textContent).toContain(tenant.danger_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 { Component, inject } from '@angular/core';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
import { InputComponent } from '../../../../shared/components/input/input.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 { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
|
||||||
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reutilizables-test-page',
|
selector: 'app-reutilizables-test-page',
|
||||||
imports: [ButtonComponent, InputComponent, ProductCardComponent, StoreSectionComponent],
|
imports: [ButtonComponent, InputComponent, PaginatorComponent, ProductCardComponent, StoreSectionComponent],
|
||||||
templateUrl: './reutilizables-test-page.component.html',
|
templateUrl: './reutilizables-test-page.component.html',
|
||||||
styleUrl: './reutilizables-test-page.component.scss'
|
styleUrl: './reutilizables-test-page.component.scss'
|
||||||
})
|
})
|
||||||
@@ -29,6 +30,8 @@ export class ReutilizablesTestPageComponent {
|
|||||||
protected fileName = '';
|
protected fileName = '';
|
||||||
protected clearTrigger = 0;
|
protected clearTrigger = 0;
|
||||||
protected editableDisabled = true;
|
protected editableDisabled = true;
|
||||||
|
protected currentPage = 1;
|
||||||
|
protected readonly paginatorTotalPages = 8;
|
||||||
|
|
||||||
protected readonly testProducts = [
|
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