feat : #11 pagination

This commit is contained in:
styve Lioumba
2025-11-30 18:39:42 +01:00
parent 2a9eb55e1b
commit 0c768296d1
21 changed files with 163 additions and 85 deletions

View File

@@ -2,10 +2,6 @@ import { TestBed } from '@angular/core/testing';
import { authGuard } from './auth.guard'; import { authGuard } from './auth.guard';
import { CanActivateFn, Router, UrlTree } from '@angular/router'; import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { AuthRepository } from '@app/domain/authentification/auth.repository';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { AuthFacade } from '@app/ui/authentification/auth.facade'; import { AuthFacade } from '@app/ui/authentification/auth.facade';
describe('authGuard', () => { describe('authGuard', () => {

View File

@@ -12,3 +12,11 @@ export interface Profile {
projets: string[]; projets: string[];
apropos: string; apropos: string;
} }
export interface ProfilePaginated {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: any[];
}

View File

@@ -1,9 +1,9 @@
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { SearchFilters } from '@app/domain/search/search-filters'; import { SearchFilters } from '@app/domain/search/search-filters';
export interface ProfileRepository { export interface ProfileRepository {
list(params?: SearchFilters): Observable<Profile[]>; list(params?: SearchFilters): Observable<ProfilePaginated>;
getByUserId(userId: string): Observable<Profile>; getByUserId(userId: string): Observable<Profile>;
create(profile: Profile): Observable<Profile>; create(profile: Profile): Observable<Profile>;
update(profileId: string, profile: Partial<Profile>): Observable<Profile>; update(profileId: string, profile: Partial<Profile>): Observable<Profile>;

View File

@@ -5,5 +5,7 @@ export interface SearchFilters {
profession: string | null; profession: string | null;
sort: string; sort: string;
page?: number; page?: number;
pageSize?: number; perPage?: number;
totalPages?: number;
totalItems?: number;
} }

View File

@@ -1,6 +1,6 @@
import { ProfileRepository } from '@app/domain/profiles/profile.repository'; import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { from, Observable } from 'rxjs'; import { from, Observable } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import PocketBase from 'pocketbase'; import PocketBase from 'pocketbase';
import { environment } from '@env/environment'; import { environment } from '@env/environment';
@@ -15,14 +15,18 @@ export class PbProfileRepository implements ProfileRepository {
expand: 'utilisateur', expand: 'utilisateur',
}; };
list(params?: SearchFilters): Observable<Profile[]> { list(params?: SearchFilters): Observable<ProfilePaginated> {
const requestOptions = { const requestOptions = {
...this.defaultOptions, ...this.defaultOptions,
sort: this.onSortSetting(params), sort: this.onSortSetting(params),
filter: this.onFilterSetting(params).join(' && '), filter: this.onFilterSetting(params).join(' && '),
}; };
return from(this.pb.collection('profiles').getFullList<Profile>(requestOptions)); return from(
this.pb
.collection('profiles')
.getList<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
);
} }
getByUserId(userId: string): Observable<Profile> { getByUserId(userId: string): Observable<Profile> {

View File

@@ -12,6 +12,8 @@ export class SearchService implements SearchRepository {
secteur: null, secteur: null,
profession: null, profession: null,
sort: 'recent', sort: 'recent',
page: 1,
perPage: 25,
}); });
setFilters(filters: SearchFilters) { setFilters(filters: SearchFilters) {

View File

@@ -70,13 +70,17 @@
d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"
/> />
</svg> </svg>
<span>{{ profiles().length }} profil(s)</span> <span>{{ profilePaginated().items.length }} profil(s)</span>
</div> </div>
</div> </div>
<!-- Liste des profils avec animation d'apparition --> <!-- Liste des profils avec animation d'apparition -->
<div class="animate-slide-up animation-delay-100"> <div class="animate-slide-up animation-delay-100">
<app-vertical-profile-list [profiles]="profiles()" /> <app-vertical-profile-list [profiles]="profilePaginated().items" />
</div>
<div class="animate-slide-up animation-delay-100">
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
</div> </div>
} }
</div> </div>

View File

@@ -6,34 +6,42 @@ import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { LoadingComponent } from '@app/shared/components/loading/loading.component'; import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { SearchFilters } from '@app/domain/search/search-filters'; import { SearchFilters } from '@app/domain/search/search-filters';
import { SearchService } from '@app/infrastructure/search/search.service';
import { FilterComponent } from '@app/shared/features/filter/filter.component'; import { FilterComponent } from '@app/shared/features/filter/filter.component';
import { PaginationComponent } from '@app/shared/features/pagination/pagination.component';
@Component({ @Component({
selector: 'app-profile-list', selector: 'app-profile-list',
standalone: true, standalone: true,
imports: [SearchComponent, VerticalProfileListComponent, LoadingComponent, FilterComponent], imports: [
SearchComponent,
VerticalProfileListComponent,
LoadingComponent,
FilterComponent,
PaginationComponent,
],
templateUrl: './profile-list.component.html', templateUrl: './profile-list.component.html',
styleUrl: './profile-list.component.scss', styleUrl: './profile-list.component.scss',
}) })
@UntilDestroy() @UntilDestroy()
export class ProfileListComponent { export class ProfileListComponent {
private readonly searchService = inject(SearchService);
private readonly facade = inject(ProfileFacade); private readonly facade = inject(ProfileFacade);
private readonly router = inject(Router); private readonly router = inject(Router);
protected readonly profiles = this.facade.profiles; protected readonly searchFilters = this.facade.searchFilters;
protected readonly profilePaginated = this.facade.profilePaginated;
protected readonly loading = this.facade.loading; protected readonly loading = this.facade.loading;
protected readonly error = this.facade.error; protected readonly error = this.facade.error;
protected readonly searchFilters = this.searchService.getFilters();
showNewQuery(filters: SearchFilters) { showNewQuery(filters: SearchFilters) {
this.facade.load(this.searchFilters()); this.facade.load(filters);
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } }); this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
} }
onFilterChange(filters: SearchFilters) { onFilterChange(filters: SearchFilters) {
this.facade.load(filters); this.facade.load(filters);
} }
onPageChange(filters: SearchFilters) {
this.facade.load(filters);
}
} }

View File

@@ -1,36 +0,0 @@
<!-- Pagination responsive -->
<nav class="flex justify-center mt-8 sm:mt-12" aria-label="Pagination">
<ul class="flex flex-wrap gap-2 items-center justify-center">
<li>
<button
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
Précédent
</button>
</li>
<li>
<button class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium">1</button>
</li>
<li>
<button
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
2
</button>
</li>
<li>
<button
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
3
</button>
</li>
<li>
<button
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
Suivant
</button>
</li>
</ul>
</nav>

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-pagination',
standalone: true,
imports: [],
templateUrl: './pagination.component.html',
styleUrl: './pagination.component.scss',
})
export class PaginationComponent {}

View File

@@ -1,6 +1,6 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Grid des projets --> <!-- Grid des projets -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-6">
@for (project of projects(); track project.id) { @for (project of projects(); track project.id) {
<app-project-item [project]="project" /> <app-project-item [project]="project" />
} @empty { } @empty {

View File

@@ -0,0 +1,38 @@
<!-- Pagination responsive -->
<section class="flex justify-center mt-8 sm:mt-12" aria-label="Pagination">
<ul class="flex flex-wrap gap-2 items-center justify-center">
<li>
<button
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 transition-colors"
[class.opacity-50]="currentPage === 1"
[class.pointer-events-none]="currentPage === 1"
[class.hover:bg-gray-100]="currentPage > 1"
(click)="goToPreviousPage()"
type="button"
[disabled]="currentPage === 1"
>
Précédent
</button>
</li>
<li>
<span class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium">
{{ currentPage }} / {{ filters.totalPages! }}
</span>
</li>
<li>
<button
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 transition-colors"
[class.opacity-50]="currentPage >= filters.totalPages!"
[class.pointer-events-none]="currentPage >= filters.totalPages!"
[class.hover:bg-gray-100]="currentPage < filters.totalPages!"
(click)="goToNextPage()"
type="button"
[disabled]="currentPage >= filters.totalPages!"
>
Suivant
</button>
</li>
</ul>
</section>

View File

@@ -0,0 +1,34 @@
import { Component, Input, OnInit, output } from '@angular/core';
import { SearchFilters } from '@app/domain/search/search-filters';
@Component({
selector: 'app-pagination',
standalone: true,
imports: [],
templateUrl: './pagination.component.html',
styleUrl: './pagination.component.scss',
})
export class PaginationComponent implements OnInit {
@Input({ required: true }) filters: SearchFilters = {} as SearchFilters;
onPageChange = output<SearchFilters>();
currentPage = 1;
ngOnInit() {
this.currentPage = this.filters.page!;
}
goToPreviousPage() {
this.currentPage = this.currentPage - 1;
this.emitPageChange();
}
goToNextPage() {
this.currentPage = this.currentPage + 1;
this.emitPageChange();
}
private emitPageChange() {
const filters = { ...this.filters, page: this.currentPage };
this.onPageChange.emit(filters);
}
}

View File

@@ -1,11 +1,11 @@
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { mockProfiles } from '@app/testing/profile.mock'; import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
import { ProfileRepository } from '@app/domain/profiles/profile.repository'; import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
export class FakeProfileRepository implements ProfileRepository { export class FakeProfileRepository implements ProfileRepository {
list(): Observable<Profile[]> { list(): Observable<ProfilePaginated> {
return of(mockProfiles); return of(mockProfilePaginated);
} }
getByUserId(userId: string): Observable<Profile> { getByUserId(userId: string): Observable<Profile> {

View File

@@ -2,7 +2,7 @@ import { PbProfileRepository } from '@app/infrastructure/profiles/pb-profile.rep
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile } from '@app/domain/profiles/profile.model';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto'; import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import PocketBase from 'pocketbase'; import PocketBase from 'pocketbase';
import { mockProfiles } from '@app/testing/profile.mock'; import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
jest.mock('pocketbase'); // on mock le module PocketBase jest.mock('pocketbase'); // on mock le module PocketBase
@@ -14,7 +14,7 @@ describe('PbProfileRepository', () => {
beforeEach(() => { beforeEach(() => {
// Création dun faux client PocketBase avec les méthodes dont on a besoin // Création dun faux client PocketBase avec les méthodes dont on a besoin
mockCollection = { mockCollection = {
getFullList: jest.fn(), getList: jest.fn(),
getFirstListItem: jest.fn(), getFirstListItem: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
@@ -35,8 +35,8 @@ describe('PbProfileRepository', () => {
// ------------------------------------------ // ------------------------------------------
// 🔹 TEST : list() // 🔹 TEST : list()
// ------------------------------------------ // ------------------------------------------
it('devrait appeler pb.collection("profiles").getFullList() avec un tri par profession', (done) => { it('devrait appeler pb.collection("profiles").getList() avec un tri par profession', (done) => {
mockCollection.getFullList.mockResolvedValue(mockProfiles); mockCollection.getList.mockResolvedValue(mockProfilePaginated);
const options = { const options = {
expand: 'utilisateur', expand: 'utilisateur',
filter: filter:
@@ -45,8 +45,8 @@ describe('PbProfileRepository', () => {
}; };
repo.list().subscribe((result) => { repo.list().subscribe((result) => {
expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles'); expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles');
expect(mockCollection.getFullList).toHaveBeenCalledWith(options); expect(mockCollection.getList).toHaveBeenCalledWith(undefined, undefined, options);
expect(result).toEqual(mockProfiles); expect(result).toEqual(mockProfilePaginated);
done(); done();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
export const mockProfiles: Profile[] = [ export const mockProfiles: Profile[] = [
{ {
@@ -30,3 +30,11 @@ export const mockProfiles: Profile[] = [
apropos: 'Designer Freelance', apropos: 'Designer Freelance',
}, },
]; ];
export const mockProfilePaginated: ProfilePaginated = {
page: 1,
perPage: 10,
totalPages: 1,
totalItems: 1,
items: mockProfiles,
};

View File

@@ -1,6 +1,6 @@
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase'; import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
import { FakeProfileRepository } from '@app/testing/domain/profiles/fake-profile.repository'; import { FakeProfileRepository } from '@app/testing/domain/profiles/fake-profile.repository';
import { mockProfiles } from '@app/testing/profile.mock'; import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
describe('ListProfilesUseCase', () => { describe('ListProfilesUseCase', () => {
it('doit retourner la liste des profils', () => { it('doit retourner la liste des profils', () => {
@@ -9,8 +9,8 @@ describe('ListProfilesUseCase', () => {
useCase.execute().subscribe({ useCase.execute().subscribe({
next: (profiles) => { next: (profiles) => {
expect(profiles.length).toBe(2); expect(profiles.items.length).toBe(2);
expect(profiles).toEqual(mockProfiles); expect(profiles).toEqual(mockProfilePaginated);
}, },
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase'; import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
import { inject, Injectable, signal } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token'; import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { Profile } from '@app/domain/profiles/profile.model'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter'; import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model'; import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { LoaderAction } from '@app/domain/loader-action.util'; import { LoaderAction } from '@app/domain/loader-action.util';
@@ -12,19 +12,23 @@ import { UpdateProfileUseCase } from '@app/usecase/profiles/update-profile.useca
import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase'; import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto'; import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import { SearchFilters } from '@app/domain/search/search-filters'; import { SearchFilters } from '@app/domain/search/search-filters';
import { SearchService } from '@app/infrastructure/search/search.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ProfileFacade { export class ProfileFacade {
private profileRepository = inject(PROFILE_REPOSITORY_TOKEN); private profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
private readonly searchService = inject(SearchService);
private listUseCase = new ListProfilesUseCase(this.profileRepository); private listUseCase = new ListProfilesUseCase(this.profileRepository);
private createUseCase = new CreateProfileUseCase(this.profileRepository); private createUseCase = new CreateProfileUseCase(this.profileRepository);
private updateUseCase = new UpdateProfileUseCase(this.profileRepository); private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
private getUseCase = new GetProfileUseCase(this.profileRepository); private getUseCase = new GetProfileUseCase(this.profileRepository);
readonly searchFilters = this.searchService.getFilters();
readonly profiles = signal<ProfileViewModel[]>([]); readonly profiles = signal<ProfileViewModel[]>([]);
readonly profilePaginated = signal<ProfilePaginated>({} as ProfilePaginated);
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel); readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE }); readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({ readonly error = signal<ErrorResponse>({
@@ -36,9 +40,25 @@ export class ProfileFacade {
load(search?: SearchFilters) { load(search?: SearchFilters) {
this.handleError(ActionType.READ, false, null, true); this.handleError(ActionType.READ, false, null, true);
if (search === undefined || search === null) {
search = this.searchFilters();
}
this.listUseCase.execute(search).subscribe({ this.listUseCase.execute(search).subscribe({
next: (profiles) => { next: (profilePaginated: ProfilePaginated) => {
this.profiles.set(ProfilePresenter.toViewModels(profiles)); const filters = {
...this.searchFilters(),
page: profilePaginated.page,
perPage: profilePaginated.perPage,
totalItems: profilePaginated.totalItems,
totalPages: profilePaginated.totalPages,
};
this.searchService.setFilters(filters);
this.searchFilters.set(filters);
this.profilePaginated.set(profilePaginated);
this.profiles.set(ProfilePresenter.toViewModels(profilePaginated.items as Profile[]));
this.handleError(ActionType.READ, false, null, false); this.handleError(ActionType.READ, false, null, false);
}, },
error: (err) => { error: (err) => {

View File

@@ -1,12 +1,12 @@
import { ProfileRepository } from '@app/domain/profiles/profile.repository'; import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model'; import { ProfilePaginated } from '@app/domain/profiles/profile.model';
import { SearchFilters } from '@app/domain/search/search-filters'; import { SearchFilters } from '@app/domain/search/search-filters';
export class ListProfilesUseCase { export class ListProfilesUseCase {
constructor(private readonly repo: ProfileRepository) {} constructor(private readonly repo: ProfileRepository) {}
execute(params?: SearchFilters): Observable<Profile[]> { execute(params?: SearchFilters): Observable<ProfilePaginated> {
return this.repo.list(params); return this.repo.list(params);
} }
} }