diff --git a/src/app/core/services/api-paginated-response.interface.ts b/src/app/core/services/api-paginated-response.interface.ts new file mode 100644 index 0000000..336e4e8 --- /dev/null +++ b/src/app/core/services/api-paginated-response.interface.ts @@ -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 extends ApiResponse { + meta: ApiPaginationMeta; + links: ApiPaginationLinks; +} diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 2f46c81..37111d3 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -174,6 +174,20 @@ Limpiar archivo + +
+ +
+

Paginator

+

+ Componente shared para navegar por paginas con primera, anterior, siguiente y ultima. +

+ +
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss index a816958..8f92a98 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss @@ -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; } diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 25eb65e..82d081a 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -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'); + }); }); diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 5b65549..9e896ec 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -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 = [ { diff --git a/src/app/shared/components/paginator/paginator.component.html b/src/app/shared/components/paginator/paginator.component.html new file mode 100644 index 0000000..3b7db38 --- /dev/null +++ b/src/app/shared/components/paginator/paginator.component.html @@ -0,0 +1,56 @@ + diff --git a/src/app/shared/components/paginator/paginator.component.scss b/src/app/shared/components/paginator/paginator.component.scss new file mode 100644 index 0000000..d17d7ff --- /dev/null +++ b/src/app/shared/components/paginator/paginator.component.scss @@ -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; +} diff --git a/src/app/shared/components/paginator/paginator.component.spec.ts b/src/app/shared/components/paginator/paginator.component.spec.ts new file mode 100644 index 0000000..ec0bb93 --- /dev/null +++ b/src/app/shared/components/paginator/paginator.component.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/components/paginator/paginator.component.ts b/src/app/shared/components/paginator/paginator.component.ts new file mode 100644 index 0000000..cee2f06 --- /dev/null +++ b/src/app/shared/components/paginator/paginator.component.ts @@ -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(); + readonly totalPages = input.required(); + readonly disabled = input(false); + + readonly pageChange = output(); + + 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 = { + 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); + } +}