feat : #12 recherche et filtre

This commit is contained in:
styve Lioumba
2025-11-28 15:15:33 +01:00
parent 4716e82628
commit 4ed6d812db
30 changed files with 308 additions and 487 deletions

69
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"prettier": "^3.6.2",
"tailwindcss": "^3.4.12",
"ts-jest": "^29.4.5",
"ts-unused-exports": "^11.0.1",
"typescript": "~5.2.2",
"typescript-eslint": "8.46.0"
}
@@ -6467,6 +6468,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -19249,6 +19257,67 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-unused-exports": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/ts-unused-exports/-/ts-unused-exports-11.0.1.tgz",
"integrity": "sha512-b1uIe0B8YfNZjeb+bx62LrB6qaO4CHT8SqMVBkwbwLj7Nh0xQ4J8uV0dS9E6AABId0U4LQ+3yB/HXZBMslGn2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
"tsconfig-paths": "^3.9.0"
},
"bin": {
"ts-unused-exports": "bin/ts-unused-exports"
},
"funding": {
"url": "https://github.com/pzavolinsky/ts-unused-exports?sponsor=1"
},
"peerDependencies": {
"typescript": ">=3.8.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": false
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
"integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json5": "^0.0.29",
"json5": "^1.0.2",
"minimist": "^1.2.6",
"strip-bom": "^3.0.0"
}
},
"node_modules/tsconfig-paths/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/tsconfig-paths/node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -14,8 +14,7 @@
"format": "bash replace-prod-env.sh src/environments/environment.development.ts http://localhost:8090 && npm run prettier && npm run lint:fix",
"lint": "ng lint",
"lint:fix": "ng lint --fix",
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments\" && npm run lint:fix",
"fix:all": "npm run format && npm run tsc",
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments;server.ts\" && npm run lint:fix","fix:all": "npm run format && npm run tsc",
"check:all": "npm run format && npm run tsc && npm run lint && npm run test",
"test": "jest",
"test:watch": "jest --watch",
@@ -77,6 +76,7 @@
"prettier": "^3.6.2",
"tailwindcss": "^3.4.12",
"ts-jest": "^29.4.5",
"ts-unused-exports": "^11.0.1",
"typescript": "~5.2.2",
"typescript-eslint": "8.46.0"
}

View File

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

View File

@@ -4,4 +4,6 @@ export interface SearchFilters {
secteur: string | null;
profession: string | null;
sort: string;
page?: number;
pageSize?: number;
}

View File

@@ -9,4 +9,5 @@ export interface SearchRepository {
filterByProfession(profession: string): void;
reset(): void;
getFilters(): WritableSignal<SearchFilters>;
setFilters(filters: SearchFilters): void;
}

View File

@@ -1,6 +0,0 @@
export interface CreateSectorDto {
id: string;
created: string;
updated: string;
nom: string;
}

View File

@@ -5,19 +5,24 @@ import { Injectable } from '@angular/core';
import PocketBase from 'pocketbase';
import { environment } from '@env/environment';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import { SearchFilters } from '@app/domain/search/search-filters';
@Injectable({ providedIn: 'root' })
export class PbProfileRepository implements ProfileRepository {
private pb = new PocketBase(environment.baseUrl);
list(): Observable<Profile[]> {
const options = {
sort: 'profession',
expand: 'utilisateur',
filter:
"utilisateur.verified=true && utilisateur.name !='' && profession!='Profession non renseignée' && secteur!='' ",
private defaultOptions = {
expand: 'utilisateur',
};
list(params?: SearchFilters): Observable<Profile[]> {
const requestOptions = {
...this.defaultOptions,
sort: this.onSortSetting(params),
filter: this.onFilterSetting(params).join(' && '),
};
return from(this.pb.collection('profiles').getFullList<Profile>(options));
return from(this.pb.collection('profiles').getFullList<Profile>(requestOptions));
}
getByUserId(userId: string): Observable<Profile> {
@@ -33,4 +38,57 @@ export class PbProfileRepository implements ProfileRepository {
update(id: string, data: Partial<Profile>): Observable<Profile> {
return from(this.pb.collection('profiles').update<Profile>(id, data));
}
private onFilterSetting(params?: SearchFilters): string[] {
const filters: string[] = [
'utilisateur.verified = true',
"utilisateur.name != ''",
"profession != 'Profession non renseignée'",
"secteur != ''",
];
if (params) {
if (params.secteur) {
filters.push(`secteur ~ '${params.secteur}'`);
}
if (params.profession) {
filters.push(`profession ~ '${params.profession}'`);
}
if (params.search) {
filters.push(`utilisateur.name ~ '${params.search}'`);
}
if (params.verified) {
filters.push('estVerifier = true');
}
}
return filters;
}
private onSortSetting(params?: SearchFilters): string {
let sortSetting = '-created';
if (params?.sort) {
switch (params.sort) {
case 'recent':
sortSetting = '-created'; // Du plus récent au plus vieux
break;
case 'name-asc':
sortSetting = '+utilisateur.name'; // Alphabétique A-Z
break;
case 'name-desc':
sortSetting = '-utilisateur.name'; // Alphabétique Z-A
break;
case 'verified':
sortSetting = '-estVerifier,-created';
break;
default:
sortSetting = '-created';
}
}
return sortSetting;
}
}

View File

@@ -14,6 +14,11 @@ export class SearchService implements SearchRepository {
sort: 'recent',
});
setFilters(filters: SearchFilters) {
const filtersToSet = { ...this.filters(), ...filters };
this.filters.set(filtersToSet);
}
filterByProfession(profession: string | null) {
const filter = { ...this.filters(), profession };
this.filters.set(filter);

View File

@@ -76,7 +76,7 @@
<img
alt="{{ user().username }}"
class="object-cover w-full h-full"
src="https://api.dicebear.com/9.x/adventurer/svg?seed={{ user().username }}"
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
loading="lazy"
/>
}

View File

@@ -37,9 +37,15 @@
<!-- Barre de recherche améliorée -->
<div class="max-w-3xl mx-auto animate-slide-up animation-delay-200">
<div
class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-4 sm:p-6 hover:shadow-2xl transition-shadow duration-300"
class="flex flex-col space-y-8 bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-4 sm:p-6 hover:shadow-2xl transition-shadow duration-300"
>
<app-search (onSearchChange)="showNewQuery($event)" />
<div>
<app-search (onSearchChange)="showNewQuery($event)" />
</div>
<div>
<app-filter (onFilterChange)="onFilterChange($event)" />
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject } from '@angular/core';
import { SearchComponent } from '@app/shared/features/search/search.component';
import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component';
import { UntilDestroy } from '@ngneat/until-destroy';
@@ -7,16 +7,17 @@ import { LoadingComponent } from '@app/shared/components/loading/loading.compone
import { Router } from '@angular/router';
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';
@Component({
selector: 'app-profile-list',
standalone: true,
imports: [SearchComponent, VerticalProfileListComponent, LoadingComponent],
imports: [SearchComponent, VerticalProfileListComponent, LoadingComponent, FilterComponent],
templateUrl: './profile-list.component.html',
styleUrl: './profile-list.component.scss',
})
@UntilDestroy()
export class ProfileListComponent implements OnInit {
export class ProfileListComponent {
private readonly searchService = inject(SearchService);
private readonly facade = inject(ProfileFacade);
private readonly router = inject(Router);
@@ -27,11 +28,12 @@ export class ProfileListComponent implements OnInit {
protected readonly searchFilters = this.searchService.getFilters();
ngOnInit() {
this.facade.load();
}
showNewQuery(filters: SearchFilters) {
this.facade.load(this.searchFilters());
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
}
onFilterChange(filters: SearchFilters) {
this.facade.load(filters);
}
}

View File

@@ -22,7 +22,7 @@
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Image du projet</h3>
</div>
@if (projectId == 'add'.toLowerCase()) {
@if (projectId === 'add'.toLowerCase()) {
<app-project-picture-form [project]="undefined" />
} @else {
<app-project-picture-form [project]="project()" />

View File

@@ -30,7 +30,6 @@ describe('NavBarComponent', () => {
name: 'john doe',
avatar: '',
};
const mockUser: AuthModel = { isValid: false, record: user, token: 'mockToken123' } as AuthModel;
beforeEach(async () => {
mockThemeService = {

View File

@@ -13,14 +13,14 @@
<div
class="w-40 h-40 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"
>
@if (imagePreviewUrl != null && project != undefined) {
@if (imagePreviewUrl !== null && project !== undefined) {
<img
alt="nouveau-projet"
class="object-cover object-center h-full w-full"
[src]="imagePreviewUrl"
loading="lazy"
/>
} @else if (project != undefined) {
} @else if (project !== undefined) {
@if (project.fichier) {
<img
alt="{{ project!.nom }}"
@@ -38,7 +38,7 @@
}
}
@if (project == undefined) {
@if (project === undefined) {
<img
alt="nouveau-projet"
class="object-cover object-center h-full w-full"
@@ -77,10 +77,10 @@
/>
</label>
@if (file != null || imagePreviewUrl != null) {
@if (file !== null || imagePreviewUrl !== null) {
<button
type="button"
[ngClass]="{ 'bg-purple-600': file != null || imagePreviewUrl != null }"
[ngClass]="{ 'bg-purple-600': file !== null || imagePreviewUrl !== null }"
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
(click)="onSubmit()"
>

View File

@@ -1,4 +1,4 @@
@if (reseaux != undefined) {
@if (reseaux !== undefined) {
<ul class="flex flex-wrap justify-center mt-4 space-x-2">
@for (rx of Object.keys(reseaux); track rx) {
@if (reseaux[rx] !== '') {

View File

@@ -5,11 +5,11 @@
}
<ng-template #content>
@if (user != undefined) {
@if (user !== undefined) {
<div
class="w-40 h-40 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"
>
@if (imagePreviewUrl != null) {
@if (imagePreviewUrl !== null) {
<img
alt="{{ user!.username }}"
class="object-cover object-center h-full w-full rounded-full"
@@ -62,10 +62,10 @@
/>
</label>
@if (file != null || imagePreviewUrl != null) {
@if (file !== null || imagePreviewUrl !== null) {
<button
type="button"
[ngClass]="{ 'bg-purple-600': file != null || imagePreviewUrl != null }"
[ngClass]="{ 'bg-purple-600': file !== null || imagePreviewUrl !== null }"
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
(click)="onUserAvatarFormSubmit()"
>

View File

@@ -1,4 +1,4 @@
@if (user() != undefined) {
@if (user() !== undefined) {
<a
[routerLink]="[user().username ? user().username : user().id]"
[state]="{ user: user(), profile }"
@@ -43,7 +43,7 @@
} @else {
<img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="https://api.dicebear.com/9.x/adventurer/svg?seed={{ user().username }}"
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
alt="{{ user().username }}"
loading="lazy"
/>

View File

@@ -18,6 +18,7 @@ export class VerticalProfileItemComponent implements OnInit {
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
protected router = inject(Router);
private readonly facade = inject(UserFacade);
protected defaultImg = Math.floor(Math.random() * 10) + 1;
protected user = this.facade.user;
protected readonly loading = this.facade.loading;

View File

@@ -1,13 +1,28 @@
<!-- Filtres -->
<div class="w-full">
<div
class="w-full bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"
>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filtrer les résultats</h3>
<h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-indigo-500"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
clip-rule="evenodd"
/>
</svg>
Filtres & Tri
</h3>
@if (hasActiveFilters()) {
<button
type="button"
(click)="clearAllFilters()"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium transition-colors flex items-center gap-1"
class="text-sm text-red-600 hover:text-red-700 dark:text-red-400 font-medium transition-colors flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -16,371 +31,98 @@
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
Effacer les filtres
Réinitialiser
</button>
}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<!-- Filtre: Profils vérifiés -->
<button
type="button"
(click)="toggleVerifiedFilter()"
[class.ring-2]="filters().verified"
[class.ring-purple-500]="filters().verified"
class="group relative flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
<form
[formGroup]="filterForm"
(ngSubmit)="onSubmit()"
class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 items-end"
>
<div
class="flex items-center h-[42px] px-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50"
>
<div class="flex items-center gap-2">
<div
class="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center"
[class.bg-purple-100]="filters().verified"
[class.dark:bg-purple-900]="filters().verified"
<label class="flex items-center gap-3 w-full cursor-pointer">
<input
type="checkbox"
formControlName="verified"
class="flex-1 focus:ring-0 focus:outline-none placeholder:text-gray-400 bg-transparent text-gray-800 dark:text-white text-sm sm:text-base"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Profils vérifiés uniquement</span
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
[class.text-purple-600]="filters().verified"
[class.dark:text-purple-400]="filters().verified"
[class.text-gray-400]="!filters().verified"
>
<path
fill-rule="evenodd"
d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clip-rule="evenodd"
/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> Profils vérifiés </span>
</div>
@if (filters().verified) {
<div
class="flex-shrink-0 w-5 h-5 rounded-full bg-purple-600 text-white flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</div>
}
</button>
<!-- Filtre: Secteur -->
<ng-container *ngTemplateOutlet="secteur"></ng-container>
<!-- Filtre: Profession -->
<ng-container *ngTemplateOutlet="profession"></ng-container>
<!-- Filtre: Tri -->
<ng-container *ngTemplateOutlet="tri"></ng-container>
</div>
<!-- Tags des filtres actifs -->
@if (hasActiveFilters()) {
<div class="flex flex-wrap gap-2 mt-4">
@if (filters().verified) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
Vérifiés
<button
type="button"
(click)="toggleVerifiedFilter()"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
@if (filters().secteur) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
{{ filters().secteur }}
<button
type="button"
(click)="selectSector(null)"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
@if (filters().profession) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
{{ filters().profession }}
<button
type="button"
(click)="selectProfession(null)"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
</label>
</div>
}
</div>
<ng-template #secteur>
<div class="relative">
<button
type="button"
(click)="toggleSectorDropdown()"
[class.ring-2]="filters().secteur"
[class.ring-purple-500]="filters().secteur"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
[class.text-purple-600]="filters().secteur"
[class.dark:text-purple-400]="filters().secteur"
[class.text-gray-400]="!filters().secteur"
>
<path
fill-rule="evenodd"
d="M2 3.5A1.5 1.5 0 013.5 2h9A1.5 1.5 0 0114 3.5v11.75A2.75 2.75 0 0016.75 18h-12A2.75 2.75 0 012 15.25V3.5zm3.75 7a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5zm0 3a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5zM5 5.75A.75.75 0 015.75 5h4.5a.75.75 0 01.75.75v2.5a.75.75 0 01-.75.75h-4.5A.75.75 0 015 8.25v-2.5z"
clip-rule="evenodd"
/>
<path d="M16.5 6.5h-1v8.75a1.25 1.25 0 102.5 0V8a1.5 1.5 0 00-1.5-1.5z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ filters().secteur || 'Secteur' }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showSectorDropdown"
<div class="space-y-1">
<label
for="sector-select"
class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
>Secteur</label
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showSectorDropdown) {
<div
class="absolute mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
<select
id="sector-select"
formControlName="secteur"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<button
type="button"
(click)="selectSector(null)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="!filters().secteur"
[class.dark:bg-purple-900]="!filters().secteur"
>
<span class="text-gray-700 dark:text-gray-300">Tous les secteurs</span>
</button>
<option [ngValue]="null">Tous les secteurs</option>
@for (sector of sectors(); track sector.id) {
<button
type="button"
(click)="selectSector(sector.nom)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters().secteur === sector.nom"
[class.dark:bg-purple-900]="filters().secteur === sector.nom"
>
<span class="text-gray-700 dark:text-gray-300">{{ sector.nom }}</span>
</button>
<option [value]="sector.nom">{{ sector.nom }}</option>
}
</div>
}
</div>
</ng-template>
</select>
</div>
<ng-template #profession>
<div class="relative">
<button
type="button"
(click)="toggleProfessionDropdown()"
[class.ring-2]="filters().profession"
[class.ring-purple-500]="filters().profession"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
[class.text-purple-600]="filters().profession"
[class.dark:text-purple-400]="filters().profession"
[class.text-gray-400]="!filters().profession"
>
<path
fill-rule="evenodd"
d="M6 3.75A2.75 2.75 0 018.75 1h2.5A2.75 2.75 0 0114 3.75v.443c.572.055 1.14.122 1.706.2C17.053 4.582 18 5.75 18 7.07v3.469c0 1.126-.694 2.191-1.83 2.54-1.952.599-4.024.921-6.17.921s-4.219-.322-6.17-.921C2.694 12.73 2 11.665 2 10.539V7.07c0-1.321.947-2.489 2.294-2.676A41.047 41.047 0 016 4.193V3.75zm6.5 0v.325a41.622 41.622 0 00-5 0V3.75c0-.69.56-1.25 1.25-1.25h2.5c.69 0 1.25.56 1.25 1.25zM10 10a1 1 0 00-1 1v.01a1 1 0 001 1h.01a1 1 0 001-1V11a1 1 0 00-1-1H10z"
clip-rule="evenodd"
/>
<path
d="M3 15.055v-.684c.126.053.255.1.39.142 2.092.642 4.313.987 6.61.987 2.297 0 4.518-.345 6.61-.987.135-.041.264-.089.39-.142v.684c0 1.347-.985 2.53-2.363 2.686a41.454 41.454 0 01-9.274 0C3.985 17.585 3 16.402 3 15.055z"
/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ filters().profession || 'Profession' }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showProfessionDropdown"
<div class="space-y-1">
<label
for="job-select"
class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
>Profession</label
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showProfessionDropdown) {
<div
class="absolute z-10 mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
<select
id="job-select"
formControlName="profession"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<button
type="button"
(click)="selectProfession(null)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="!filters().profession"
[class.dark:bg-purple-900]="!filters().profession"
>
<span class="text-gray-700 dark:text-gray-300">Toutes les professions</span>
</button>
<option [ngValue]="null">Toutes les professions</option>
@for (profile of profiles(); track profile.id) {
<button
type="button"
(click)="selectProfession(profile.profession)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters().profession === profile.profession"
[class.dark:bg-purple-900]="filters().profession === profile.profession"
>
<span class="text-gray-700 dark:text-gray-300">{{ profile.profession }}</span>
</button>
<option [value]="profile.profession">{{ profile.profession }}</option>
}
</div>
}
</div>
</ng-template>
</select>
</div>
<ng-template #tri>
<div class="relative">
<button
type="button"
(click)="toggleSortDropdown()"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400"
<div class="space-y-1">
<label
for="sort-select"
class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
>Trier par</label
>
<select
id="sort-select"
formControlName="sort"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
>
<path
fill-rule="evenodd"
d="M2.24 6.8a.75.75 0 001.06-.04l1.95-2.1v8.59a.75.75 0 001.5 0V4.66l1.95 2.1a.75.75 0 101.1-1.02l-3.25-3.5a.75.75 0 00-1.1 0L2.2 5.74a.75.75 0 00.04 1.06zm8 6.4a.75.75 0 00-.04 1.06l3.25 3.5a.75.75 0 001.1 0l3.25-3.5a.75.75 0 10-1.1-1.02l-1.95 2.1V6.75a.75.75 0 00-1.5 0v8.59l-1.95-2.1a.75.75 0 00-1.06-.04z"
clip-rule="evenodd"
/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ sortLabel }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showSortDropdown"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showSortDropdown) {
<div
class="absolute z-10 mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg"
>
@for (sort of sortOptions; track sort.value) {
<button
type="button"
(click)="selectSort(sort)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters().sort === sort.value"
[class.dark:bg-purple-900]="filters().sort === sort.value"
>
<span class="text-gray-700 dark:text-gray-300">{{ sort.label }}</span>
</button>
@for (opt of sortOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</div>
}
</div>
</ng-template>
</select>
</div>
<div class="w-full">
<button
type="submit"
class="w-full py-3 px-4 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white font-medium rounded-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<small> Appliquer les filtres</small>
</button>
</div>
</form>
</div>

View File

@@ -1,8 +1,9 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, output } from '@angular/core';
import { SectorFacade } from '@app/ui/sectors/sector.facade';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { NgTemplateOutlet } from '@angular/common';
import { SearchService } from '@app/infrastructure/search/search.service';
import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SearchFilters } from '@app/domain/search/search-filters';
interface SortOption {
value: string;
@@ -12,7 +13,7 @@ interface SortOption {
@Component({
selector: 'app-filter',
standalone: true,
imports: [NgTemplateOutlet],
imports: [FormsModule, ReactiveFormsModule],
providers: [SectorFacade],
templateUrl: './filter.component.html',
styleUrl: './filter.component.scss',
@@ -20,27 +21,28 @@ interface SortOption {
export class FilterComponent implements OnInit {
private readonly searchService = inject(SearchService);
// État des dropdowns
showSectorDropdown = false;
showProfessionDropdown = false;
showSortDropdown = false;
// Filtres
filters = this.searchService.getFilters();
protected readonly sectorFacade = inject(SectorFacade);
protected readonly sectors = this.sectorFacade.sectors;
protected readonly sectorLoading = this.sectorFacade.loading;
protected readonly sectorError = this.sectorFacade.error;
protected readonly profileFacade = inject(ProfileFacade);
protected readonly profiles = this.profileFacade.profiles;
protected readonly ProfileFacade = inject(ProfileFacade);
protected readonly profiles = this.ProfileFacade.profiles;
protected readonly profileLoading = this.ProfileFacade.loading;
protected readonly profileError = this.ProfileFacade.error;
readonly activeFilters = this.searchService.getFilters();
ngOnInit() {
this.sectorFacade.load();
this.ProfileFacade.load();
onFilterChange = output<SearchFilters>();
private fb = inject(FormBuilder);
filterForm = this.fb.group({
verified: [this.activeFilters().verified],
secteur: [this.activeFilters().secteur],
profession: [this.activeFilters().profession],
sort: [this.activeFilters().sort || 'recent'],
});
onSubmit() {
const filters = this.filterForm.getRawValue() as SearchFilters;
this.searchService.setFilters(filters);
this.onFilterChange.emit(filters);
}
sortOptions: SortOption[] = [
@@ -50,56 +52,17 @@ export class FilterComponent implements OnInit {
{ value: 'verified', label: 'Vérifiés' },
];
get sortLabel(): string {
return this.sortOptions.find((s) => s.value === this.filters().sort)?.label || 'Trier par';
ngOnInit() {
this.sectorFacade.load();
this.profileFacade.load();
}
// Gestion du filtre "Vérifiés"
toggleVerifiedFilter(): void {
this.filters().verified = !this.filters().verified;
}
// Gestion des dropdowns
toggleSectorDropdown(): void {
this.showSectorDropdown = !this.showSectorDropdown;
this.showProfessionDropdown = false;
this.showSortDropdown = false;
}
toggleProfessionDropdown(): void {
this.showProfessionDropdown = !this.showProfessionDropdown;
this.showSectorDropdown = false;
this.showSortDropdown = false;
}
toggleSortDropdown(): void {
this.showSortDropdown = !this.showSortDropdown;
this.showSectorDropdown = false;
this.showProfessionDropdown = false;
}
// Sélection des filtres
selectSector(sector: string | null): void {
this.searchService.filterBySecteur(sector);
this.showSectorDropdown = false;
}
selectProfession(profession: string | null): void {
this.searchService.filterByProfession(profession);
this.showProfessionDropdown = false;
}
selectSort(sort: SortOption): void {
this.searchService.sortBy(sort.value);
this.showSortDropdown = false;
}
// Vérifier si des filtres sont actifs
// Vérifier si des filtres sont actifs pour afficher le bouton "Effacer"
hasActiveFilters(): boolean {
return this.filters().verified || !!this.filters().secteur || !!this.filters().profession;
const f = this.activeFilters();
return f.verified || !!f.secteur || !!f.profession;
}
// Effacer tous les filtres
clearAllFilters(): void {
this.searchService.reset();
}

View File

@@ -1,14 +1,5 @@
<div class="w-full space-y-4 sm:space-y-6 z-[800]">
<!-- Filtres -->
<div>
<app-filter />
</div>
<!-- Barre de recherche -->
<ng-container *ngTemplateOutlet="form"></ng-container>
</div>
<ng-template #form>
<form [formGroup]="searchForm" (ngSubmit)="onSubmit()" class="w-full">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-0 w-full">
<div
@@ -61,4 +52,4 @@
</button>
</div>
</form>
</ng-template>
</div>

View File

@@ -1,14 +1,12 @@
import { Component, inject, output } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { FilterComponent } from '@app/shared/features/filter/filter.component';
import { SearchFilters } from '@app/domain/search/search-filters';
import { NgTemplateOutlet } from '@angular/common';
import { SearchService } from '@app/infrastructure/search/search.service';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule, FilterComponent, NgTemplateOutlet],
imports: [ReactiveFormsModule],
templateUrl: './search.component.html',
styleUrl: './search.component.scss',
})

View File

@@ -11,7 +11,9 @@ export class FakeAuthRepository implements AuthRepository {
newPassword: string,
confirmPassword: string
): Observable<boolean> {
return of(true);
return of(
resetToken === 'fakeToken' && newPassword === confirmPassword && fakeUsers[0].verified
);
}
get(): User | undefined {

View File

@@ -24,7 +24,6 @@ describe('PbProfileRepository', () => {
collection: jest.fn().mockReturnValue(mockCollection),
};
// @ts-ignore : on remplace linstance réelle de PocketBase par notre mock
(PocketBase as jest.Mock).mockImplementation(() => mockPocketBase);
// on récupère une "collection" simulée
@@ -39,10 +38,10 @@ describe('PbProfileRepository', () => {
it('devrait appeler pb.collection("profiles").getFullList() avec un tri par profession', (done) => {
mockCollection.getFullList.mockResolvedValue(mockProfiles);
const options = {
sort: 'profession',
expand: 'utilisateur',
filter:
"utilisateur.verified=true && utilisateur.name !='' && profession!='Profession non renseignée' && secteur!='' ",
"utilisateur.verified = true && utilisateur.name != '' && profession != 'Profession non renseignée' && secteur != ''",
sort: '-created',
};
repo.list().subscribe((result) => {
expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles');

View File

@@ -11,6 +11,7 @@ import { CreateProfileUseCase } from '@app/usecase/profiles/create-profile.useca
import { UpdateProfileUseCase } from '@app/usecase/profiles/update-profile.usecase';
import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import { SearchFilters } from '@app/domain/search/search-filters';
@Injectable({
providedIn: 'root',
@@ -32,10 +33,10 @@ export class ProfileFacade {
message: null,
});
load(search?: string) {
load(search?: SearchFilters) {
this.handleError(ActionType.READ, false, null, true);
this.listUseCase.execute({ search }).subscribe({
this.listUseCase.execute(search).subscribe({
next: (profiles) => {
this.profiles.set(ProfilePresenter.toViewModels(profiles));
this.handleError(ActionType.READ, false, null, false);

View File

@@ -45,7 +45,6 @@ export class ProjectFacade {
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
complete: () => {},
});
}

View File

@@ -2,8 +2,6 @@ import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { Project } from '@app/domain/projects/project.model';
export class ProjectPresenter {
constructor() {}
toViewModel(project: Project): ProjectViewModel {
return {
id: project.id,

View File

@@ -2,8 +2,6 @@ import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { User } from '@app/domain/users/user.model';
export class UserPresenter {
constructor() {}
toViewModel(user: User): UserViewModel {
return {
id: user.id,

View File

@@ -1,9 +0,0 @@
import { AuthRepository } from '@app/domain/authentification/auth.repository';
export class ConfirmPasswordResetUsecase {
constructor(private readonly authRepo: AuthRepository) {}
execute(resetToken: string, newPassword: string, confirmPassword: string) {
return this.authRepo.confirmPasswordReset(resetToken, newPassword, confirmPassword);
}
}

View File

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