project refactoring en clean archi

This commit is contained in:
styve Lioumba
2025-10-23 14:10:53 +02:00
parent ef02c6a537
commit 02637235e3
52 changed files with 3873 additions and 875 deletions

View File

@@ -17,6 +17,9 @@
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"pocketbase"
],
"outputPath": "dist/",
"index": "src/index.html",
"browser": "src/main.ts",
@@ -88,6 +91,15 @@
"options": {
"buildTarget": "TrouveTonProfile:build"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
@@ -95,6 +107,7 @@
"cli": {
"analytics": "dda3ec82-e13e-4042-ae63-71d138479518",
"schematicCollections": [
"angular-eslint",
"angular-eslint"
]
}

43
eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
}
);

View File

@@ -1,17 +1,21 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-preset-angular',
rootDir: '.',
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/dist/'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.ts$': 'ts-jest', // Only transform .ts files
'^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular'
},
transformIgnorePatterns: [
'/node_modules/(?!flat)/', // Exclude modules except 'flat' from transformation
'node_modules/(?!(@angular|rxjs|pocketbase|flat)/)', // 👈 simplifié et plus robuste
],
moduleNameMapper: {
'^@app/(.*)$': '<rootDir>/src/app/$1',
'^@env/(.*)$': '<rootDir>/src/environments/$1',
},
testMatch: ['**/*.spec.ts'],
testTimeout: 30000,
verbose: true,
};

3220
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,12 @@
"tsc:watch": "tsc --noEmit --watch",
"format": "prettier --write \"src/**/*.{ts,html,scss,css,md,json}\"",
"check:all": "npm run format && npm run tsc && npm run lint && npm run test",
"test": "jest",
"test": "jest --no-warnings node_modules/jest/bin/jest.js",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --runInBand",
"serve:ssr:TrouveTonProfile": "node dist/trouve-ton-profile/server/server.mjs"
"serve:ssr:TrouveTonProfile": "node dist/trouve-ton-profile/server/server.mjs",
"lint": "ng lint"
},
"private": true,
"dependencies": {
@@ -55,16 +56,20 @@
"@types/jest": "^30.0.0",
"@types/node": "^18.18.0",
"@types/node-fetch": "^2.6.13",
"angular-eslint": "20.4.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.6.1",
"jest-preset-angular": "^14.6.2",
"node-fetch": "^2.7.0",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.12",
"typescript": "~5.2.2"
"ts-jest": "^29.4.5",
"typescript": "~5.2.2",
"typescript-eslint": "8.46.0"
}
}

View File

@@ -13,6 +13,8 @@ import { provideAnimations } from '@angular/platform-browser/animations';
import { provideToastr } from 'ngx-toastr';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { PbProfileRepository } from '@app/infrastructure/profiles/pb-profile.repository';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { PbProjectRepository } from '@app/infrastructure/projects/pb-project.repository';
export const appConfig: ApplicationConfig = {
providers: [
@@ -28,6 +30,7 @@ export const appConfig: ApplicationConfig = {
provideAnimations(),
provideHttpClient(withFetch()),
{ provide: PROFILE_REPOSITORY_TOKEN, useExisting: PbProfileRepository },
{ provide: PROJECT_REPOSITORY_TOKEN, useExisting: PbProjectRepository },
provideToastr({
timeOut: 10000,
positionClass: 'toast-top-right',

View File

@@ -1,27 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ProjectService } from './project.service';
import { Router } from '@angular/router';
describe('ProjectService', () => {
let service: ProjectService;
const routerSpy = {
navigate: jest.fn(),
navigateByUrl: jest.fn(),
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: routerSpy }, // <<— spy: neutralise la navigation
],
imports: [],
});
service = TestBed.inject(ProjectService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -1,33 +0,0 @@
import { Injectable } from '@angular/core';
import PocketBase from 'pocketbase';
import { environment } from '@env/environment.development';
import { from } from 'rxjs';
import { Project } from '@app/shared/models/project';
import { ProjectDto } from '@app/shared/models/project-dto';
@Injectable({
providedIn: 'root',
})
export class ProjectService {
createProject(projectDto: ProjectDto) {
const pb = new PocketBase(environment.baseUrl);
return from(pb.collection('projets').create<Project>(projectDto));
}
getProjectByUserId(userId: string) {
const pb = new PocketBase(environment.baseUrl);
return from(
pb.collection<Project>('projets').getFullList({ filter: `utilisateur='${userId}'` })
);
}
getProjectById(id: string) {
const pb = new PocketBase(environment.baseUrl);
return from(pb.collection<Project>('projets').getOne<Project>(id));
}
updateProject(id: string, data: Project | any) {
const pb = new PocketBase(environment.baseUrl);
return from(pb.collection('projets').update<Project>(id, data));
}
}

View File

@@ -0,0 +1,7 @@
export enum ActionType {
CREATE = 'CREATE',
READ = 'READ',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
NONE = 'NONE',
}

View File

@@ -0,0 +1,7 @@
import { ActionType } from '@app/domain/action-type.util';
export interface ErrorResponse {
action: ActionType;
hasError: boolean;
message?: string | null;
}

View File

@@ -0,0 +1,6 @@
import { ActionType } from '@app/domain/action-type.util';
export interface LoaderAction {
action: ActionType;
isLoading: boolean;
}

View File

@@ -0,0 +1,5 @@
export interface ProfileDTO {
profession: string;
utilisateur: string;
reseaux: any;
}

View File

@@ -0,0 +1,6 @@
export interface CreateProjectDto {
nom: string;
description: string;
lien: string;
utilisateur: string;
}

View File

@@ -0,0 +1,10 @@
export interface Project {
id: string;
created: string;
updated: string;
nom: string;
lien: string;
description: string;
fichier: string[];
utilisateur: string;
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { Project } from '@app/shared/models/project';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
export interface ProjectRepository {
create(projectDto: CreateProjectDto): Observable<Project>;
list(userId: string): Observable<Project[]>;
get(projectId: string): Observable<Project>;
update(id: string, data: Project | any): Observable<Project>;
}

View File

@@ -4,12 +4,7 @@ import { Profile } from '@app/domain/profiles/profile.model';
import { Injectable } from '@angular/core';
import PocketBase from 'pocketbase';
import { environment } from '@env/environment';
interface ProfileDTO {
profession: string;
utilisateur: string;
reseaux: any;
}
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
@Injectable({ providedIn: 'root' })
export class PbProfileRepository implements ProfileRepository {

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { environment } from '@env/environment';
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { Project } from '@app/shared/models/project';
import { from, Observable } from 'rxjs';
import PocketBase from 'pocketbase';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
@Injectable({
providedIn: 'root',
})
export class PbProjectRepository implements ProjectRepository {
private pb = new PocketBase(environment.baseUrl);
create(project: CreateProjectDto): Observable<Project> {
return from(this.pb.collection('projets').create<Project>(project));
}
list(userId: string): Observable<Project[]> {
return from(
this.pb.collection<Project>('projets').getFullList({ filter: `utilisateur='${userId}'` })
);
}
get(projectId: string): Observable<Project> {
return from(this.pb.collection<Project>('projets').getOne<Project>(projectId));
}
update(id: string, data: any): Observable<Project> {
return from(this.pb.collection('projets').update<Project>(id, data));
}
}

View File

@@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import { ProjectRepository } from '@app/domain/projects/project.repository';
export const PROJECT_REPOSITORY_TOKEN = new InjectionToken<ProjectRepository>('ProjectRepository');

View File

@@ -8,7 +8,7 @@
</div>
<div class="max-w-6xl mx-auto px-4">
@if (loading()) {
@if (loading().isLoading) {
<div class="flex justify-center items-center h-96">
<p>Chargement...</p>
</div>

View File

@@ -1,6 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { SearchComponent } from '@app/shared/features/search/search.component';
import { DisplayProfileCardComponent } from '@app/shared/features/display-profile-card/display-profile-card.component';
import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
@@ -8,7 +7,7 @@ import { ProfileFacade } from '@app/ui/profiles/profile.facade';
@Component({
selector: 'app-profile-list',
standalone: true,
imports: [SearchComponent, DisplayProfileCardComponent, VerticalProfileListComponent],
imports: [SearchComponent, VerticalProfileListComponent],
templateUrl: './profile-list.component.html',
styleUrl: './profile-list.component.scss',
})

View File

@@ -1,8 +1,8 @@
@if (project) {
@if (project()) {
<div class="bg-white rounded-2xl border p-6 max-w-sm">
<div class="">
<h3 class="text-lg font-bold text-gray-800 mb-3">{{ project.nom }}</h3>
<p class="text-gray-800 text-sm">{{ project.description }}</p>
<h3 class="text-lg font-bold text-gray-800 mb-3">{{ project().nom }}</h3>
<p class="text-gray-800 text-sm">{{ project().description }}</p>
<div class="mt-6">
<a
[routerLink]="[]"

View File

@@ -1,9 +1,8 @@
import { Component, inject, Input, OnInit } from '@angular/core';
import { AuthService } from '@app/core/services/authentication/auth.service';
import { ProjectService } from '@app/core/services/project/project.service';
import { Project } from '@app/shared/models/project';
import { environment } from '@env/environment';
import { RouterLink } from '@angular/router';
import { ProjectFacade } from '@app/ui/projects/project.facade';
@Component({
selector: 'app-my-profile-project-item',
@@ -17,11 +16,10 @@ export class MyProfileProjectItemComponent implements OnInit {
@Input({ required: true }) projectId = '';
protected authService = inject(AuthService);
protected projectService = inject(ProjectService);
protected project: Project | undefined = undefined;
private readonly projectFacade = new ProjectFacade();
protected project = this.projectFacade.project;
ngOnInit(): void {
this.projectService.getProjectById(this.projectId).subscribe((value) => (this.project = value));
this.projectFacade.loadOne(this.projectId);
}
}

View File

@@ -2,7 +2,7 @@
<div class="max-w-4xl max-lg:max-w-2xl max-sm:max-w-sm mx-auto">
<h2 class="text-2xl font-bold text-gray-800 mb-8">Mes projets</h2>
@if (projects) {
@if (projects()) {
<div class="relative flex items-center">
<select
[(ngModel)]="projectIdSelected"
@@ -12,7 +12,7 @@
<option [value]="'add'.toLowerCase()">Ajouter un nouveau projet</option>
@for (project of projects; track project.id) {
@for (project of projects(); track project.id) {
<option [value]="project.id">
{{ project.nom }}
</option>

View File

@@ -1,24 +1,14 @@
import { Component, inject, Input, OnInit, signal } from '@angular/core';
import { MyProfileProjectItemComponent } from '@app/shared/components/my-profile-project-item/my-profile-project-item.component';
import { Component, Input, OnInit, signal } from '@angular/core';
import { PaginatorModule } from 'primeng/paginator';
import { ReactiveFormsModule } from '@angular/forms';
import { ProjectService } from '@app/core/services/project/project.service';
import { AsyncPipe, JsonPipe } from '@angular/common';
import { Project } from '@app/shared/models/project';
import { UntilDestroy } from '@ngneat/until-destroy';
import { MyProfileUpdateProjectFormComponent } from '@app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component';
import { ProjectFacade } from '@app/ui/projects/project.facade';
@Component({
selector: 'app-my-profile-project-list',
standalone: true,
imports: [
MyProfileProjectItemComponent,
PaginatorModule,
ReactiveFormsModule,
AsyncPipe,
JsonPipe,
MyProfileUpdateProjectFormComponent,
],
imports: [PaginatorModule, ReactiveFormsModule, MyProfileUpdateProjectFormComponent],
templateUrl: './my-profile-project-list.component.html',
styleUrl: './my-profile-project-list.component.scss',
})
@@ -26,15 +16,14 @@ import { MyProfileUpdateProjectFormComponent } from '@app/shared/components/my-p
export class MyProfileProjectListComponent implements OnInit {
@Input({ required: true }) projectIds: string[] = [];
@Input({ required: true }) userId = '';
protected projectService = inject(ProjectService);
protected projectIdSelected = signal<string | null>(null);
protected projects: Project[] = [];
private readonly projectFacade = new ProjectFacade();
protected projects = this.projectFacade.projects;
ngOnInit(): void {
this.projectService
.getProjectByUserId(this.userId)
.subscribe((value) => (this.projects = value));
this.projectFacade.load(this.userId);
}
onProjectFormSubmitted($event: string | null) {

View File

@@ -2,13 +2,21 @@
@if (projectId == 'add'.toLowerCase()) {
<app-project-picture-form [project]="undefined" />
} @else {
<app-project-picture-form [project]="project" />
<app-project-picture-form [project]="project()" />
}
<h3 class="font-ubuntu w-full text-start font-bold text-xl uppercase dark:text-white my-5">
Information du projet
</h3>
@if (loading().isLoading) {
@switch (loading().action) {
@case (ActionType.NONE || ActionType.CREATE || ActionType.DELETE) {}
@default {
<p>Chargement...</p>
}
}
} @else {
<form class="mx-8" [formGroup]="projectForm" (ngSubmit)="onSubmit()">
<label class="mb-2 text-sm text-black block dark:text-white">Nom</label>
<div class="relative flex items-center">
@@ -87,3 +95,4 @@
</button>
</form>
}
}

View File

@@ -3,7 +3,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyProfileUpdateProjectFormComponent } from './my-profile-update-project-form.component';
import { AuthService } from '@app/core/services/authentication/auth.service';
import { ToastrService } from 'ngx-toastr';
import { ProjectService } from '@app/core/services/project/project.service';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { Auth } from '@app/shared/models/auth';
@@ -12,7 +11,6 @@ describe('MyProfileUpdateProjectFormComponent', () => {
let component: MyProfileUpdateProjectFormComponent;
let fixture: ComponentFixture<MyProfileUpdateProjectFormComponent>;
let mockProjectService: Partial<ProjectService>;
let mockAuthService: Partial<AuthService>;
let mockToastrService: Partial<ToastrService>;
@@ -27,15 +25,12 @@ describe('MyProfileUpdateProjectFormComponent', () => {
user: signal<Auth | undefined>(undefined),
};
mockProjectService = {};
await TestBed.configureTestingModule({
imports: [MyProfileUpdateProjectFormComponent],
providers: [
provideRouter([]),
{ provide: AuthService, useValue: mockAuthService },
{ provide: ToastrService, useValue: mockToastrService },
{ provide: ProjectService, useValue: mockProjectService },
],
}).compileComponents();

View File

@@ -1,13 +1,22 @@
import { Component, inject, Input, OnChanges, OnInit, output, SimpleChanges } from '@angular/core';
import {
Component,
effect,
inject,
Input,
OnChanges,
OnInit,
output,
SimpleChanges,
} from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { ProjectService } from '@app/core/services/project/project.service';
import { Project } from '@app/shared/models/project';
import { NgClass } from '@angular/common';
import { PaginatorModule } from 'primeng/paginator';
import { ProjectPictureFormComponent } from '@app/shared/components/project-picture-form/project-picture-form.component';
import { ToastrService } from 'ngx-toastr';
import { AuthService } from '@app/core/services/authentication/auth.service';
import { ProjectDto } from '@app/shared/models/project-dto';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { ActionType } from '@app/domain/action-type.util';
@Component({
selector: 'app-my-profile-update-project-form',
@@ -19,11 +28,15 @@ import { ProjectDto } from '@app/shared/models/project-dto';
export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
@Input({ required: true }) projectId: string | null = null;
protected project: Project | undefined = undefined;
private readonly toastrService = inject(ToastrService);
private readonly authService = inject(AuthService);
protected readonly projectService = inject(ProjectService);
private readonly projectFacade = new ProjectFacade();
protected readonly ActionType = ActionType;
protected readonly project = this.projectFacade.project;
protected readonly loading = this.projectFacade.loading;
protected readonly error = this.projectFacade.error;
private readonly formBuilder = inject(FormBuilder);
protected projectForm = this.formBuilder.group({
@@ -34,6 +47,32 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
formIsUpdated = output<string | null>();
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.CREATE:
message = `Le projet ${this.projectForm.getRawValue().nom} a bien été créer !`;
this.customToast(ActionType.CREATE, message);
break;
case ActionType.UPDATE:
message = `Les informations du projet ${this.projectForm.getRawValue().nom} ont bien été modifier !`;
this.customToast(ActionType.UPDATE, message);
break;
}
if (this.project() !== undefined) {
this.projectForm.setValue({
nom: this.project()!.nom,
description: this.project()!.description,
lien: this.project()!.lien,
});
}
}
});
}
ngOnInit(): void {
if (this.projectId == 'add'.toLowerCase()) {
this.projectForm.setValue({
@@ -44,14 +83,7 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
}
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
this.projectService.getProjectById(this.projectId).subscribe((value) => {
this.project = value;
this.projectForm.setValue({
nom: value.nom,
description: value.description,
lien: value.lien,
});
});
this.projectFacade.loadOne(this.projectId);
}
}
@@ -62,39 +94,15 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
// Update
this.projectService
.updateProject(this.project!.id, this.projectForm.getRawValue())
.subscribe((value) => {
this.formIsUpdated.emit(value.id);
this.toastrService.success(
`Les informations du projet ${value.nom} ont bien été modifier !`,
`Mise à jour`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
});
this.projectFacade.update(this.project()!.id, this.projectForm.getRawValue());
} else {
// Create
const projectDto: ProjectDto = {
const projectDto: CreateProjectDto = {
...this.projectForm.getRawValue(),
utilisateur: this.authService.user()!.record!.id,
} as ProjectDto;
} as CreateProjectDto;
this.projectService.createProject(projectDto).subscribe((value) => {
this.formIsUpdated.emit(value.id);
this.toastrService.success(`Le projet ${value.nom} a bien été créer !`, `Nouveau projet`, {
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
});
});
this.projectFacade.create(projectDto);
}
}
@@ -102,4 +110,30 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
this.projectId = changes['projectId'].currentValue;
this.ngOnInit();
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.formIsUpdated.emit(this.project()!.id);
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : 'Nouveau projet'}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
}
}

View File

@@ -1,18 +1,16 @@
import { Component, Input } from '@angular/core';
import { Project } from '@app/shared/models/project';
import { JsonPipe } from '@angular/common';
import { environment } from '@env/environment';
import { RouterLink } from '@angular/router';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
@Component({
selector: 'app-project-item',
standalone: true,
imports: [JsonPipe, RouterLink],
imports: [],
templateUrl: './project-item.component.html',
styleUrl: './project-item.component.scss',
})
export class ProjectItemComponent {
protected readonly environment = environment;
@Input({ required: true }) project: Project | undefined = undefined;
@Input({ required: true }) project: ProjectViewModel | undefined = undefined;
}

View File

@@ -2,7 +2,7 @@
<div class="max-w-4xl max-lg:max-w-2xl max-sm:max-w-sm mx-auto">
<h2 class="text-2xl font-bold text-gray-800 mb-8">Explorer les projets</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@for (project of projects; track project) {
@for (project of projects(); track project) {
<app-project-item [project]="project" />
} @empty {
<p>Aucun projet</p>

View File

@@ -1,14 +1,12 @@
import { Component, inject, Input, OnInit } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ProjectItemComponent } from '@app/shared/components/project-item/project-item.component';
import { JsonPipe } from '@angular/common';
import { ProjectService } from '@app/core/services/project/project.service';
import { Project } from '@app/shared/models/project';
import { ProjectFacade } from '@app/ui/projects/project.facade';
@Component({
selector: 'app-project-list',
standalone: true,
imports: [ProjectItemComponent, JsonPipe],
imports: [ProjectItemComponent],
templateUrl: './project-list.component.html',
styleUrl: './project-list.component.scss',
})
@@ -16,13 +14,11 @@ import { Project } from '@app/shared/models/project';
export class ProjectListComponent implements OnInit {
@Input({ required: true }) userProjectId = '';
protected readonly projectService = inject(ProjectService);
private readonly projectFacade = new ProjectFacade();
protected projects: Project[] = [];
protected projects = this.projectFacade.projects;
ngOnInit(): void {
this.projectService
.getProjectByUserId(this.userProjectId)
.subscribe((value) => (this.projects = value));
this.projectFacade.load(this.userProjectId);
}
}

View File

@@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectPictureFormComponent } from './project-picture-form.component';
import { provideRouter } from '@angular/router';
import { ProjectService } from '@app/core/services/project/project.service';
import { ToastrService } from 'ngx-toastr';
import { AuthService } from '@app/core/services/authentication/auth.service';
@@ -10,16 +9,10 @@ describe('ProjectPictureFormComponent', () => {
let component: ProjectPictureFormComponent;
let fixture: ComponentFixture<ProjectPictureFormComponent>;
let mockProjectService: Partial<ProjectService>;
let mockToastrService: Partial<ToastrService>;
let mockAuthService: Partial<AuthService>;
beforeEach(async () => {
mockProjectService = {
updateProject: jest.fn().mockReturnValue({
subscribe: jest.fn(),
}),
};
mockToastrService = {
success: jest.fn(),
error: jest.fn(),
@@ -33,7 +26,6 @@ describe('ProjectPictureFormComponent', () => {
imports: [ProjectPictureFormComponent],
providers: [
provideRouter([]),
{ provide: ProjectService, useValue: mockProjectService },
{ provide: ToastrService, useValue: mockToastrService },
{ provide: AuthService, useValue: mockAuthService },
],

View File

@@ -1,10 +1,11 @@
import { Component, inject, Input, output } from '@angular/core';
import { Component, effect, inject, Input, output } from '@angular/core';
import { AuthService } from '@app/core/services/authentication/auth.service';
import { Project } from '@app/shared/models/project';
import { ProjectService } from '@app/core/services/project/project.service';
import { NgClass } from '@angular/common';
import { environment } from '@env/environment';
import { ToastrService } from 'ngx-toastr';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ActionType } from '@app/domain/action-type.util';
@Component({
selector: 'app-project-picture-form',
@@ -14,27 +15,29 @@ import { ToastrService } from 'ngx-toastr';
styleUrl: './project-picture-form.component.scss',
})
export class ProjectPictureFormComponent {
@Input({ required: true }) project: Project | undefined = undefined;
@Input({ required: true }) project: ProjectViewModel | undefined = undefined;
onFormSubmitted = output<any>();
private readonly projectService = inject(ProjectService);
private readonly toastrService = inject(ToastrService);
private readonly projectFacade = new ProjectFacade();
protected readonly loading = this.projectFacade.loading;
protected readonly error = this.projectFacade.error;
private readonly authService = inject(AuthService);
file: File | null = null; // Variable to store file
imagePreviewUrl: string | null = null; // URL for image preview
onSubmit() {
if (this.file != null) {
const formData = new FormData();
formData.append('fichier', this.file); // "fichier" est le nom du champ dans PocketBase
this.projectService.updateProject(this.project?.id!, formData).subscribe((value) => {
constructor() {
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.UPDATE:
this.authService.updateUser();
this.toastrService.success(
`L'aperçu du projet ${value.nom} ont bien été modifier !`,
`L'aperçu du projet ${this.project!.nom} ont bien été modifier !`,
`Mise à jour`,
{
closeButton: true,
@@ -42,9 +45,20 @@ export class ProjectPictureFormComponent {
progressBar: true,
}
);
});
this.onFormSubmitted.emit('');
break;
}
}
});
}
onSubmit() {
if (this.file != null) {
const formData = new FormData();
formData.append('fichier', this.file); // "fichier" est le nom du champ dans PocketBase
this.projectFacade.update(this.project?.id!, formData);
}
}

View File

@@ -0,0 +1,45 @@
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { Project } from '@app/shared/models/project';
import { Observable, of } from 'rxjs';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { fakeProjects } from '@app/testing/project.mock';
export class FakeProjectRepository implements ProjectRepository {
private projects: Project[] = [...fakeProjects];
create(projectDto: CreateProjectDto): Observable<Project> {
const newProject: Project = {
...projectDto,
fichier: [],
id: (this.projects.length + 1).toString(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
this.projects.push(newProject);
return of(newProject);
}
list(userId: string): Observable<Project[]> {
const filtered = this.projects.filter((p) => p.utilisateur === userId);
return of(filtered);
}
get(projectId: string): Observable<Project> {
const found = this.projects.find((p) => p.id === projectId) ?? ({} as Project);
return of(found);
}
update(id: string, data: Partial<Project>): Observable<Project> {
const index = this.projects.findIndex((p) => p.id === id);
if (index === -1) {
return of({} as Project);
}
const updated: Project = {
...this.projects[index],
...data,
updated: new Date().toISOString(),
};
this.projects[index] = updated;
return of(updated);
}
}

View File

@@ -0,0 +1,156 @@
import { PbProfileRepository } from '@app/infrastructure/profiles/pb-profile.repository';
import { Profile } from '@app/domain/profiles/profile.model';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import PocketBase from 'pocketbase';
jest.mock('pocketbase'); // on mock le module PocketBase
describe('PbProfileRepository', () => {
let repo: PbProfileRepository;
let mockPocketBase: any;
let mockCollection: any;
beforeEach(() => {
// Création dun faux client PocketBase avec les méthodes dont on a besoin
mockCollection = {
getFullList: jest.fn(),
getFirstListItem: jest.fn(),
create: jest.fn(),
update: jest.fn(),
};
mockPocketBase = {
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
mockCollection = mockPocketBase.collection('profiles');
repo = new PbProfileRepository();
});
// ------------------------------------------
// 🔹 TEST : list()
// ------------------------------------------
it('devrait appeler pb.collection("profiles").getFullList() avec un tri par profession', (done) => {
const fakeProfiles: Profile[] = [
{
id: '1',
created: '',
updated: '',
profession: 'Développeur',
utilisateur: 'u001',
estVerifier: false,
secteur: 'Informatique',
reseaux: {} as JSON,
bio: 'Bio test',
cv: '',
projets: [],
apropos: 'À propos...',
},
];
mockCollection.getFullList.mockResolvedValue(fakeProfiles);
repo.list().subscribe((result) => {
expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles');
expect(mockCollection.getFullList).toHaveBeenCalledWith({ sort: 'profession' });
expect(result).toEqual(fakeProfiles);
done();
});
});
// ------------------------------------------
// 🔹 TEST : getByUserId()
// ------------------------------------------
it('devrait appeler pb.collection("profiles").getFirstListItem() avec le bon filtre utilisateur', (done) => {
const userId = 'user_001';
const fakeProfile: Profile = {
id: 'p001',
created: '',
updated: '',
profession: 'Designer',
utilisateur: userId,
estVerifier: true,
secteur: 'Création',
reseaux: {} as JSON,
bio: 'Bio',
cv: '',
projets: [],
apropos: 'À propos...',
};
mockCollection.getFirstListItem.mockResolvedValue(fakeProfile);
repo.getByUserId(userId).subscribe((result) => {
expect(mockCollection.getFirstListItem).toHaveBeenCalledWith(`utilisateur="${userId}"`);
expect(result).toEqual(fakeProfile);
done();
});
});
// ------------------------------------------
// 🔹 TEST : create()
// ------------------------------------------
it('devrait appeler pb.collection("profiles").create() avec le DTO du profil', (done) => {
const dto: ProfileDTO = {
utilisateur: 'user',
profession: 'DevOps',
reseaux: {} as JSON,
};
const fakeResponse: Profile = {
id: '123',
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
estVerifier: true,
secteur: 'Création',
bio: 'Bio',
cv: '',
projets: [],
apropos: 'À propos...',
...dto,
};
mockCollection.create.mockResolvedValue(fakeResponse);
repo.create(dto).subscribe((result) => {
expect(mockCollection.create).toHaveBeenCalledWith(dto);
expect(result).toEqual(fakeResponse);
done();
});
});
// ------------------------------------------
// 🔹 TEST : update()
// ------------------------------------------
it('devrait appeler pb.collection("profiles").update() avec ID et données partielle', (done) => {
const id = 'p002';
const data = { bio: 'Bio mise à jour' };
const updatedProfile: Profile = {
id,
created: '',
updated: '',
profession: 'Dev',
utilisateur: 'user_001',
estVerifier: false,
secteur: 'Tech',
reseaux: {} as JSON,
bio: data.bio!,
cv: '',
projets: [],
apropos: '',
};
mockCollection.update.mockResolvedValue(updatedProfile);
repo.update(id, data).subscribe((result) => {
expect(mockCollection.update).toHaveBeenCalledWith(id, data);
expect(result.bio).toBe('Bio mise à jour');
done();
});
});
});

View File

@@ -0,0 +1,141 @@
import { PbProjectRepository } from '@app/infrastructure/projects/pb-project.repository';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { Project } from '@app/domain/projects/project.model';
import PocketBase from 'pocketbase';
jest.mock('pocketbase'); // on mock le module entier
describe('PbProjectRepository', () => {
let repo: PbProjectRepository;
let mockCollection: any;
let mockPocketBase: any;
beforeEach(() => {
// Création dun faux client PocketBase avec des méthodes mockées
mockPocketBase = {
collection: jest.fn().mockReturnValue({
create: jest.fn(),
getFullList: jest.fn(),
getOne: jest.fn(),
update: jest.fn(),
}),
};
// 👇 On remplace la classe importée par notre version mockée
(PocketBase as jest.Mock).mockImplementation(() => mockPocketBase);
// on récupère une "collection" simulée
mockCollection = mockPocketBase.collection('projets');
// on instancie le repository à tester
repo = new PbProjectRepository();
});
// ------------------------------------------
// 🔹 TEST : create()
// ------------------------------------------
it('devrait appeler pb.collection("projets").create() avec le DTO', (done) => {
const dto: CreateProjectDto = {
nom: 'Projet test',
lien: 'https://exemple.com',
description: 'Test création',
utilisateur: 'user_001',
};
const fakeResponse: Project = {
id: '123',
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
fichier: [],
...dto,
};
mockCollection.create.mockResolvedValue(fakeResponse);
repo.create(dto).subscribe((result) => {
expect(mockCollection.create).toHaveBeenCalledWith(dto);
expect(result).toEqual(fakeResponse);
done();
});
});
// ------------------------------------------
// 🔹 TEST : list()
// ------------------------------------------
it('devrait appeler pb.collection("projets").getFullList() avec le filtre utilisateur', (done) => {
const userId = 'user_001';
const fakeProjects: Project[] = [
{
id: '1',
created: '',
updated: '',
nom: 'P1',
lien: '',
description: '',
fichier: [],
utilisateur: userId,
},
];
mockCollection.getFullList.mockResolvedValue(fakeProjects);
repo.list(userId).subscribe((result) => {
expect(mockCollection.getFullList).toHaveBeenCalledWith({
filter: `utilisateur='${userId}'`,
});
expect(result).toEqual(fakeProjects);
done();
});
});
// ------------------------------------------
// 🔹 TEST : get()
// ------------------------------------------
it('devrait appeler pb.collection("projets").getOne() avec le bon ID', (done) => {
const id = 'p123';
const fakeProject: Project = {
id,
created: '',
updated: '',
nom: 'Test',
lien: '',
description: '',
fichier: [],
utilisateur: 'user_001',
};
mockCollection.getOne.mockResolvedValue(fakeProject);
repo.get(id).subscribe((result) => {
expect(mockCollection.getOne).toHaveBeenCalledWith(id);
expect(result).toEqual(fakeProject);
done();
});
});
// ------------------------------------------
// 🔹 TEST : update()
// ------------------------------------------
it('devrait appeler pb.collection("projets").update() avec ID et data', (done) => {
const id = 'p001';
const data = { nom: 'Projet modifié' };
const updated: Project = {
id,
created: '',
updated: '',
nom: data.nom,
lien: '',
description: '',
fichier: [],
utilisateur: 'user_001',
};
mockCollection.update.mockResolvedValue(updated);
repo.update(id, data).subscribe((result) => {
expect(mockCollection.update).toHaveBeenCalledWith(id, data);
expect(result.nom).toBe('Projet modifié');
done();
});
});
});

View File

@@ -0,0 +1,59 @@
import { Project } from '@app/domain/projects/project.model';
export const fakeProjects: Project[] = [
{
id: '1',
created: '2025-01-12T09:32:00Z',
updated: '2025-01-15T11:45:00Z',
nom: 'Portfolio Web 3D',
lien: 'https://portfolio-3d.example.com',
description:
'Un site web interactif utilisant Three.js et Angular 17 pour présenter un portfolio 3D.',
fichier: ['portfolio-preview.png', 'scene-setup.glb'],
utilisateur: 'user_001',
},
{
id: '2',
created: '2025-02-03T08:22:00Z',
updated: '2025-02-10T10:05:00Z',
nom: 'Application Mobile de Gestion de Budget',
lien: 'https://budget-app.example.com',
description:
'Une application mobile multiplateforme développée avec Angular et Capacitor pour suivre les dépenses quotidiennes.',
fichier: ['budget-app-icon.png', 'screenshot-dashboard.png'],
utilisateur: 'user_002',
},
{
id: '3',
created: '2025-03-01T13:10:00Z',
updated: '2025-03-05T16:50:00Z',
nom: 'Système de Suivi des Présences',
lien: 'https://presence-system.example.com',
description:
'Un projet SaaS développé avec Angular et Spring Boot pour gérer la présence des étudiants dans les écoles.',
fichier: ['attendance-report.pdf'],
utilisateur: 'user_001',
},
{
id: '4',
created: '2025-04-20T12:15:00Z',
updated: '2025-04-22T09:40:00Z',
nom: 'Dashboard dAnalyse Crypto',
lien: 'https://crypto-dashboard.example.com',
description:
'Un tableau de bord en temps réel affichant les cours et tendances des crypto-monnaies avec RxJS et WebSocket.',
fichier: ['crypto-dashboard-chart.png'],
utilisateur: 'user_003',
},
{
id: '5',
created: '2025-05-11T14:00:00Z',
updated: '2025-05-12T10:20:00Z',
nom: 'API de Gestion de Projets',
lien: 'https://api-project-manager.example.com',
description:
'Backend RESTful pour la gestion des projets, conçu avec Node.js, Express et PostgreSQL.',
fichier: ['openapi-schema.yaml'],
utilisateur: 'user_001',
},
];

View File

@@ -23,7 +23,7 @@ describe('ProfileFacade', () => {
// attendre un peu le .subscribe
setTimeout(() => {
expect(facade.profiles().length).toBe(2);
expect(facade.loading()).toBe(false);
expect(facade.loading().isLoading).toBe(false);
done();
}, 0);
});

View File

@@ -0,0 +1,92 @@
import { TestBed } from '@angular/core/testing';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { fakeProjects } from '@app/testing/project.mock';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
describe('ProjectFacade', () => {
let facade: ProjectFacade;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ProjectFacade,
{ provide: PROJECT_REPOSITORY_TOKEN, useClass: FakeProjectRepository },
],
});
facade = TestBed.inject(ProjectFacade);
});
// ------------------------------------------
// 🔹 TEST : Chargement des projets (load)
// ------------------------------------------
it('devrait charger les projets pour un utilisateur', (done) => {
const userId = 'user_001';
facade.load(userId);
setTimeout(() => {
const projets = facade.projects();
expect(projets.length).toBe(fakeProjects.filter((p) => p.utilisateur === userId).length);
expect(facade.loading().isLoading).toBe(false);
expect(facade.error().hasError).toBe(false);
done();
}, 0);
});
// ------------------------------------------
// 🔹 TEST : Chargement dun projet unique
// ------------------------------------------
it('devrait charger un projet par son ID', (done) => {
const projectId = '1';
facade.loadOne(projectId);
setTimeout(() => {
const project = facade.project();
expect(project?.id).toBe(projectId);
expect(facade.error().hasError).toBe(false);
done();
}, 0);
});
// ------------------------------------------
// 🔹 TEST : Création dun nouveau projet
// ------------------------------------------
it('devrait créer un nouveau projet', (done) => {
const dto: CreateProjectDto = {
nom: 'Projet test création',
lien: 'https://test-create.example.com',
description: 'Projet ajouté via façade',
utilisateur: 'user_010',
};
facade.create(dto);
setTimeout(() => {
const dernier = facade.projects().find((p) => p.nom === dto.nom);
expect(dernier).toBeDefined();
expect(facade.project()?.nom).toBe(dto.nom);
expect(facade.loading().isLoading).toBe(false);
expect(facade.error().hasError).toBe(false);
done();
}, 0);
});
// ------------------------------------------
// 🔹 TEST : Mise à jour dun projet
// ------------------------------------------
it('devrait mettre à jour un projet existant', (done) => {
const projectId = '1';
const data = { nom: 'Projet mis à jour' };
facade.update(projectId, data);
setTimeout(() => {
const updated = facade.project();
expect(updated?.nom).toBe('Projet mis à jour');
expect(facade.error().hasError).toBe(false);
done();
}, 0);
});
});

View File

@@ -0,0 +1,66 @@
import { Project } from '@app/domain/projects/project.model';
import { ProjectPresenter } from '@app/ui/projects/project.presenter';
import { fakeProjects } from '@app/testing/project.mock';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
describe('ProjectPresenter', () => {
let presenter: ProjectPresenter;
beforeEach(() => {
presenter = new ProjectPresenter();
});
// ------------------------------------------
// 🔹 TEST 1 : Conversion dun seul projet
// ------------------------------------------
it('devrait convertir un projet en ProjectViewModel', () => {
const projet: Project = fakeProjects[0];
const viewModel: ProjectViewModel = presenter.toViewModel(projet);
expect(viewModel.id).toBe(projet.id);
expect(viewModel.nom).toBe(projet.nom);
expect(viewModel.lien).toBe(projet.lien);
expect(viewModel.utilisateur).toBe(projet.utilisateur);
expect(viewModel.description).toContain(projet.description);
});
// ------------------------------------------
// 🔹 TEST 2 : Conversion dune liste de projets
// ------------------------------------------
it('devrait convertir une liste de projets en liste de ViewModels', () => {
const viewModels = presenter.toViewModels(fakeProjects);
expect(Array.isArray(viewModels)).toBe(true);
expect(viewModels.length).toBe(fakeProjects.length);
// Vérifie que le premier élément est bien mappé
expect(viewModels[0].nom).toBe(fakeProjects[0].nom);
expect(viewModels[0].lien).toBe(fakeProjects[0].lien);
});
// ------------------------------------------
// 🔹 TEST 3 : Vérification dune conversion cohérente
// ------------------------------------------
it('devrait garder les mêmes données essentielles entre domaine et vue', () => {
const projet = fakeProjects[1];
const vm = presenter.toViewModel(projet);
expect(vm).toEqual(
expect.objectContaining({
id: projet.id,
nom: projet.nom,
utilisateur: projet.utilisateur,
description: projet.description,
})
);
});
// ------------------------------------------
// 🔹 TEST 4 : Conversion dune liste vide
// ------------------------------------------
it('devrait renvoyer un tableau vide si aucun projet nest fourni', () => {
const result = presenter.toViewModels([]);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,29 @@
import { CreateProjectUseCase } from '@app/usecase/projects/create-project.usecase';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository';
describe('CreateProjectUseCase', () => {
let useCase: CreateProjectUseCase;
let repo: FakeProjectRepository;
beforeEach(() => {
repo = new FakeProjectRepository();
useCase = new CreateProjectUseCase(repo);
});
it('doit creer un nouveau projet', (done) => {
const dto: CreateProjectDto = {
nom: 'New Project',
lien: 'https://example.com',
description: 'Un nouveau projet de test',
utilisateur: 'user_005',
};
useCase.execute(dto).subscribe((project) => {
expect(project.id).toBeDefined();
expect(project.nom).toBe(dto.nom);
expect(project.utilisateur).toBe(dto.utilisateur);
done();
});
});
});

View File

@@ -0,0 +1,20 @@
import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository';
import { GetProjectUseCase } from '@app/usecase/projects/get-project.usecase';
describe('GetProjectUseCase', () => {
let useCase: GetProjectUseCase;
let repo: FakeProjectRepository;
beforeEach(() => {
repo = new FakeProjectRepository();
useCase = new GetProjectUseCase(repo);
});
it("doit retourné le projet en fonction de l'id projet", (done) => {
const projectId = '1';
useCase.execute(projectId).subscribe((project) => {
expect(project.id).toBe(projectId);
done();
});
});
});

View File

@@ -0,0 +1,22 @@
import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository';
import { ListProjectUseCase } from '@app/usecase/projects/list-project.usecase';
import { fakeProjects } from '@app/testing/project.mock';
describe('ListProjectUseCase', () => {
let useCase: ListProjectUseCase;
let repo: FakeProjectRepository;
beforeEach(() => {
repo = new FakeProjectRepository();
useCase = new ListProjectUseCase(repo);
});
it("doit retourne la liste des projets d'un utilisateur en fonction de l'id", (done) => {
const userId = 'user_001';
useCase.execute(userId).subscribe((projects) => {
expect(projects.length).toBe(fakeProjects.filter((p) => p.utilisateur === userId).length);
expect(projects.every((p) => p.utilisateur === userId)).toBeTruthy();
done();
});
});
});

View File

@@ -0,0 +1,23 @@
import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository';
import { UpdateProjectUseCase } from '@app/usecase/projects/update-project.usecase';
describe('UpdateProjectUseCase', () => {
let useCase: UpdateProjectUseCase;
let repo: FakeProjectRepository;
beforeEach(() => {
repo = new FakeProjectRepository();
useCase = new UpdateProjectUseCase(repo);
});
it("doit modifier un projet en fonction de l'id projet", (done) => {
const projectId = '1';
const newData = { nom: 'Projet modifié' };
useCase.execute(projectId, newData).subscribe((updated) => {
expect(updated.nom).toBe('Projet modifié');
expect(updated.updated).toBeDefined();
done();
});
});
});

View File

@@ -5,30 +5,45 @@ import { Profile } from '@app/domain/profiles/profile.model';
import { Injectable } from '@angular/core';
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { LoaderAction } from '@app/domain/loader-action.util';
import { ActionType } from '@app/domain/action-type.util';
import { ErrorResponse } from '@app/domain/error-response.util';
@Injectable({
providedIn: 'root',
})
export class ProfileFacade {
private profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
private useCase = new ListProfilesUseCase(this.profileRepository);
readonly loading = signal(false);
private listUseCase = new ListProfilesUseCase(this.profileRepository);
readonly profiles = signal<ProfileViewModel[]>([]);
readonly error = signal<string | null>(null);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
load(search?: string) {
this.loading.set(true);
this.error.set(null);
this.handleError(ActionType.READ, false, null, true);
this.useCase.execute({ search }).subscribe({
this.listUseCase.execute({ search }).subscribe({
next: (profiles) => {
this.profiles.set(ProfilePresenter.toViewModels(profiles));
this.loading.set(false);
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.error.set('Failed to load profiles');
this.loading.set(false);
this.handleError(ActionType.READ, false, err, false);
},
});
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
}
}

View File

@@ -0,0 +1,116 @@
import { inject, Injectable, signal } from '@angular/core';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { CreateProjectUseCase } from '@app/usecase/projects/create-project.usecase';
import { ListProjectUseCase } from '@app/usecase/projects/list-project.usecase';
import { GetProjectUseCase } from '@app/usecase/projects/get-project.usecase';
import { UpdateProjectUseCase } from '@app/usecase/projects/update-project.usecase';
import { Project } from '@app/domain/projects/project.model';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectPresenter } from '@app/ui/projects/project.presenter';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { ErrorResponse } from '@app/domain/error-response.util';
import { ActionType } from '@app/domain/action-type.util';
import { LoaderAction } from '@app/domain/loader-action.util';
@Injectable({
providedIn: 'root',
})
export class ProjectFacade {
private readonly projectRepo = inject(PROJECT_REPOSITORY_TOKEN);
private readonly createUseCase = new CreateProjectUseCase(this.projectRepo);
private readonly listUseCase = new ListProjectUseCase(this.projectRepo);
private readonly getUseCase = new GetProjectUseCase(this.projectRepo);
private readonly UpdateUseCase = new UpdateProjectUseCase(this.projectRepo);
readonly projects = signal<ProjectViewModel[]>([]);
readonly project = signal<ProjectViewModel>({} as ProjectViewModel);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
private readonly projectPresenter = new ProjectPresenter();
load(userId: string) {
this.loading.set({
action: ActionType.READ,
isLoading: true,
});
this.listUseCase.execute(userId).subscribe({
next: (projects: Project[]) => {
this.projects.set(this.projectPresenter.toViewModels(projects));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
complete: () => {},
});
}
loadOne(projectId: string) {
this.loading.set({
action: ActionType.READ,
isLoading: true,
});
this.getUseCase.execute(projectId).subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
create(projectDto: CreateProjectDto) {
this.loading.set({
action: ActionType.CREATE,
isLoading: true,
});
this.createUseCase.execute(projectDto).subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.projects.update((prev) => [...prev, this.projectPresenter.toViewModel(project)]);
this.handleError(ActionType.CREATE, false, null, false);
},
error: (err) => {
this.handleError(ActionType.CREATE, false, err, false);
},
});
}
update(userId: string, data: any) {
this.loading.set({
action: ActionType.UPDATE,
isLoading: true,
});
this.UpdateUseCase.execute(userId, data).subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.handleError(ActionType.UPDATE, false, null, false);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
}
}

View File

@@ -0,0 +1,10 @@
export interface ProjectViewModel {
id: string;
created: string;
updated: string;
nom: string;
lien: string;
description: string;
fichier: string[];
utilisateur: string;
}

View File

@@ -0,0 +1,23 @@
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,
created: project.created,
updated: project.updated,
nom: project.nom,
lien: project.lien,
description: project.description,
fichier: project.fichier,
utilisateur: project.utilisateur,
};
}
toViewModels(projects: Project[]): ProjectViewModel[] {
return projects.map(this.toViewModel);
}
}

View File

@@ -0,0 +1,10 @@
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
export class CreateProjectUseCase {
constructor(private readonly repo: ProjectRepository) {}
execute(projectDto: CreateProjectDto) {
return this.repo.create(projectDto);
}
}

View File

@@ -0,0 +1,11 @@
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { Project } from '@app/domain/projects/project.model';
import { Observable } from 'rxjs';
export class GetProjectUseCase {
constructor(private readonly repo: ProjectRepository) {}
execute(projectId: string): Observable<Project> {
return this.repo.get(projectId);
}
}

View File

@@ -0,0 +1,11 @@
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { Project } from '@app/domain/projects/project.model';
import { Observable } from 'rxjs';
export class ListProjectUseCase {
constructor(private readonly repo: ProjectRepository) {}
execute(userId: string): Observable<Project[]> {
return this.repo.list(userId);
}
}

View File

@@ -0,0 +1,11 @@
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { Project } from '@app/domain/projects/project.model';
import { Observable } from 'rxjs';
export class UpdateProjectUseCase {
constructor(private readonly repo: ProjectRepository) {}
execute(userId: string, data: any): Observable<Project> {
return this.repo.update(userId, data);
}
}

View File

@@ -2,6 +2,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowJs": true,
"outDir": "./out-tsc/spec",
"types": [
"jest","node"