From 7a28a0fcb468c3548169e4b221ccc81b22ab8a21 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:50:25 +0200 Subject: [PATCH 01/23] create app-button --- .../is-login-home-page.component.html | 31 ++--- .../is-login-home-page.component.ts | 59 ++++++++-- .../app/core/sidebar/sidebar.component.html | 53 ++++++--- .../src/app/core/sidebar/sidebar.component.ts | 24 +++- .../danger-zone/danger-zone.component.html | 8 +- .../danger-zone/danger-zone.component.ts | 6 +- .../delete-confirmation-popup.component.html | 22 ++-- .../delete-confirmation-popup.component.ts | 24 +++- .../archive-event-popup.component.html | 12 +- .../archive-event-popup.component.ts | 5 +- .../delete-event-popup.component.html | 109 ------------------ .../delete-event-popup.component.scss | 0 .../delete-event-popup.component.spec.ts | 23 ---- .../delete-event-popup.component.ts | 66 ----------- .../general-info-event.component.html | 12 +- .../general-info-event.component.ts | 5 +- .../information-event.component.html | 16 +-- .../information-event.component.ts | 8 +- .../generic-filter-popup.component.html | 47 ++++---- .../generic-filter-popup.component.ts | 30 ++++- .../navbar-admin-page.component.html | 12 +- .../navbar-admin-page.component.ts | 8 +- .../session-review-import.component.html | 7 +- .../session-review-import.component.ts | 4 +- .../session-schedule-import.component.html | 9 +- .../session-schedule-import.component.ts | 4 +- .../sidebar-admin-page.component.html | 4 +- .../sidebar-admin-page.component.ts | 6 +- .../delete-team-popup.component.html | 104 ----------------- .../delete-team-popup.component.scss | 0 .../delete-team-popup.component.spec.ts | 23 ---- .../delete-team-popup.component.ts | 58 ---------- .../delete-popup/delete-popup.component.html | 26 +++-- .../delete-popup/delete-popup.component.ts | 5 +- .../role-popup/role-popup.component.html | 17 ++- .../role-popup/role-popup.component.ts | 4 +- .../members-card/members-card.component.html | 17 +-- .../members-card/members-card.component.ts | 2 + .../calendar-event-page.component.html | 18 +-- .../calendar-event-page.component.ts | 4 +- .../customize-event.component.html | 13 +-- .../customize-event.component.ts | 2 + .../session-detail-page.component.html | 32 +++-- .../session-detail-page.component.ts | 8 +- .../session-list-page.component.html | 43 ++++--- .../session-list-page.component.ts | 8 +- .../speaker-list-page.component.html | 41 +++---- .../speaker-list-page.component.ts | 8 +- .../create-team-page.component.html | 6 +- .../create-team-page.component.ts | 4 +- .../list-event-page.component.html | 19 ++- .../list-event-page.component.ts | 6 +- .../setting-team-general-page.component.html | 6 +- .../setting-team-general-page.component.ts | 4 +- .../setting-team-members-page.component.html | 6 +- .../setting-team-members-page.component.ts | 4 +- .../navbar-profile.component.html | 16 ++- .../navbar-profile.component.ts | 6 +- .../profile-sidebar.component.html | 25 ++-- .../profile-sidebar.component.ts | 25 +++- .../social-networks.component.html | 24 +--- .../social-networks.component.ts | 14 --- .../feature/profile/profile.component.html | 6 +- .../app/feature/profile/profile.component.ts | 4 +- .../button-green-actions.component.html | 16 --- .../button-green-actions.component.scss | 0 .../button-green-actions.component.spec.ts | 46 -------- .../button-green-actions.component.ts | 35 ------ .../button-grey/button-grey.component.html | 17 --- .../button-grey/button-grey.component.scss | 0 .../button-with-icon.component.spec.ts | 97 ---------------- .../button-with-icon.component.ts | 66 ----------- .../button.component.html} | 8 +- .../button.component.scss} | 0 .../button.component.spec.ts} | 12 +- .../button.component.ts} | 30 +++-- 76 files changed, 505 insertions(+), 1044 deletions(-) delete mode 100644 front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.html delete mode 100644 front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.spec.ts delete mode 100644 front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.ts delete mode 100644 front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.html delete mode 100644 front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.spec.ts delete mode 100644 front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.ts delete mode 100644 front/src/app/shared/button-green-actions/button-green-actions.component.html delete mode 100644 front/src/app/shared/button-green-actions/button-green-actions.component.scss delete mode 100644 front/src/app/shared/button-green-actions/button-green-actions.component.spec.ts delete mode 100644 front/src/app/shared/button-green-actions/button-green-actions.component.ts delete mode 100644 front/src/app/shared/button-grey/button-grey.component.html delete mode 100644 front/src/app/shared/button-grey/button-grey.component.scss delete mode 100644 front/src/app/shared/button-with-icon/button-with-icon.component.spec.ts delete mode 100644 front/src/app/shared/button-with-icon/button-with-icon.component.ts rename front/src/app/shared/{button-with-icon/button-with-icon.component.html => button/button.component.html} (86%) rename front/src/app/shared/{button-with-icon/button-with-icon.component.scss => button/button.component.scss} (100%) rename front/src/app/shared/{button-grey/button-grey.component.spec.ts => button/button.component.spec.ts} (52%) rename front/src/app/shared/{button-grey/button-grey.component.ts => button/button.component.ts} (68%) diff --git a/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.html b/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.html index 179ca4f9..f6f2ede0 100644 --- a/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.html +++ b/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.html @@ -6,23 +6,26 @@

- + - + diff --git a/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.ts b/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.ts index e45a2594..19776312 100644 --- a/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.ts +++ b/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.ts @@ -11,24 +11,25 @@ import { EventTeamField } from '../../../feature/admin-management/components/event/event-team-card/interface/event-team-field'; import { Event } from '../../../feature/admin-management/type/event/event'; +import { ButtonComponent } from '../../../shared/button/button.component'; @Component({ selector: 'app-is-login-home-page', standalone: true, - imports: [CommonModule, EventTeamCardComponent], + imports: [CommonModule, EventTeamCardComponent, ButtonComponent], templateUrl: './is-login-home-page.component.html', styleUrl: './is-login-home-page.component.scss' }) export class IsLoginHomePageComponent { - activeTab = signal<'currents' | 'passed'>('currents'); - userEvents = signal([]); - isLoading = signal(true); - error = signal(null); + readonly activeTab = signal<'currents' | 'passed'>('currents'); + readonly userEvents = signal([]); + readonly isLoading = signal(true); + readonly error = signal(null); - private eventService = inject(EventService); - private eventStatusService = inject(EventStatusService); + private readonly eventService = inject(EventService); + private readonly eventStatusService = inject(EventStatusService); - eventCounts = computed(() => { + readonly eventCounts = computed(() => { let currentCount = 0; let passedCount = 0; @@ -44,7 +45,7 @@ export class IsLoginHomePageComponent { return { current: currentCount, passed: passedCount }; }); - private filteredAndSortedEvents = computed(() => { + private readonly filteredAndSortedEvents = computed(() => { const filteredEvents = this.eventStatusService.filterEventsByStatus( this.userEvents(), this.activeTab() === 'passed' @@ -53,12 +54,13 @@ export class IsLoginHomePageComponent { return this.sortEventsByDate(filteredEvents); }); - displayedEvents = computed(() => + readonly displayedEvents = computed(() => this.transformEventsToFields(this.filteredAndSortedEvents()) ); - hasEvents = computed(() => this.displayedEvents().length > 0); - emptyStateMessage = computed(() => { + readonly hasEvents = computed(() => this.displayedEvents().length > 0); + + readonly emptyStateMessage = computed(() => { const counts = this.eventCounts(); if (this.activeTab() === 'currents') { return counts.current === 0 @@ -71,6 +73,14 @@ export class IsLoginHomePageComponent { } }); + readonly currentsTabClasses = computed(() => + this.getTabClasses('currents') + ); + + readonly passedTabClasses = computed(() => + this.getTabClasses('passed') + ); + constructor() { effect(() => { if (this.userEvents().length === 0 && !this.isLoading()) { @@ -80,6 +90,31 @@ export class IsLoginHomePageComponent { this.loadUserEvents(); } + private getTabClasses(tab: 'currents' | 'passed'): string { + const baseClasses = 'flex items-center rounded-md py-0.5 px-12 text-sm cursor-pointer transition-all'; + const activeClasses = this.activeTab() === tab + ? 'bg-white text-primaryColor shadow-sm' + : 'text-secondary hover:text-white'; + + return `${baseClasses} ${activeClasses}`.trim(); + } + + getCurrentsTabClasses(): string { + return this.currentsTabClasses(); + } + + getPassedTabClasses(): string { + return this.passedTabClasses(); + } + + getCurrentsTabHandler(): () => void { + return () => this.setActiveTab('currents'); + } + + getPassedTabHandler(): () => void { + return () => this.setActiveTab('passed'); + } + private loadUserEvents(): void { this.isLoading.set(true); this.error.set(null); diff --git a/front/src/app/core/sidebar/sidebar.component.html b/front/src/app/core/sidebar/sidebar.component.html index dba7a9c5..7018ac66 100644 --- a/front/src/app/core/sidebar/sidebar.component.html +++ b/front/src/app/core/sidebar/sidebar.component.html @@ -15,27 +15,34 @@
profile + class="w-12 h-12 rounded-full border-none" referrerpolicy="no-referrer">

{{ userDataService.displayName() }}

{{ userDataService.userName() ? userDataService.userEmail() : '' }}

- + diff --git a/front/src/app/core/sidebar/sidebar.component.ts b/front/src/app/core/sidebar/sidebar.component.ts index 5c9aced4..51d06a48 100644 --- a/front/src/app/core/sidebar/sidebar.component.ts +++ b/front/src/app/core/sidebar/sidebar.component.ts @@ -3,15 +3,15 @@ import { NavigationEnd, Router } from '@angular/router'; import { filter, map } from 'rxjs'; import { UserDataService } from '../services/user-services/user-data.service'; import { AuthService } from '../login/services/auth.service'; -import {ButtonWithIconComponent} from '../../shared/button-with-icon/button-with-icon.component'; -import {CommonModule} from '@angular/common'; -import {TeamService} from '../../feature/admin-management/services/team/team.service'; -import {takeUntilDestroyed, toSignal} from '@angular/core/rxjs-interop'; +import { ButtonComponent } from '../../shared/button/button.component'; +import { CommonModule } from '@angular/common'; +import { TeamService } from '../../feature/admin-management/services/team/team.service'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-sidebar', standalone: true, - imports: [ButtonWithIconComponent, CommonModule], + imports: [ButtonComponent, CommonModule], templateUrl: './sidebar.component.html', styleUrl: './sidebar.component.scss' }) @@ -65,13 +65,25 @@ export class SidebarComponent implements OnInit { this.teamService.loadUserTeams(); } + getSidebarButtonClasses(additionalClasses: string = ''): string { + const baseClasses = 'group flex items-center gap-x-3 w-full text-left p-2 leading-6 transition-colors hover:bg-gray-100 rounded-md text-left hover:text-gray-900'; + + return additionalClasses + ? `${baseClasses} ${additionalClasses}`.trim() + : baseClasses; + } + + getCloseSidebarHandler(): () => void { + return () => this.closeSidebar(); + } + closeSidebar(): void { this.userDataService.toggleSidebar(false); } logout(): void { this.authService.logout(); - this.closeSidebar(); + this.getCloseSidebarHandler(); } navigateTo(path: string): void { diff --git a/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.html b/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.html index 153eb1e5..518f6324 100644 --- a/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.html +++ b/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.html @@ -15,11 +15,11 @@

{{ action.title }}

[innerHTML]="action.description" [id]="action.id + '-description'">

- + diff --git a/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.ts b/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.ts index b9ac613f..04a01aae 100644 --- a/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.ts +++ b/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.ts @@ -1,11 +1,13 @@ import { Component, input, output, computed } from '@angular/core'; import { DangerZoneAction, DangerZoneConfig } from '../../type/components/danger-zone'; import { NgClass } from '@angular/common'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-danger-zone', imports: [ - NgClass + NgClass, + ButtonComponent ], templateUrl: './danger-zone.component.html', styleUrl: './danger-zone.component.scss' @@ -66,7 +68,7 @@ export class DangerZoneComponent { } getButtonClass(): string { - const baseClasses = 'px-4 py-1 rounded-md font-medium flex-shrink-0 border cursor-pointer flex items-center transition-colors duration-200'; + const baseClasses = 'px-6 py-2 rounded-md font-medium flex-shrink-0 border cursor-pointer flex items-center transition-colors duration-200'; return `${baseClasses} bg-white hover:bg-red-50 text-red-600 border-red-300 hover:border-red-400`; } diff --git a/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.html b/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.html index 47890056..18c5fde5 100644 --- a/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.html +++ b/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.html @@ -20,7 +20,7 @@ class="text-xl font-medium"> {{ title() }}

- +
@@ -69,32 +69,32 @@

Warning: This action cannot be undone

diff --git a/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.ts b/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.ts index fe7e20da..b26a54b6 100644 --- a/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.ts +++ b/front/src/app/feature/admin-management/components/delete-confirmation-popup/delete-confirmation-popup.component.ts @@ -1,11 +1,13 @@ import { Component, input, output, computed, signal } from '@angular/core'; import { DeleteConfirmationConfig } from '../../type/components/delete-confirmation'; import { FormsModule } from '@angular/forms'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-delete-confirmation-popup', imports: [ - FormsModule + FormsModule, + ButtonComponent ], templateUrl: './delete-confirmation-popup.component.html', styleUrl: './delete-confirmation-popup.component.scss' @@ -126,4 +128,24 @@ export class DeleteConfirmationPopupComponent { private resetForm(): void { this.userConfirmationText.set(''); } + + getCancelButtonClass(): string { + const baseClasses = 'px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-100 focus:outline-none'; + return `${baseClasses} text-lg`; + } + + readonly deleteButtonFocusClasses = computed(() => { + return this.isDeleteButtonDisabled() + ? 'focus:ring-red-300 focus:ring-opacity-50' + : 'focus:ring-red-500 focus:ring-offset-2 hover:bg-red-700'; + }); + + readonly deleteButtonClasses = computed(() => { + const baseClasses = 'px-4 py-2 bg-red-600 text-white rounded-md flex items-center text-lg transition-colors duration-200 focus:outline-none focus:ring-2'; + const focusClasses = this.deleteButtonFocusClasses(); + const interactionClasses = 'hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600'; + + return `${baseClasses} ${focusClasses} ${interactionClasses}`; + }); + } diff --git a/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.html b/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.html index 1fd70a04..237c60e1 100644 --- a/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.html +++ b/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.html @@ -42,23 +42,23 @@
- + - +
diff --git a/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.ts b/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.ts index af1e03bc..e41eda3f 100644 --- a/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.ts +++ b/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.ts @@ -1,8 +1,11 @@ import { Component, input, output } from '@angular/core'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-archive-event-popup', - imports: [], + imports: [ + ButtonComponent + ], templateUrl: './archive-event-popup.component.html', styleUrl: './archive-event-popup.component.scss' }) diff --git a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.html b/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.html deleted file mode 100644 index abd31bf5..00000000 --- a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.html +++ /dev/null @@ -1,109 +0,0 @@ -@if (isOpen()) { - -} diff --git a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.scss b/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.spec.ts b/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.spec.ts deleted file mode 100644 index 3ea97ef2..00000000 --- a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DeleteTeamPopupComponent } from './delete-team-popup.component'; - -describe('DeleteTeamPopupComponent', () => { - let component: DeleteTeamPopupComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeleteTeamPopupComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(DeleteTeamPopupComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.ts b/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.ts deleted file mode 100644 index 368499b5..00000000 --- a/front/src/app/feature/admin-management/components/event/delete-event-popup/delete-event-popup.component.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Component, input, output, signal, computed } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; - -@Component({ - selector: 'app-delete-event-popup', - standalone: true, - imports: [CommonModule, FormsModule], - templateUrl: './delete-event-popup.component.html', - styleUrl: './delete-event-popup.component.scss' -}) -export class DeleteEventPopupComponent { - eventName = input(''); - isOpen = input(false); - isDeleting = input(false); - - confirm = output(); - cancel = output(); - - confirmationText = signal(''); - - isConfirmationValid = computed(() => - this.confirmationText().trim() === 'DELETE' - ); - - isDeleteButtonDisabled = computed(() => - this.isDeleting() || !this.isConfirmationValid() - ); - - onCancel(event: MouseEvent): void { - event.preventDefault(); - this.resetForm(); - this.cancel.emit(); - } - - onConfirm(event: MouseEvent): void { - event.preventDefault(); - - if (this.isConfirmationValid() && !this.isDeleting()) { - this.confirm.emit(); - } - } - - onBackdropClick(event: MouseEvent): void { - if ((event.target as HTMLElement).id === 'delete-event-modal') { - this.resetForm(); - this.cancel.emit(); - } - } - - onKeyDown(event: KeyboardEvent): void { - if (event.key === 'Escape') { - this.resetForm(); - this.cancel.emit(); - } - } - - onConfirmationTextChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.confirmationText.set(target.value); - } - - private resetForm(): void { - this.confirmationText.set(''); - } -} diff --git a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html index 114a7d62..e76db760 100644 --- a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html +++ b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html @@ -149,19 +149,19 @@
- + [customClass]="'text-gray-700 hover:text-gray-700 px-6 py-2 rounded-md mr-5 text-sm inline-flex items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm'"> Go back - + - {{ submitButtonText() }} - +
} diff --git a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts index 1b0d561d..e85cd28c 100644 --- a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts +++ b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts @@ -16,9 +16,7 @@ import 'moment-timezone'; import { BehaviorSubject, debounceTime, Subject } from 'rxjs'; import 'moment-timezone'; import {EventDTO} from '../../../type/event/eventDTO'; -import {ButtonGreenActionsComponent} from '../../../../../shared/button-green-actions/button-green-actions.component'; import {FieldComponent} from '../../../../../shared/input/field.component'; -import {ButtonGreyComponent} from '../../../../../shared/button-grey/button-grey.component'; import {TimezoneOption} from '../../../type/event/time-zone-option'; import {Team} from '../../../type/team/team'; import {TeamService} from '../../../services/team/team.service'; @@ -30,11 +28,12 @@ import {AutoSaveService} from '../../services/auto-save.service'; import {EventService} from '../../../services/event/event.service'; import {MatSnackBar} from '@angular/material/snack-bar'; import {SaveIndicatorComponent} from '../../../../../core/save-indicator/save-indicator.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-general-info-event', standalone: true, - imports: [CommonModule, ButtonGreenActionsComponent, FieldComponent, ReactiveFormsModule, ButtonGreyComponent, FormsModule, SaveIndicatorComponent], + imports: [CommonModule, FieldComponent, ReactiveFormsModule, FormsModule, SaveIndicatorComponent, ButtonComponent], templateUrl: './general-info-event.component.html', styleUrl: './general-info-event.component.scss' }) diff --git a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html index 3875bf2e..fe2ff6ad 100644 --- a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html +++ b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html @@ -92,20 +92,20 @@ @if (showNavigationButtons) {
- + [customClass]="'text-gray-700 hover:text-gray-700 px-6 py-2 rounded-md mr-5 text-sm inline-flex items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm'"> Go back - + - + [attr.aria-label]="'Continue to next step'" + [customClass]="'flex items-center gap-2 bg-action hover:bg-action-hover disabled:bg-gray-400 disabled:cursor-not-allowed cursor-pointer text-white text-sm px-3 rounded-md transition-colors'" + materialIcon="arrow_forward"> Continue - +
} diff --git a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.ts b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.ts index 5dea3020..6099f9d4 100644 --- a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.ts +++ b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.ts @@ -1,8 +1,6 @@ import {Component, input, OnDestroy, OnInit, output, signal} from '@angular/core'; import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; import {Subject, Subscription} from 'rxjs'; -import {ButtonGreenActionsComponent} from '../../../../../shared/button-green-actions/button-green-actions.component'; -import {ButtonGreyComponent} from '../../../../../shared/button-grey/button-grey.component'; import {FieldComponent} from '../../../../../shared/input/field.component'; import {EventDTO} from '../../../type/event/eventDTO'; import {EventDataService} from '../../../services/event/event-data.service'; @@ -13,17 +11,17 @@ import {AutoSaveService} from '../../services/auto-save.service'; import {EventService} from '../../../services/event/event.service'; import {MatSnackBar} from '@angular/material/snack-bar'; import {SaveIndicatorComponent} from '../../../../../core/save-indicator/save-indicator.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-information-event', standalone: true, imports: [ - ButtonGreenActionsComponent, - ButtonGreyComponent, ReactiveFormsModule, FormsModule, FieldComponent, - SaveIndicatorComponent + SaveIndicatorComponent, + ButtonComponent ], templateUrl: './information-event.component.html', styleUrl: './information-event.component.scss' diff --git a/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.html b/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.html index dc7b3aa5..20cb5e1a 100644 --- a/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.html +++ b/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.html @@ -25,12 +25,13 @@

@for (option of button.options; track option.value) { - + }
@@ -44,22 +45,23 @@

{{ dropdown.label }}
- + @if (isDropdownOpen(dropdown.id)) {
- + [buttonHandler]="getResetHandler()" + [customClass]="'text-gray-600 hover:text-gray-600 bg-white hover:bg-gray-100 border border-gray-300 shadow-sm px-3 mr-5'" + [ariaLabel]="'Reset all filters'"> Reset - + - + [buttonHandler]="getApplyHandler()" + [customClass]="'px-3 py-1 text-sm border border-transparent bg-action hover:bg-action-hover text-white'" + [ariaLabel]="'Apply selected filters'"> Apply Filters - +
diff --git a/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.ts b/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.ts index 6e0c7399..75bb2814 100644 --- a/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.ts +++ b/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.ts @@ -5,20 +5,17 @@ import { OnInit, computed, effect, - inject, input, output, signal } from '@angular/core'; import { DropdownConfig, FilterConfig, FilterOption } from '../../../type/components/filter.type'; -import { ButtonGreyComponent } from '../../../../../shared/button-grey/button-grey.component'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-generic-filter-popup', imports: [ - ButtonGreyComponent, - ButtonGreenActionsComponent + ButtonComponent ], templateUrl: './generic-filter-popup.component.html', styleUrl: './generic-filter-popup.component.scss' @@ -234,6 +231,29 @@ export class GenericFilterPopupComponent implements OnInit, OnDestroy { this.filtersReset.emit(); } + getButtonClickHandler(buttonId: string, value: boolean | null): () => void { + return () => this.onButtonChange(buttonId, value); + } + + getDropdownToggleHandler(dropdownId: string): () => void { + return () => this.toggleDropdown(dropdownId); + } + + getResetHandler(): () => void { + return () => this.onReset(); + } + + getApplyHandler(): () => void { + return () => this.onApply(); + } + + getDropdownButtonClasses(dropdownId: string): string { + const baseClasses = 'w-full bg-white border border-gray-300 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 justify-between'; + const activeClasses = this.isDropdownOpen(dropdownId) ? 'border-blue-500' : ''; + + return `${baseClasses} ${activeClasses}`.trim(); + } + onClose(): void { this._openDropdowns.set(new Set()); this.popupClosed.emit(); diff --git a/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.html b/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.html index 90a0be5c..ae1093be 100644 --- a/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.html +++ b/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.html @@ -1,15 +1,15 @@
@for (button of visibleLeftButtons(); track button.id) { - + [customClass]="'text-base'" + [ngClass]="button.cssClass"> {{ button.label }} - + }
@@ -20,13 +20,13 @@ } @if (rightButtonConfig()) { - + }
diff --git a/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.ts b/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.ts index a4758b5a..68b3b1ea 100644 --- a/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.ts +++ b/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.ts @@ -1,17 +1,17 @@ -import { Component, OnDestroy, OnInit, computed, effect, inject, input, signal } from '@angular/core'; +import { Component, OnInit, computed, effect, inject, input, signal } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; import { UserRoleService } from '../../services/team/user-role.service'; -import { ButtonGreyComponent } from '../../../../shared/button-grey/button-grey.component'; import { NgClass } from '@angular/common'; import { NavbarButton, NavbarConfig } from '../../type/components/navbar-config'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-navbar-admin-page', standalone: true, imports: [ - ButtonGreyComponent, - NgClass + NgClass, + ButtonComponent ], templateUrl: './navbar-admin-page.component.html', styleUrl: './navbar-admin-page.component.scss' diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html index 23cd325c..3d0f89f2 100644 --- a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html +++ b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html @@ -53,14 +53,13 @@
- + [customClass]="'text-gray-600 hover:text-gray-600 flex-shrink-0 rounded-md text-base items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm'"> {{ isImporting ? 'Importing...' : 'Import Review' }} - + diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts index a1722b76..6136bf27 100644 --- a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts @@ -1,12 +1,12 @@ import {Component} from '@angular/core'; import {SessionImportData} from '../../../type/session/session'; -import {ButtonGreyComponent} from '../../../../../shared/button-grey/button-grey.component'; import {BaseImportComponent} from '../../base-import/base-import.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-session-review-import', imports: [ - ButtonGreyComponent + ButtonComponent ], templateUrl: './session-review-import.component.html', styleUrl: './session-review-import.component.scss' diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html index 91830543..21150f2d 100644 --- a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html +++ b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html @@ -53,16 +53,13 @@ - + [customClass]="'text-gray-600 hover:text-gray-600 flex-shrink-0 rounded-md text-base items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm'"> {{ isImporting ? 'Importing...' : 'Import Schedule' }} - - - + @if (importResult) { diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts index 75dcb0db..f94deb8a 100644 --- a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts @@ -1,5 +1,4 @@ import {Component} from '@angular/core'; -import {ButtonGreyComponent} from '../../../../../shared/button-grey/button-grey.component'; import { ScheduleJsonData, ScheduleSessionData, SessionScheduleImportDataDTO, @@ -9,11 +8,12 @@ import {ImportResult} from '../../../type/session/session'; import {EventDTO} from '../../../type/event/eventDTO'; import {EventService} from '../../../services/event/event.service'; import {EventDataService} from '../../../services/event/event-data.service'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-session-schedule-import', imports: [ - ButtonGreyComponent + ButtonComponent ], templateUrl: './session-schedule-import.component.html', styleUrl: './session-schedule-import.component.scss' diff --git a/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.html b/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.html index b8de2dd3..10ff867a 100644 --- a/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.html +++ b/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.html @@ -1,7 +1,7 @@ diff --git a/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.ts b/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.ts index ca0f00cc..ac1dfd31 100644 --- a/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.ts +++ b/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.ts @@ -3,14 +3,14 @@ import { SidebarButton, SidebarConfig } from '../../type/components/sidebar-conf import { Subject, takeUntil, filter } from 'rxjs'; import { NavigationEnd, Router } from '@angular/router'; import { NgClass } from '@angular/common'; -import { ButtonWithIconComponent } from '../../../../shared/button-with-icon/button-with-icon.component'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-sidebar-admin-page', standalone: true, imports: [ NgClass, - ButtonWithIconComponent + ButtonComponent ], templateUrl: './sidebar-admin-page.component.html', styleUrl: './sidebar-admin-page.component.scss' @@ -96,7 +96,7 @@ export class SidebarAdminPageComponent implements OnInit, OnDestroy { } private calculateButtonClasses(button: SidebarButton, isActive: boolean): string { - const baseClasses = button.cssClass || ''; + const baseClasses = "const baseClasses = 'group flex items-center gap-x-3 w-full text-left p-2 leading-6 transition-colors hover:bg-gray-100'"; const activeClasses = isActive ? 'bg-gray-100' : ''; return `${baseClasses} ${activeClasses}`.trim(); diff --git a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.html b/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.html deleted file mode 100644 index 0df65951..00000000 --- a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.html +++ /dev/null @@ -1,104 +0,0 @@ -@if (isOpen()) { - -} diff --git a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.scss b/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.spec.ts b/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.spec.ts deleted file mode 100644 index 3ea97ef2..00000000 --- a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DeleteTeamPopupComponent } from './delete-team-popup.component'; - -describe('DeleteTeamPopupComponent', () => { - let component: DeleteTeamPopupComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeleteTeamPopupComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(DeleteTeamPopupComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.ts b/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.ts deleted file mode 100644 index 33bf5ddb..00000000 --- a/front/src/app/feature/admin-management/components/team/delete-team-popup/delete-team-popup.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Component, computed, input, output } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; - -@Component({ - selector: 'app-delete-team-popup', - standalone: true, - imports: [CommonModule, FormsModule], - templateUrl: './delete-team-popup.component.html', - styleUrl: './delete-team-popup.component.scss' -}) -export class DeleteTeamPopupComponent { - teamName = input(''); - isOpen = input(false); - isDeleting = input(false); - - confirm = output(); - cancel = output(); - confirmationText: string = ''; - - isConfirmationValid = computed(() => - this.confirmationText.trim() === 'DELETE' - ); - - isDeleteButtonDisabled = computed(() => - this.isDeleting() || !this.isConfirmationValid() - ); - - onCancel(event: MouseEvent): void { - event.preventDefault(); - this.resetForm(); - this.cancel.emit(); - } - - onConfirm(event: MouseEvent): void { - event.preventDefault(); - - if (this.isConfirmationValid() && !this.isDeleting()) { - this.confirm.emit(); - } - } - - onBackdropClick(event: MouseEvent): void { - if ((event.target as HTMLElement).id === 'delete-team-modal') { - this.resetForm(); - this.cancel.emit(); - } - } - - private resetForm(): void { - this.confirmationText = ''; - } - - onConfirmationTextChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.confirmationText = target.value; - } -} diff --git a/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.html b/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.html index adcb60bd..1001a264 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.html +++ b/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.html @@ -10,13 +10,16 @@ (click)="$event.stopPropagation()">

Confirm Member Removal

- +
@@ -29,25 +32,28 @@

Confirm Member Removal
- - +

diff --git a/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.ts b/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.ts index 30cec33c..c8e441f7 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.ts +++ b/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.ts @@ -1,10 +1,13 @@ import { Component, computed, input, output } from '@angular/core'; import { TeamMember } from '../../../../../type/team/team-member'; +import {ButtonComponent} from '../../../../../../../shared/button/button.component'; @Component({ selector: 'app-delete-popup', standalone: true, - imports: [], + imports: [ + ButtonComponent + ], templateUrl: './delete-popup.component.html', styleUrl: './delete-popup.component.scss' }) diff --git a/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.html b/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.html index c56884ae..3288b7d1 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.html +++ b/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.html @@ -43,14 +43,19 @@

Change the role of {{ member().displayName || 't
- - +
diff --git a/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.ts b/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.ts index b166c74c..dc848464 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.ts +++ b/front/src/app/feature/admin-management/components/team/members-card/components/role-popup/role-popup.component.ts @@ -1,13 +1,15 @@ import { Component, input, output, effect } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { TeamMember } from '../../../../../type/team/team-member'; +import {ButtonComponent} from '../../../../../../../shared/button/button.component'; @Component({ selector: 'app-role-popup', standalone: true, imports: [ ReactiveFormsModule, - FormsModule + FormsModule, + ButtonComponent ], templateUrl: './role-popup.component.html', styleUrl: './role-popup.component.scss' diff --git a/front/src/app/feature/admin-management/components/team/members-card/members-card.component.html b/front/src/app/feature/admin-management/components/team/members-card/members-card.component.html index 0e3395f5..b7ab4314 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/members-card.component.html +++ b/front/src/app/feature/admin-management/components/team/members-card/members-card.component.html @@ -18,22 +18,23 @@

@if (canManageRoles()) { - - + [customClass]="'bg-white text-sm ml-0 sm:ml-1 hover:bg-gray-100 text-red-600 px-4 py-1 rounded-md font-medium flex-shrink-0 border border-gray-400 cursor-pointer flex items-center'"> + Remove + }
diff --git a/front/src/app/feature/admin-management/components/team/members-card/members-card.component.ts b/front/src/app/feature/admin-management/components/team/members-card/members-card.component.ts index ea758d29..c57e0141 100644 --- a/front/src/app/feature/admin-management/components/team/members-card/members-card.component.ts +++ b/front/src/app/feature/admin-management/components/team/members-card/members-card.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from '@angular/common'; import { RolePopupComponent } from './components/role-popup/role-popup.component'; import { DeletePopupComponent } from './components/delete-popup/delete-popup.component'; import { TeamMember } from '../../../type/team/team-member'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-members-card', @@ -13,6 +14,7 @@ import { TeamMember } from '../../../type/team/team-member'; FormsModule, RolePopupComponent, DeletePopupComponent, + ButtonComponent, ], templateUrl: './members-card.component.html', styleUrl: './members-card.component.scss' diff --git a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.html b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.html index 579eed62..9cc5bc85 100644 --- a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.html +++ b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.html @@ -34,38 +34,38 @@

diff --git a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts index 8ed23632..8f9f43e5 100644 --- a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts +++ b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts @@ -9,6 +9,7 @@ import { EventDataService } from '../../../services/event/event-data.service'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { CalendarDayData, CalendarSession, CalendarSessionData } from '../../../type/calendar/calendar'; import { BaseListService, ListState } from '../../../components/services/base-list.service'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-calendar-event-page', @@ -16,7 +17,8 @@ import { BaseListService, ListState } from '../../../components/services/base-li imports: [ NavbarEventPageComponent, NgClass, - AsyncPipe + AsyncPipe, + ButtonComponent ], providers: [BaseListService], styleUrls: ['./calendar-event-page.component.css'] diff --git a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html index 81f7bbe3..1b6a089a 100644 --- a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html +++ b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html @@ -78,15 +78,14 @@

Customize event logo

alt="Event logo preview" class="w-full h-full object-cover rounded-lg" (error)="onImageError($event)"> - - + } @@ -116,13 +115,13 @@

Customize event logo

@if (currentUserRole() === 'Owner') {
- +
} diff --git a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts index b6e782cd..f6b84cbb 100644 --- a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts +++ b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts @@ -6,6 +6,7 @@ import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { SidebarEventComponent } from '../../../components/event/sidebar-event/sidebar-event.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; type UserRole = 'Owner' | 'Admin' | 'Member'; type ActiveSection = 'event-customize'; @@ -18,6 +19,7 @@ type ActiveSection = 'event-customize'; NavbarEventPageComponent, SidebarEventComponent, ReactiveFormsModule, + ButtonComponent, ], templateUrl: './customize-event.component.html', styleUrl: './customize-event.component.scss' diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html index 05b67bd9..cba0509d 100644 --- a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html @@ -16,14 +16,13 @@ class="text-lg font-medium text-gray-900 flex-1 mr-4"> {{ sessionData.title }}

- Edit - +
@@ -156,9 +155,9 @@

Schedule

required />
- + @if (showDurationDropdown()) {
    @for (duration of durations; track duration.value) {
  • - +
  • }
@@ -210,24 +209,23 @@

Schedule

- + [customClass]="'py-2 px-3 text-gray-600 hover:text-gray-600 rounded-md text-base items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 shadow-sm'"> Cancel - + - + [customClass]="'disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 bg-action hover:bg-action-hover disabled:bg-gray-400 disabled:cursor-not-allowed cursor-pointer text-white text-sm rounded-md transition-colors'"> @if (isUpdatingSchedule()) { Saving... } @else { Save } - +
} diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts index 6c6cf347..5c9a2716 100644 --- a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts @@ -1,6 +1,5 @@ import { Component, DestroyRef, HostListener, inject, OnInit, signal, computed } from '@angular/core'; import { Category, Format, SessionImportData } from '../../../type/session/session'; -import { ButtonGreyComponent } from '../../../../../shared/button-grey/button-grey.component'; import { finalize, Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { SessionService } from '../../../services/sessions/session.service'; @@ -14,11 +13,11 @@ import { Validators } from '@angular/forms'; import { SessionScheduleUpdate } from '../../../type/session/schedule-json-data'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isDefined } from '../../../../../shared/type/predicates'; import { BaseDetailService, DetailState } from '../../../components/services/base-detail.service'; import { AsyncPipe } from '@angular/common'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; interface DurationOption { readonly label: string; @@ -29,11 +28,10 @@ interface DurationOption { selector: 'app-session-detail-page', standalone: true, imports: [ - ButtonGreyComponent, NavbarSessionPageComponent, ReactiveFormsModule, - ButtonGreenActionsComponent, - AsyncPipe + AsyncPipe, + ButtonComponent ], providers: [BaseDetailService], templateUrl: './session-detail-page.component.html', diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html index 4e9e3ab2..b704c0f6 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html @@ -43,18 +43,18 @@
- Create session - + - @@ -65,7 +65,7 @@ {{ activeFiltersCount() }} } - + @if (showFilterPopup()) {
  • - +
  • @for (pageNum of pageNumbers(); track pageNum) {
  • - +
  • }
  • - +
@@ -233,4 +233,3 @@ } - diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index 5a8423e1..e202f5ff 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -6,14 +6,13 @@ import { AsyncPipe } from '@angular/common'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ButtonGreyComponent } from '../../../../../shared/button-grey/button-grey.component'; import { Category, Format, SessionImportData, Speaker } from '../../../type/session/session'; import { SessionFilters } from '../../../type/session/session-filters'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; import { SessionFilterPopupComponent } from '../../../components/session/session-filter-popup/session-filter-popup.component'; import { BaseListService, ListState } from '../../../components/services/base-list.service'; import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-session-list-page', @@ -22,10 +21,9 @@ import { EventDataService } from '../../../services/event/event-data.service'; NavbarEventPageComponent, FormsModule, ReactiveFormsModule, - ButtonGreyComponent, - ButtonGreenActionsComponent, SessionFilterPopupComponent, - AsyncPipe + AsyncPipe, + ButtonComponent ], providers: [BaseListService], templateUrl: './session-list-page.component.html', diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html index 17a7bc57..e4ebe6ec 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html @@ -48,18 +48,19 @@

Speakers Management

Search by name, email, company or biography - Create speaker - + - @@ -70,7 +71,7 @@

Speakers Management

{{ activeFiltersCount }} } -
+ @if (showFilterPopup) {
  • - +
  • @for (pageNum of pageNumbers; track pageNum) {
  • - +
  • }
  • - +
diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts index 8a7bf201..fff1d064 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts @@ -5,10 +5,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AsyncPipe } from '@angular/common'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ButtonGreyComponent } from '../../../../../shared/button-grey/button-grey.component'; import { Category, Format, Speaker } from '../../../type/session/session'; import { SpeakerFilters } from '../../../type/speaker/speaker-filters'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; import { SpeakerWithSessionsDTO } from '../../../type/speaker/speaker-with-sessions'; import { SpeakerService } from '../../../services/speaker/speaker.service'; import { SpeakerFilterPopupComponent } from '../../../components/speaker/speaker-filter-popup/speaker-filter-popup.component'; @@ -16,6 +14,7 @@ import { isDefined } from '../../../../../shared/type/predicates'; import { BaseListService, ListState } from '../../../components/services/base-list.service'; import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-speaker-list-page', @@ -23,10 +22,9 @@ import { EventDataService } from '../../../services/event/event-data.service'; NavbarEventPageComponent, FormsModule, ReactiveFormsModule, - ButtonGreyComponent, - ButtonGreenActionsComponent, SpeakerFilterPopupComponent, - AsyncPipe + AsyncPipe, + ButtonComponent ], providers: [BaseListService], templateUrl: './speaker-list-page.component.html', diff --git a/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.html b/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.html index 76906ac8..c5cbd0da 100644 --- a/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.html @@ -20,9 +20,11 @@

Create a new team.

}
- + Create Team - +
diff --git a/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.ts b/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.ts index 8932d6d3..c82aec1c 100644 --- a/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.ts +++ b/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.ts @@ -3,10 +3,10 @@ import { FormBuilder, FormGroup, Validators, FormControl, ReactiveFormsModule } import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { FieldComponent } from '../../../../../shared/input/field.component'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; import { TeamService } from '../../../services/team/team.service'; import { FormField } from '../../../../../shared/input/interface/form-field'; import { Team } from '../../../type/team/team'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-create-team-page', @@ -15,7 +15,7 @@ import { Team } from '../../../type/team/team'; CommonModule, ReactiveFormsModule, FieldComponent, - ButtonGreenActionsComponent, + ButtonComponent, ], templateUrl: './create-team-page.component.html', styleUrl: './create-team-page.component.scss' diff --git a/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.html b/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.html index 036baf25..512cf7ea 100644 --- a/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.html @@ -17,8 +17,8 @@

Team events

- + [customClass]="'text-gray-600 hover:text-gray-600 rounded-md text-sm inline-flex items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm'"> New Event - +
diff --git a/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.ts b/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.ts index 17a390cc..3637482d 100644 --- a/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.ts +++ b/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.ts @@ -9,16 +9,16 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {finalize} from 'rxjs'; import {EventStatusService} from '../../../services/event/event-status.service'; import {EventTeamCardComponent} from '../../../components/event/event-team-card/event-team-card.component'; -import {ButtonGreyComponent} from '../../../../../shared/button-grey/button-grey.component'; import {NavbarTeamPageComponent} from '../../../components/team/navbar-team-page/navbar-team-page.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-list-event-page', templateUrl: './list-event-page.component.html', imports: [ EventTeamCardComponent, - ButtonGreyComponent, - NavbarTeamPageComponent + NavbarTeamPageComponent, + ButtonComponent ], styleUrls: ['./list-event-page.component.css'] }) diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html index 7be6f603..07833e54 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html @@ -45,12 +45,12 @@

General

@if (currentUserRole() === 'Owner') {
- +
} diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.ts b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.ts index 59b69635..23937f05 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.ts +++ b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.ts @@ -15,6 +15,7 @@ import { DangerZoneComponent } from '../../../components/danger-zone/danger-zone import { DeleteConfirmationPopupComponent } from '../../../components/delete-confirmation-popup/delete-confirmation-popup.component'; import { DeleteConfirmationConfig } from '../../../type/components/delete-confirmation'; import { DangerZoneConfig } from '../../../type/components/danger-zone'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-setting-team-general-page', @@ -25,7 +26,8 @@ import { DangerZoneConfig } from '../../../type/components/danger-zone'; FormsModule, SidebarTeamComponent, DangerZoneComponent, - DeleteConfirmationPopupComponent + DeleteConfirmationPopupComponent, + ButtonComponent ], templateUrl: './setting-team-general-page.component.html', styleUrl: './setting-team-general-page.component.scss' diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html index a0c35b98..97422296 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html @@ -51,9 +51,9 @@

Members

(inputChanged)="onSearchInputChanged($event)"> - - + } diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts index 4e85ceb1..a66c8ec9 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts +++ b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts @@ -17,7 +17,6 @@ import { map } from 'rxjs/operators'; import { SidebarTeamComponent } from '../../../components/team/sidebar-team/sidebar-team.component'; import { MembersCardComponent } from '../../../components/team/members-card/members-card.component'; import { AutocompleteComponent } from '../../../components/auto-complete/auto-complete.component'; -import { ButtonGreenActionsComponent } from '../../../../../shared/button-green-actions/button-green-actions.component'; import { NavbarTeamPageComponent } from '../../../components/team/navbar-team-page/navbar-team-page.component'; import { TeamMember } from '../../../type/team/team-member'; import { FormSubmitData } from '../../../type/team/form-submit-data'; @@ -27,6 +26,7 @@ import { AuthService } from '../../../../../core/login/services/auth.service'; import { UserRoleService } from '../../../services/team/user-role.service'; import { FormField } from '../../../../../shared/input/interface/form-field'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; @Component({ selector: 'app-setting-team-members-page', @@ -36,10 +36,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; FormsModule, ReactiveFormsModule, MembersCardComponent, - ButtonGreenActionsComponent, AutocompleteComponent, NavbarTeamPageComponent, SidebarTeamComponent, + ButtonComponent, ], templateUrl: './setting-team-members-page.component.html', styleUrl: './setting-team-members-page.component.scss' diff --git a/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.html b/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.html index a9864c67..c7a1bf09 100644 --- a/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.html +++ b/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.html @@ -5,32 +5,30 @@ (error)="handlePictureError($event)" referrerpolicy="no-referrer" >
- Talks - + - Travel - + - + [customClass]="'flex items-center gap-2 bg-action hover:bg-action-hover disabled:bg-gray-400 disabled:cursor-not-allowed cursor-pointer text-white text-sm py-1.5 px-3 rounded-md transition-colors'"> Speaker - +
diff --git a/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.ts b/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.ts index 831ebc5d..37993e41 100644 --- a/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.ts +++ b/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.ts @@ -1,15 +1,13 @@ import {Component, inject, OnInit, Signal} from '@angular/core'; import {Router} from '@angular/router'; -import {ButtonWithIconComponent} from '../../../../shared/button-with-icon/button-with-icon.component'; -import {ButtonGreenActionsComponent} from '../../../../shared/button-green-actions/button-green-actions.component'; import {UserStateService} from '../../../../core/services/user-services/user-state.service'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-navbar-profile', standalone:true, imports: [ - ButtonWithIconComponent, - ButtonGreenActionsComponent, + ButtonComponent, ], templateUrl: './navbar-profile.component.html', styleUrl: './navbar-profile.component.scss' diff --git a/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.html b/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.html index ff2b84dd..6fe15e21 100644 --- a/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.html +++ b/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.html @@ -1,23 +1,28 @@ - diff --git a/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.ts b/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.ts index 0e1b3a93..9e24bc54 100644 --- a/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.ts +++ b/front/src/app/feature/profile/components/profile-sidebar/profile-sidebar.component.ts @@ -1,12 +1,12 @@ -import {Component, input, Input} from '@angular/core'; +import {Component, input} from '@angular/core'; import {Router} from '@angular/router'; -import {ButtonWithIconComponent} from '../../../../shared/button-with-icon/button-with-icon.component'; +import {ButtonComponent} from '../../../../shared/button/button.component'; @Component({ selector: 'app-profile-sidebar', standalone:true, imports: [ - ButtonWithIconComponent + ButtonComponent ], templateUrl: './profile-sidebar.component.html', styleUrl: './profile-sidebar.component.scss' @@ -16,19 +16,32 @@ export class ProfileSidebarComponent { constructor(private router: Router) {} - navigateTo(path: string) { + navigateTo(path: string): void { if (path.startsWith('#')) { const elementId = path.substring(1); const element = document.getElementById(elementId); if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); } } else { this.router.navigate([path]); } } - isActive(sectionId: string): boolean { + isActive(sectionId: string): boolean { return this.activeSection() === sectionId.replace('#', ''); } + + getSidebarButtonClasses(sectionId: string): string { + const baseClasses = 'group flex items-center gap-x-3 w-full text-left p-2 leading-6 transition-colors hover:bg-gray-100'; + + const activeClasses = this.isActive(sectionId) + ? 'bg-gray-100' + : ''; + + return `${baseClasses} ${activeClasses}`.trim(); + } } diff --git a/front/src/app/feature/profile/components/social-networks/social-networks.component.html b/front/src/app/feature/profile/components/social-networks/social-networks.component.html index 8e0c00ad..89c18b74 100644 --- a/front/src/app/feature/profile/components/social-networks/social-networks.component.html +++ b/front/src/app/feature/profile/components/social-networks/social-networks.component.html @@ -17,18 +17,8 @@

Social links

} -
- - Add new link - -
- -

Other Links

-
+

Other Links

+
Other Links [name]="otherLinkField.name">
- -
- - Add new link - -
diff --git a/front/src/app/feature/profile/components/social-networks/social-networks.component.ts b/front/src/app/feature/profile/components/social-networks/social-networks.component.ts index 92ad15c3..db9e26bc 100644 --- a/front/src/app/feature/profile/components/social-networks/social-networks.component.ts +++ b/front/src/app/feature/profile/components/social-networks/social-networks.component.ts @@ -2,7 +2,6 @@ import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { FormControl } from '@angular/forms'; import {FieldComponent} from '../../../../shared/input/field.component'; -import {ButtonWithIconComponent} from '../../../../shared/button-with-icon/button-with-icon.component'; import {FormField} from '../../../../shared/input/interface/form-field'; import {ProfileService} from '../../services/profile.service'; @@ -11,7 +10,6 @@ import {ProfileService} from '../../services/profile.service'; standalone: true, imports: [ FieldComponent, - ButtonWithIconComponent ], templateUrl: './social-networks.component.html', styleUrl: './social-networks.component.scss' @@ -52,16 +50,4 @@ export class SocialNetworksComponent { getFormControl(name: string): FormControl { return this.profileService.getForm().get(name) as FormControl; } - - navigateTo(path: string) { - if (path.startsWith('#')) { - const elementId = path.substring(1); - const element = document.getElementById(elementId); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } else { - this.router.navigate([path]); - } - } } diff --git a/front/src/app/feature/profile/profile.component.html b/front/src/app/feature/profile/profile.component.html index 0c2b4b34..e97a7414 100644 --- a/front/src/app/feature/profile/profile.component.html +++ b/front/src/app/feature/profile/profile.component.html @@ -3,9 +3,11 @@
- +

Refresh data with datas in Conference Hall

diff --git a/front/src/app/feature/profile/profile.component.ts b/front/src/app/feature/profile/profile.component.ts index a99170b4..bf86c28f 100644 --- a/front/src/app/feature/profile/profile.component.ts +++ b/front/src/app/feature/profile/profile.component.ts @@ -14,6 +14,7 @@ import {User} from '../../core/models/user.model'; import {SaveIndicatorComponent} from '../../core/save-indicator/save-indicator.component'; import {SaveStatus} from '../../core/types/save-status.types'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ButtonComponent} from '../../shared/button/button.component'; @Component({ selector: 'app-profile', @@ -26,7 +27,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; SocialNetworksComponent, NavbarProfileComponent, CommonModule, - SaveIndicatorComponent + SaveIndicatorComponent, + ButtonComponent ], templateUrl: './profile.component.html', styleUrl: './profile.component.scss' diff --git a/front/src/app/shared/button-green-actions/button-green-actions.component.html b/front/src/app/shared/button-green-actions/button-green-actions.component.html deleted file mode 100644 index cdf0f593..00000000 --- a/front/src/app/shared/button-green-actions/button-green-actions.component.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/front/src/app/shared/button-green-actions/button-green-actions.component.scss b/front/src/app/shared/button-green-actions/button-green-actions.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/shared/button-green-actions/button-green-actions.component.spec.ts b/front/src/app/shared/button-green-actions/button-green-actions.component.spec.ts deleted file mode 100644 index c8fbd24f..00000000 --- a/front/src/app/shared/button-green-actions/button-green-actions.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { ButtonGreenActionsComponent } from './button-green-actions.component'; - -describe('ActionButtonComponent', () => { - let component: ButtonGreenActionsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ButtonGreenActionsComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(ButtonGreenActionsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have default button type as "button"', () => { - expect(component.type).toBe('button'); - }); - - it('should set the button type correctly when provided', () => { - component.type = 'submit'; - fixture.detectChanges(); - - const buttonElement = fixture.debugElement.query(By.css('button')).nativeElement; - expect(buttonElement.type).toBe('submit'); - }); - - it('should render content inside the button', () => { - fixture.componentRef.setInput('type', 'button'); - const buttonElement = fixture.debugElement.query(By.css('button')).nativeElement; - - buttonElement.textContent = 'Test Button'; - fixture.detectChanges(); - - expect(buttonElement.textContent.trim()).toBe('Test Button'); - }); -}); - diff --git a/front/src/app/shared/button-green-actions/button-green-actions.component.ts b/front/src/app/shared/button-green-actions/button-green-actions.component.ts deleted file mode 100644 index 4a71c667..00000000 --- a/front/src/app/shared/button-green-actions/button-green-actions.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Component, output, input } from '@angular/core'; - -@Component({ - selector: 'app-button-green-actions', - standalone: true, - imports: [], - templateUrl: './button-green-actions.component.html', - styleUrl: './button-green-actions.component.scss' -}) -export class ButtonGreenActionsComponent { - readonly type = input<'button' | 'submit' | 'reset'>('button'); - readonly ariaLabel = input(null); - readonly materialIcon = input(''); - readonly buttonHandler = input<(() => void) | null>(null); - readonly route = input(''); - readonly disabled = input(false); - - readonly itemClick = output(); - - - handleButtonClick(): void { - if (this.disabled()) { - return; - } - - const handler = this.buttonHandler(); - const routeValue = this.route(); - - if (handler) { - handler(); - } else if (routeValue) { - this.itemClick.emit(routeValue); - } - } -} diff --git a/front/src/app/shared/button-grey/button-grey.component.html b/front/src/app/shared/button-grey/button-grey.component.html deleted file mode 100644 index 23388335..00000000 --- a/front/src/app/shared/button-grey/button-grey.component.html +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/front/src/app/shared/button-grey/button-grey.component.scss b/front/src/app/shared/button-grey/button-grey.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/shared/button-with-icon/button-with-icon.component.spec.ts b/front/src/app/shared/button-with-icon/button-with-icon.component.spec.ts deleted file mode 100644 index 1b0c01f8..00000000 --- a/front/src/app/shared/button-with-icon/button-with-icon.component.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ButtonWithIconComponent } from './button-with-icon.component'; -import { By } from '@angular/platform-browser'; - -describe('SidebarNavItemComponent', () => { - let component: ButtonWithIconComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ButtonWithIconComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(ButtonWithIconComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display material icon when provided', () => { - component.materialIcon = 'home'; - fixture.detectChanges(); - - const iconElement = fixture.debugElement.query(By.css('.material-symbols-outlined')); - expect(iconElement).toBeTruthy(); - expect(iconElement.nativeElement.textContent.trim()).toBe('home'); - }); - - it('should show notification indicator when hasNotification is true', () => { - component.materialIcon = 'notifications'; - component.hasNotification = true; - fixture.detectChanges(); - - const notificationElement = fixture.debugElement.query(By.css('.absolute.bg-primaryColor')); - expect(notificationElement).toBeTruthy(); - }); - - it('should not show notification indicator when hasNotification is false', () => { - component.materialIcon = 'notifications'; - component.hasNotification = false; - fixture.detectChanges(); - - const notificationElement = fixture.debugElement.query(By.css('.absolute.bg-primaryColor')); - expect(notificationElement).toBeFalsy(); - }); - - it('should emit itemClick event with route when navigate() is called', () => { - const routePath = '/test-route'; - component.route = routePath; - - const spy = jest.spyOn(component.itemClick, 'emit'); - component.navigate(); - - expect(spy).toHaveBeenCalledWith(routePath); - }); - - it('should call buttonHandler when provided and button is clicked', () => { - const handlerFn = jest.fn(); - component.buttonHandler = handlerFn; - - component.handleButtonClick(); - - expect(handlerFn).toHaveBeenCalled(); - }); - - it('should emit route when no buttonHandler is provided but route exists', () => { - component.route = '/test-route'; - component.buttonHandler = null; - - const spy = jest.spyOn(component.itemClick, 'emit'); - component.handleButtonClick(); - - expect(spy).toHaveBeenCalledWith('/test-route'); - }); - - it('should trigger handleButtonClick when button is clicked', () => { - const spy = jest.spyOn(component, 'handleButtonClick'); - - const button = fixture.debugElement.query(By.css('button')); - button.triggerEventHandler('click', null); - - expect(spy).toHaveBeenCalled(); - }); - - it('should render ng-content correctly', () => { - const buttonDE = fixture.debugElement.query(By.css('button')); - buttonDE.nativeElement.innerHTML += 'Test Content'; - - fixture.detectChanges(); - - expect(buttonDE.nativeElement.textContent).toContain('Test Content'); - }); -}); diff --git a/front/src/app/shared/button-with-icon/button-with-icon.component.ts b/front/src/app/shared/button-with-icon/button-with-icon.component.ts deleted file mode 100644 index 424d7522..00000000 --- a/front/src/app/shared/button-with-icon/button-with-icon.component.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Component, output, input, computed } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-button-with-icon', - standalone: true, - imports: [CommonModule], - templateUrl: './button-with-icon.component.html', - styleUrl: './button-with-icon.component.scss' -}) -export class ButtonWithIconComponent { - readonly route = input(''); - readonly materialIcon = input(''); - readonly hasNotification = input(false); - readonly buttonHandler = input<(() => void) | null>(null); - readonly notificationCount = input(1); - readonly disabled = input(false); - readonly customClass = input(''); - readonly ariaLabel = input(null); - - readonly itemClick = output(); - - readonly buttonClasses = computed(() => { - const baseClasses = 'group flex items-center gap-x-3 w-full text-left rounded-md p-2 leading-6 transition-colors'; - const customClass = this.customClass(); - - const stateClasses = this.disabled() - ? 'opacity-50 cursor-not-allowed hover:bg-transparent' - : 'hover:bg-gray-100 cursor-pointer'; - - return `${baseClasses} ${customClass} ${stateClasses}`.trim(); - }); - - readonly notificationAriaLabel = computed(() => { - const count = this.notificationCount(); - return count > 1 - ? `You have ${count} notifications` - : 'You have notifications'; - }); - - navigate(): void { - if (this.disabled()) { - return; - } - - const routeValue = this.route(); - if (routeValue) { - this.itemClick.emit(routeValue); - } - } - - handleButtonClick(): void { - if (this.disabled()) { - return; - } - - const handler = this.buttonHandler(); - const routeValue = this.route(); - - if (handler) { - handler(); - } else if (routeValue) { - this.itemClick.emit(routeValue); - } - } -} diff --git a/front/src/app/shared/button-with-icon/button-with-icon.component.html b/front/src/app/shared/button/button.component.html similarity index 86% rename from front/src/app/shared/button-with-icon/button-with-icon.component.html rename to front/src/app/shared/button/button.component.html index f1243846..287af1d9 100644 --- a/front/src/app/shared/button-with-icon/button-with-icon.component.html +++ b/front/src/app/shared/button/button.component.html @@ -1,8 +1,9 @@ diff --git a/front/src/app/shared/button-with-icon/button-with-icon.component.scss b/front/src/app/shared/button/button.component.scss similarity index 100% rename from front/src/app/shared/button-with-icon/button-with-icon.component.scss rename to front/src/app/shared/button/button.component.scss diff --git a/front/src/app/shared/button-grey/button-grey.component.spec.ts b/front/src/app/shared/button/button.component.spec.ts similarity index 52% rename from front/src/app/shared/button-grey/button-grey.component.spec.ts rename to front/src/app/shared/button/button.component.spec.ts index af8a61d8..15e63736 100644 --- a/front/src/app/shared/button-grey/button-grey.component.spec.ts +++ b/front/src/app/shared/button/button.component.spec.ts @@ -1,18 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ButtonGreyComponent } from './button-grey.component'; +import { ButtonComponent } from './button.component'; -describe('ButtonGreyComponent', () => { - let component: ButtonGreyComponent; - let fixture: ComponentFixture; +describe('ButtonComponent', () => { + let component: ButtonComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ButtonGreyComponent] + imports: [ButtonComponent] }) .compileComponents(); - fixture = TestBed.createComponent(ButtonGreyComponent); + fixture = TestBed.createComponent(ButtonComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/front/src/app/shared/button-grey/button-grey.component.ts b/front/src/app/shared/button/button.component.ts similarity index 68% rename from front/src/app/shared/button-grey/button-grey.component.ts rename to front/src/app/shared/button/button.component.ts index 2abc3138..ffee1dcd 100644 --- a/front/src/app/shared/button-grey/button-grey.component.ts +++ b/front/src/app/shared/button/button.component.ts @@ -1,28 +1,29 @@ -import { Component, output, input, computed } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import {Component, computed, input, output} from '@angular/core'; @Component({ - selector: 'app-button-grey', - standalone: true, - imports: [CommonModule], - templateUrl: './button-grey.component.html', - styleUrl: './button-grey.component.scss' + selector: 'app-button', + imports: [], + templateUrl: './button.component.html', + styleUrl: './button.component.scss' }) -export class ButtonGreyComponent { +export class ButtonComponent { readonly type = input<'button' | 'submit' | 'reset'>('button'); readonly route = input(''); readonly materialIcon = input(''); readonly buttonHandler = input<(() => void) | null>(null); readonly isActivePage = input(false); - readonly customTextClass = input(''); readonly disabled = input(false); readonly ariaLabel = input(null); + readonly hasNotification = input(false); + readonly notificationCount = input(1); + readonly customClass = input(''); + readonly materialIconClass = input('text-base'); readonly itemClick = output(); readonly buttonClasses = computed(() => { const baseClasses = 'rounded-md py-1 px-2 text-sm inline-flex items-center gap-2 transition-colors'; - const customClass = this.customTextClass(); + const customClass = this.customClass(); const stateClasses = this.isActivePage() ? 'bg-grey hover:bg-grey-hover cursor-pointer text-gray-700 shadow-sm' @@ -35,6 +36,15 @@ export class ButtonGreyComponent { return `${baseClasses} ${customClass} ${stateClasses} ${disabledClasses}`.trim(); }); + readonly notificationAriaLabel = computed(() => { + const count = this.notificationCount(); + return count > 1 + ? `You have ${count} notifications` + : 'You have notifications'; + }); + + + navigate(): void { if (this.disabled()) { return; From dfa800ab516f49298390c7c805a6944e632ca3f0 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:09:12 +0200 Subject: [PATCH 02/23] reduce auth.service.ts --- .../login-form/login-form.component.spec.ts | 4 +- .../services/auth-backend.service.spec.ts | 16 + .../login/services/auth-backend.service.ts | 147 +++++++ .../auth-error-handler.service.spec.ts | 16 + .../core/login/services/auth-error-handler.ts | 39 ++ .../services/auth-provider.service.spec.ts | 16 + .../login/services/auth-providers.service.ts | 47 ++ .../core/login/services/auth.service.spec.ts | 112 ----- .../app/core/login/services/auth.service.ts | 403 ++---------------- .../email-link-handler.service.spec.ts | 16 + .../services/email-link-handler.service.ts | 122 ++++++ .../sessions/base-import.service.spec.ts | 16 + .../services/sessions/base-import.service.ts | 6 + .../app/shared/button/button.component.scss | 2 +- 14 files changed, 486 insertions(+), 476 deletions(-) create mode 100644 front/src/app/core/login/services/auth-backend.service.spec.ts create mode 100644 front/src/app/core/login/services/auth-backend.service.ts create mode 100644 front/src/app/core/login/services/auth-error-handler.service.spec.ts create mode 100644 front/src/app/core/login/services/auth-error-handler.ts create mode 100644 front/src/app/core/login/services/auth-provider.service.spec.ts create mode 100644 front/src/app/core/login/services/auth-providers.service.ts create mode 100644 front/src/app/core/login/services/email-link-handler.service.spec.ts create mode 100644 front/src/app/core/login/services/email-link-handler.service.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/base-import.service.ts diff --git a/front/src/app/core/login/login-form/login-form.component.spec.ts b/front/src/app/core/login/login-form/login-form.component.spec.ts index 396dc198..319fb64e 100644 --- a/front/src/app/core/login/login-form/login-form.component.spec.ts +++ b/front/src/app/core/login/login-form/login-form.component.spec.ts @@ -3,10 +3,10 @@ import { LoginFormComponent } from './login-form.component'; import { AuthService } from '../services/auth.service'; import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { ButtonWithIconComponent } from "../../../shared/button-with-icon/button-with-icon.component"; import { MatDialog } from '@angular/material/dialog'; import { AuthErrorDialogComponent } from '../../../shared/auth-error-dialog/auth-error-dialog.component'; import { of } from 'rxjs'; +import {ButtonComponent} from '../../../shared/button/button.component'; describe('LoginFormComponent', () => { let component: LoginFormComponent; @@ -52,7 +52,7 @@ describe('LoginFormComponent', () => { imports: [ FormsModule, LoginFormComponent, - ButtonWithIconComponent + ButtonComponent ], providers: [ { provide: AuthService, useValue: authServiceMock }, diff --git a/front/src/app/core/login/services/auth-backend.service.spec.ts b/front/src/app/core/login/services/auth-backend.service.spec.ts new file mode 100644 index 00000000..d7f42e97 --- /dev/null +++ b/front/src/app/core/login/services/auth-backend.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthBackendService } from './auth-backend.service'; + +describe('AuthBackendService', () => { + let service: AuthBackendService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthBackendService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/core/login/services/auth-backend.service.ts b/front/src/app/core/login/services/auth-backend.service.ts new file mode 100644 index 00000000..74c06ac0 --- /dev/null +++ b/front/src/app/core/login/services/auth-backend.service.ts @@ -0,0 +1,147 @@ +import { Injectable, inject } from '@angular/core'; +import { + Auth, + User as FirebaseUser, +} from '@angular/fire/auth'; +import {HttpClient} from '@angular/common/http'; +import {UserStateService} from '../../services/user-services/user-state.service'; +import {User} from '../../models/user.model'; +import {firstValueFrom} from 'rxjs'; +import {environment} from '../../../../environments/environment.development'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthBackendService { + private readonly http = inject(HttpClient); + private readonly auth = inject(Auth); + private readonly userState = inject(UserStateService); + + async syncUserData(firebaseUser: FirebaseUser): Promise { + try { + const userData = await this.fetchUserData(firebaseUser.uid); + + if (userData) { + const mergedUser = this.mergeUserData(firebaseUser, userData); + this.userState.updateUser(mergedUser); + this.userState.saveToStorage(); + } + } catch (error) { + console.error('Error syncing user data:', error); + } + } + + async processUserLogin(user: FirebaseUser): Promise { + const token = await user.getIdToken(); + + await Promise.all([ + this.sendTokenToBackend(token), + this.processInvitations(user), + this.saveUserToBackend(this.createUserPayload(user)) + ]); + + this.userState.updateUser(this.createUserPayload(user)); + this.userState.saveToStorage(); + } + + private mergeUserData(firebaseUser: FirebaseUser, userData: User): User { + return { + uid: firebaseUser.uid, + email: userData.email || firebaseUser.email || '', + displayName: userData.displayName || firebaseUser.displayName || '', + photoURL: userData.photoURL || firebaseUser.photoURL || '', + company: userData.company || '', + city: userData.city || '', + phoneNumber: userData.phoneNumber || '', + githubLink: userData.githubLink || '', + twitterLink: userData.twitterLink || '', + blueSkyLink: userData.blueSkyLink || '', + linkedInLink: userData.linkedInLink || '', + biography: userData.biography || '', + otherLink: userData.otherLink || '' + }; + } + + private createUserPayload(user: FirebaseUser): Partial { + return { + uid: user.uid, + email: user.email, + displayName: user.displayName, + photoURL: user.photoURL + }; + } + + async getIdToken(forceRefresh = true): Promise { + try { + if (!this.auth.currentUser) return null; + + const token = await this.auth.currentUser.getIdToken(forceRefresh); + await this.sendTokenToBackend(token); + return token; + } catch { + return null; + } + } + + async logout(): Promise { + try { + await firstValueFrom( + this.http.post(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true }) + ); + } catch (error) { + console.error('Backend logout error:', error); + } + } + + private async fetchUserData(uid: string): Promise { + try { + return await firstValueFrom( + this.http.get(`${environment.apiUrl}/auth/user/${uid}`, { withCredentials: true }) + ); + } catch { + return null; + } + } + + private async sendTokenToBackend(token: string): Promise { + await firstValueFrom( + this.http.post(`${environment.apiUrl}/auth/login`, { idToken: token }, { withCredentials: true }) + ); + } + + private async saveUserToBackend(user: Partial): Promise { + if (!user?.uid) return; + + await firstValueFrom( + this.http.post(`${environment.apiUrl}/auth`, user, { withCredentials: true }) + ); + } + + async processInvitations(user: FirebaseUser): Promise { + if (!user?.email) return; + + try { + await firstValueFrom( + this.http.post(`${environment.apiUrl}/public/invitations/process`, { + email: user.email.toLowerCase(), + uid: user.uid + }) + ); + } catch (error) { + console.error('Error processing invitations:', error); + } + } + + async getCurrentUserToken(): Promise { + try { + const currentUser = this.auth.currentUser; + if (currentUser) { + return await currentUser.getIdToken(true); + } + return null; + } catch (error) { + console.error('Error getting user token:', error); + return null; + } + } +} diff --git a/front/src/app/core/login/services/auth-error-handler.service.spec.ts b/front/src/app/core/login/services/auth-error-handler.service.spec.ts new file mode 100644 index 00000000..09f9f18b --- /dev/null +++ b/front/src/app/core/login/services/auth-error-handler.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EmailLinkService } from './email-link.service'; + +describe('EmailLinkService', () => { + let service: EmailLinkService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EmailLinkService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/core/login/services/auth-error-handler.ts b/front/src/app/core/login/services/auth-error-handler.ts new file mode 100644 index 00000000..8c599504 --- /dev/null +++ b/front/src/app/core/login/services/auth-error-handler.ts @@ -0,0 +1,39 @@ +import {inject, Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {AuthErrorDialogComponent} from '../../../shared/auth-error-dialog/auth-error-dialog.component'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthErrorHandlerService { + private readonly dialog = inject(MatDialog); + + handleProviderError(error: any): null { + if (error.code === 'auth/account-exists-with-different-credential') { + this.showAuthErrorDialog(error.customData.email); + } + return null; + } + + showAuthErrorDialog(email: string): void { + this.dialog.open(AuthErrorDialogComponent, { + width: '400px', + data: { + title: 'Authentication Error', + email, + message: `The email address "${email}" is already associated with another sign-in method.` + } + }); + } + + showSuccessDialog(title: string, message: string): void { + this.dialog.open(AuthErrorDialogComponent, { + width: '400px', + data: { title, message } + }); + } + + openDialog(component: any, config: any) { + return this.dialog.open(component, config); + } +} diff --git a/front/src/app/core/login/services/auth-provider.service.spec.ts b/front/src/app/core/login/services/auth-provider.service.spec.ts new file mode 100644 index 00000000..56024100 --- /dev/null +++ b/front/src/app/core/login/services/auth-provider.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthProviderService } from './auth-provider.service'; + +describe('AuthProviderService', () => { + let service: AuthProviderService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthProviderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/core/login/services/auth-providers.service.ts b/front/src/app/core/login/services/auth-providers.service.ts new file mode 100644 index 00000000..1c3b59e5 --- /dev/null +++ b/front/src/app/core/login/services/auth-providers.service.ts @@ -0,0 +1,47 @@ +import { Injectable, inject } from '@angular/core'; +import { Router} from '@angular/router'; +import { + Auth, + User as FirebaseUser, + signInWithPopup, + GoogleAuthProvider, + GithubAuthProvider, +} from '@angular/fire/auth'; +import {AuthBackendService} from './auth-backend.service'; +import {AuthErrorHandlerService} from './auth-error-handler'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthProvidersService { + private readonly auth = inject(Auth); + private readonly router = inject(Router); + private readonly authBackend = inject(AuthBackendService); + private readonly errorHandler = inject(AuthErrorHandlerService); + + async loginWithGoogle() { + return this.loginWithProvider('google', new GoogleAuthProvider()); + } + + async loginWithGitHub() { + return this.loginWithProvider('github', new GithubAuthProvider()); + } + + private async loginWithProvider( + providerType: 'google' | 'github', + provider: GoogleAuthProvider | GithubAuthProvider + ): Promise { + try { + const result = await signInWithPopup(this.auth, provider); + + if (result.user) { + await this.authBackend.processUserLogin(result.user); + this.router.navigate(['/']); + } + + return result.user; + } catch (error: any) { + return this.errorHandler.handleProviderError(error); + } + } +} diff --git a/front/src/app/core/login/services/auth.service.spec.ts b/front/src/app/core/login/services/auth.service.spec.ts index fe3811a5..608b6a31 100644 --- a/front/src/app/core/login/services/auth.service.spec.ts +++ b/front/src/app/core/login/services/auth.service.spec.ts @@ -161,116 +161,4 @@ describe('AuthService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); - - describe('loginWithGoogle', () => { - it('should login with Google provider successfully', async () => { - const mockUser = createMockUser(); - - jest.spyOn(service, 'loginWithProvider').mockResolvedValue(mockUser); - jest.spyOn(router, 'navigate'); - - const result = await service.loginWithGoogle(); - - expect(service.loginWithProvider).toHaveBeenCalledWith('google'); - expect(result).toEqual(mockUser); - }); - }); - - describe('loginWithGitHub', () => { - it('should login with GitHub provider successfully', async () => { - const mockUser = createMockUser(); - - jest.spyOn(service, 'loginWithProvider').mockResolvedValue(mockUser); - jest.spyOn(router, 'navigate'); - - const result = await service.loginWithGitHub(); - - expect(service.loginWithProvider).toHaveBeenCalledWith('github'); - expect(result).toEqual(mockUser); - }); - }); - - describe('logout', () => { - it('should sign out successfully', (done) => { - jest.spyOn(firebaseMocks, 'signOut').mockImplementation(() => Promise.resolve(undefined)); - jest.spyOn(service['http'], 'post').mockImplementation(() => of({ success: true })); - - service.logout().then(() => { - expect(firebaseMocks.signOut).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/']); - done(); - }); - }, 10000); - }); - - describe('loginWithEmail', () => { - it('should send email link successfully', async () => { - const email = 'test@example.com'; - - jest.spyOn(service, 'loginWithProvider').mockResolvedValue(true); - - Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:4200' }, - writable: true - }); - - const result = await service.loginWithEmail(email); - - expect(service.loginWithProvider).toHaveBeenCalledWith('email', email); - expect(result).toBe(true); - }); - }); - - describe('loginWithProvider', () => { - it('should use GoogleAuthProvider for google provider', async () => { - const mockUser = createMockUser(); - - firebaseMocks.signInWithPopup.mockImplementation(() => Promise.resolve({ user: mockUser })); - firebaseMocks.fetchSignInMethodsForEmail.mockImplementation(() => Promise.resolve([])); - - const httpSpy = jest.spyOn(service['http'], 'post').mockImplementation(() => of({ success: true })); - - const result = await service.loginWithProvider('google'); - - expect(firebaseMocks.GoogleAuthProvider).toHaveBeenCalled(); - expect(firebaseMocks.signInWithPopup).toHaveBeenCalled(); - expect(httpSpy).toHaveBeenCalledTimes(2); - expect(result).toEqual(mockUser); - }); - }); - - describe('sendEmailLink', () => { - it('should send email link and show dialog', async () => { - const email = 'test@example.com'; - - firebaseMocks.fetchSignInMethodsForEmail.mockImplementation(() => Promise.resolve([])); - firebaseMocks.sendSignInLinkToEmail.mockImplementation(() => Promise.resolve(undefined)); - - Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:4200' }, - writable: true - }); - - const result = await service.sendEmailLink(email); - - expect(firebaseMocks.sendSignInLinkToEmail).toHaveBeenCalled(); - expect(mockDialog.open).toHaveBeenCalled(); - expect(result).toBe(true); - }); - }); - - - describe('sendEmailLink', () => { - it('should send email link and show dialog', async () => { - const email = 'test@example.com'; - firebaseMocks.fetchSignInMethodsForEmail.mockResolvedValueOnce([]); - firebaseMocks.sendSignInLinkToEmail.mockResolvedValueOnce(undefined); - - const result = await service.sendEmailLink(email); - - expect(firebaseMocks.sendSignInLinkToEmail).toHaveBeenCalled(); - expect(mockDialog.open).toHaveBeenCalled(); - expect(result).toBe(true); - }); - }); }); diff --git a/front/src/app/core/login/services/auth.service.ts b/front/src/app/core/login/services/auth.service.ts index 475ab718..655a8d04 100644 --- a/front/src/app/core/login/services/auth.service.ts +++ b/front/src/app/core/login/services/auth.service.ts @@ -1,421 +1,102 @@ import { Injectable, inject } from '@angular/core'; -import {ActivatedRoute, Router} from '@angular/router'; -import { HttpClient } from '@angular/common/http'; -import {BehaviorSubject, firstValueFrom, Observable, of} from 'rxjs'; -import { MatDialog } from '@angular/material/dialog'; import { - Auth, + Auth, onAuthStateChanged, signOut, User as FirebaseUser, - onAuthStateChanged, - signInWithPopup, - GoogleAuthProvider, - GithubAuthProvider, - signInWithEmailLink, - isSignInWithEmailLink, - sendSignInLinkToEmail, - fetchSignInMethodsForEmail, - setPersistence, - browserLocalPersistence, - signOut, authState } from '@angular/fire/auth'; +import {AuthBackendService} from './auth-backend.service'; import {UserStateService} from '../../services/user-services/user-state.service'; -import {User} from '../../models/user.model'; -import {environment} from '../../../../environments/environment.development'; -import {AuthErrorDialogComponent} from '../../../shared/auth-error-dialog/auth-error-dialog.component'; -import {map} from 'rxjs/operators'; +import {AuthProvidersService} from './auth-providers.service'; +import {EmailLinkHandlerService} from './email-link-handler.service'; +import {BehaviorSubject, from, Observable, of, switchMap} from 'rxjs'; +import {AuthErrorHandlerService} from './auth-error-handler'; +import {catchError} from 'rxjs/operators'; +import {Router} from '@angular/router'; @Injectable({ providedIn: 'root' }) export class AuthService { - private currentUserSubject = new BehaviorSubject(null); - - private auth = inject(Auth); - private http = inject(HttpClient); + private readonly auth = inject(Auth); private router = inject(Router); - private dialog = inject(MatDialog); - private userState = inject(UserStateService); + private readonly userState = inject(UserStateService); + private readonly authProviders = inject(AuthProvidersService); + private readonly authBackend = inject(AuthBackendService); + private readonly emailLinkHandler = inject(EmailLinkHandlerService); + private readonly errorHandler = inject(AuthErrorHandlerService); - private userSubject$ = new BehaviorSubject(null); + private readonly userSubject$ = new BehaviorSubject(null); - get user$() { - return this.userSubject$.asObservable(); - } + readonly user$ = this.userSubject$.asObservable(); - private setFirebaseUser(user: FirebaseUser | null): void { - this.userSubject$.next(user); + constructor() { + this.initializeAuthState(); } - constructor( - private route: ActivatedRoute - ) { - onAuthStateChanged(this.auth, (user) => { - this.setFirebaseUser(user); + private initializeAuthState(): void { + onAuthStateChanged(this.auth, async (user) => { + this.userSubject$.next(user); if (user) { - this.fetchAndMergeUserData(user); + await this.authBackend.syncUserData(user); } else { this.userState.clearUser(); } }); this.userState.loadFromStorage(); - this.checkEmailLink(); - - authState(this.auth).subscribe(user => { - this.currentUserSubject.next(user); - }); - } - - private async fetchAndMergeUserData(firebaseUser: FirebaseUser): Promise { - try { - const userData = await firstValueFrom( - this.http.get(`${environment.apiUrl}/auth/user/${firebaseUser.uid}`, { withCredentials: true }) - ); - - if (userData) { - const mergedUser: User = { - uid: firebaseUser.uid, - email: userData.email || firebaseUser.email || '', - displayName: userData.displayName || firebaseUser.displayName || '', - photoURL: userData.photoURL || firebaseUser.photoURL || '', - company: userData.company || '', - city: userData.city || '', - phoneNumber: userData.phoneNumber || '', - githubLink: userData.githubLink || '', - twitterLink: userData.twitterLink || '', - blueSkyLink: userData.blueSkyLink || '', - linkedInLink: userData.linkedInLink || '', - biography: userData.biography || '', - otherLink: userData.otherLink || '' - }; - - this.userState.updateUser(mergedUser); - this.userState.saveToStorage(); - } - } catch (error) { - console.error('Error fetching and merging user data:', error); - } - } - - private fetchAndStoreUserData(uid: string): void { - this.fetchAndMergeUserData(this.auth.currentUser as FirebaseUser); - } - - async loginWithProvider(providerType: 'google' | 'github' | 'email', email?: string) { - if (providerType === 'email' && email) { - return this.sendEmailLink(email); - } - - const provider = providerType === 'google' - ? new GoogleAuthProvider() - : new GithubAuthProvider(); - - try { - if (email) { - try { - const methods = await fetchSignInMethodsForEmail(this.auth, email); - if (methods.length > 0 && - ((providerType === 'google' && !methods.includes('google.com')) || - (providerType === 'github' && !methods.includes('github.com')))) { - - this.showAuthErrorDialog(email); - return null; - } - } catch (error) { - console.error("Error checking sign-in methods:", error); - } - } - - const result = await signInWithPopup(this.auth, provider); - this.setFirebaseUser(result.user); - if (result.user) { - await this.processInvitations(result.user); - const token = await result.user.getIdToken(); - await this.sendTokenToBackend(token); - const userData = await this.fetchUserData(result.user.uid); - const mergedUserData: Partial = { - ...userData, - uid: result.user.uid, - email: result.user.email, - displayName: result.user.displayName, - photoURL: result.user.photoURL - }; - - await this.saveUserToBackend(mergedUserData); - - this.userState.updateUser(mergedUserData); - this.userState.saveToStorage(); - } - this.router.navigate(['/']); - return result.user; - } catch (error: any) { - if (error.code === 'auth/account-exists-with-different-credential') { - const email = error.customData.email; - this.showAuthErrorDialog(email); - return null; - } else { - return null; - } - } - } - - private showAuthErrorDialog(email: string) { - this.dialog.open(AuthErrorDialogComponent, { - width: '400px', - data: { - title: 'Authentication Error', - email: email, - message: `The email address "${email}" is already associated with another sign-in method. Please use the method you initially signed up with.` - } - }); - } - - async sendEmailLink(email: string) { - const actionCodeSettings = { - url: `${window.location.origin}/?email=${encodeURIComponent(email.toLowerCase())}`, - handleCodeInApp: true, - }; - - try { - const methods = await fetchSignInMethodsForEmail(this.auth, email); - if (methods.length > 0 && !methods.includes('emailLink')) { - this.showAuthErrorDialog(email); - return null; - } - - await sendSignInLinkToEmail(this.auth, email, actionCodeSettings); - sessionStorage.setItem('emailForSignIn', email.toLowerCase()); - - this.dialog.open(AuthErrorDialogComponent, { - width: '400px', - data: { - title: 'Email Sent', - message: 'A sign-in link has been sent to your email address. Please check your inbox.' - } - }); - - return true; - } catch (error) { - - this.dialog.open(AuthErrorDialogComponent, { - width: '400px', - data: { - title: 'Error', - message: 'Failed to send sign-in link. Please check your email address and try again.' - } - }); - - return null; - } + this.emailLinkHandler.checkEmailLink(); } async loginWithGoogle() { - return this.loginWithProvider('google'); + return this.authProviders.loginWithGoogle(); } async loginWithGitHub() { - return this.loginWithProvider('github'); + return this.authProviders.loginWithGitHub(); } async loginWithEmail(email: string) { - return this.loginWithProvider('email', email); + return this.emailLinkHandler.sendEmailLink(email); } - async confirmSignIn(email: string, url: string) { - if (isSignInWithEmailLink(this.auth, url)) { - try { - await setPersistence(this.auth, browserLocalPersistence); - const result = await signInWithEmailLink(this.auth, email, url); - this.setFirebaseUser(result.user); - sessionStorage.removeItem('emailForSignIn'); - - if (result.user) { - const token = await result.user.getIdToken(); - - try { - await this.sendTokenToBackend(token); - await this.processInvitations(result.user); - await this.saveUserToBackend({ - uid: result.user.uid, - email: result.user.email, - displayName: result.user.displayName, - photoURL: result.user.photoURL - }); - this.router.navigate(['/']); - } catch (error) { - console.error('Error during backend operations after email sign-in:', error); - } - } - return result.user; - } catch (error) { - this.dialog.open(AuthErrorDialogComponent, { - width: '400px', - data: { - title: 'Authentication Error', - message: 'Invalid email or expired link. Please try again.' - } - }); - return null; - } - } - return null; + async confirmSignIn(email: string, url: string): Promise { + return this.emailLinkHandler.confirmSignIn(email, url); } - isSignInWithEmailLink(url: string) { - return isSignInWithEmailLink(this.auth, url); + isSignInWithEmailLink(url: string): boolean { + return this.emailLinkHandler.isSignInWithEmailLink(url); } - checkEmailLink() { - if (isSignInWithEmailLink(this.auth, window.location.href)) { - let email = sessionStorage.getItem('emailForSignIn'); - if (!email) { - this.route.queryParams.subscribe(params => { - email = params['email']; - - if (email) { - this.processEmailSignIn(email); - } - }); - } else { - this.processEmailSignIn(email); - } - } - } - - private processEmailSignIn(email: string) { - signInWithEmailLink(this.auth, email, window.location.href) - .then(async (result) => { - sessionStorage.removeItem('emailForSignIn'); - this.setFirebaseUser(result.user); - - if (result.user) { - const token = await result.user.getIdToken(); - - try { - await this.sendTokenToBackend(token); - await this.saveUserToBackend({ - uid: result.user.uid, - email: result.user.email, - displayName: result.user.displayName, - photoURL: result.user.photoURL - }); - } catch (error) { - console.error('Error during backend operations:', error); - } - } - - this.router.navigate(['/']); - }) - .catch((error) => { - console.error('Connection error:', error); - }); - } - - private async sendTokenToBackend(token: string) { - try { - return await firstValueFrom( - this.http.post(`${environment.apiUrl}/auth/login`, { idToken: token }, { withCredentials: true }) - ); - } catch (error) { - throw error; - } + async processInvitations(user: FirebaseUser): Promise { + return this.authBackend.processInvitations(user); } - private async saveUserToBackend(user: Partial) { - if (!user?.uid) return; - - try { - return await firstValueFrom( - this.http.post(`${environment.apiUrl}/auth`, user, { withCredentials: true }) - ); - } catch (error) { - throw error; - } + openDialog(component: any, config: any) { + return this.errorHandler.openDialog(component, config); } async logout() { await signOut(this.auth); - try { - await firstValueFrom( - this.http.post(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true }) - ); - } catch (error) { - console.error('Error during logout:', error); - } - + await this.authBackend.logout(); this.userState.clearUser(); + this.userSubject$.next(null); window.history.replaceState({}, document.title, '/'); - this.setFirebaseUser(null); this.router.navigate(['/']); } async getIdToken(forceRefresh = true): Promise { - try { - if (!this.auth.currentUser) { - return null; - } - - const token = await this.auth.currentUser.getIdToken(forceRefresh); - await this.sendTokenToBackend(token); - - return token; - } catch (error) { - return null; - } - } - - private async fetchUserData(uid: string): Promise { - try { - return await firstValueFrom( - this.http.get(`${environment.apiUrl}/auth/user/${uid}`, { withCredentials: true }) - ); - } catch (error) { - return null; - } - } - - public openDialog(component: any, config: any) { - return this.dialog.open(component, config); + return this.authBackend.getIdToken(forceRefresh); } - async processInvitations(user: FirebaseUser): Promise { - if (!user || !user.email) return; - - try { - await firstValueFrom( - this.http.post( - `${environment.apiUrl}/public/invitations/process`, - { - email: user.email.toLowerCase(), - uid: user.uid - } - ) - ); - } catch (error) { - console.error('Error processing invitations:', error); - } + async getCurrentUserToken(): Promise { + return this.authBackend.getCurrentUserToken(); } getToken(): Observable { - return this.currentUserSubject.pipe( - map(user => user?.token ?? localStorage.getItem('token')), + return this.user$.pipe( + switchMap(user => user ? from(user.getIdToken()) : of(null)), + catchError(() => of(null)) ); } - - async getCurrentUserToken(): Promise { - try { - const currentUser = this.auth.currentUser; - if (currentUser) { - const token = await currentUser.getIdToken(true); - return token; - } - return null; - } catch (error) { - console.error('Error getting user token:', error); - return null; - } - } - } diff --git a/front/src/app/core/login/services/email-link-handler.service.spec.ts b/front/src/app/core/login/services/email-link-handler.service.spec.ts new file mode 100644 index 00000000..4e4e9f43 --- /dev/null +++ b/front/src/app/core/login/services/email-link-handler.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthDialogService } from './auth-dialog.service'; + +describe('AuthDialogService', () => { + let service: AuthDialogService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthDialogService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/core/login/services/email-link-handler.service.ts b/front/src/app/core/login/services/email-link-handler.service.ts new file mode 100644 index 00000000..555527c1 --- /dev/null +++ b/front/src/app/core/login/services/email-link-handler.service.ts @@ -0,0 +1,122 @@ +import { Injectable, inject } from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import { + Auth, + signInWithEmailLink, + isSignInWithEmailLink, + sendSignInLinkToEmail, + fetchSignInMethodsForEmail, + setPersistence, + browserLocalPersistence, + User as FirebaseUser, +} from '@angular/fire/auth'; +import {AuthBackendService} from './auth-backend.service'; +import {AuthErrorHandlerService} from './auth-error-handler'; +import {take} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +@Injectable({ + providedIn: 'root' +}) +export class EmailLinkHandlerService { + private readonly auth = inject(Auth); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly authBackend = inject(AuthBackendService); + private readonly errorHandler = inject(AuthErrorHandlerService); + + async sendEmailLink(email: string): Promise { + const actionCodeSettings = { + url: `${window.location.origin}/?email=${encodeURIComponent(email.toLowerCase())}`, + handleCodeInApp: true, + }; + + try { + const methods = await fetchSignInMethodsForEmail(this.auth, email); + if (methods.length > 0 && !methods.includes('emailLink')) { + this.errorHandler.showAuthErrorDialog(email); + return null; + } + + await sendSignInLinkToEmail(this.auth, email, actionCodeSettings); + sessionStorage.setItem('emailForSignIn', email.toLowerCase()); + + this.errorHandler.showSuccessDialog( + 'Email Sent', + 'A sign-in link has been sent to your email address.' + ); + + return true; + } catch { + this.errorHandler.showSuccessDialog( + 'Error', + 'Failed to send sign-in link. Please try again.' + ); + return null; + } + } + + async confirmSignIn(email: string, url: string): Promise { + if (!isSignInWithEmailLink(this.auth, url)) { + return null; + } + + try { + await setPersistence(this.auth, browserLocalPersistence); + const result = await signInWithEmailLink(this.auth, email, url); + + sessionStorage.removeItem('emailForSignIn'); + + if (result.user) { + await this.authBackend.processUserLogin(result.user); + this.router.navigate(['/']); + } + + return result.user; + } catch (error) { + this.errorHandler.showAuthErrorDialog(email); + return null; + } + } + + isSignInWithEmailLink(url: string): boolean { + return isSignInWithEmailLink(this.auth, url); + } + + checkEmailLink(): void { + if (!this.isSignInWithEmailLink(window.location.href)) return; + + const storedEmail = sessionStorage.getItem('emailForSignIn'); + + if (storedEmail) { + this.processEmailSignIn(storedEmail); + } else { + this.route.queryParams.pipe( + take(1) + ).subscribe(params => { + if (params['email']) { + this.processEmailSignIn(params['email']); + } + }); + } + } + + private async processEmailSignIn(email: string): Promise { + try { + await setPersistence(this.auth, browserLocalPersistence); + const result = await signInWithEmailLink(this.auth, email, window.location.href); + + sessionStorage.removeItem('emailForSignIn'); + + if (result.user) { + await this.authBackend.processUserLogin(result.user); + this.router.navigate(['/']); + } + } catch (error) { + console.error('Email sign-in error:', error); + this.errorHandler.showAuthErrorDialog(email); + } + } +} diff --git a/front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts new file mode 100644 index 00000000..02dcb84e --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { BaseImportService } from './base-import.service'; + +describe('BaseImportService', () => { + let service: BaseImportService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BaseImportService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/sessions/base-import.service.ts b/front/src/app/feature/admin-management/services/sessions/base-import.service.ts new file mode 100644 index 00000000..a4ecbf13 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/base-import.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class BaseImportService {} diff --git a/front/src/app/shared/button/button.component.scss b/front/src/app/shared/button/button.component.scss index 4e516caf..2b0f590a 100644 --- a/front/src/app/shared/button/button.component.scss +++ b/front/src/app/shared/button/button.component.scss @@ -1,3 +1,3 @@ .material-symbols-outlined { - font-size: 30px; + font-size: 25px; } From 2bbfe3c78df0481e6eec2f24f46b3effb84374d2 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:52:02 +0200 Subject: [PATCH 03/23] delete base-import.component --- .../base-import/base-import.component.html | 1 - .../base-import/base-import.component.scss | 0 .../base-import/base-import.component.spec.ts | 23 -- .../base-import/base-import.component.ts | 155 ------------- .../session-review-import.component.html | 107 +++++---- .../session-review-import.component.ts | 148 ++++++++---- .../session-schedule-import.component.html | 103 +++++---- .../session-schedule-import.component.ts | 108 +++++---- .../services/sessions/base-import.service.ts | 6 - ...e.spec.ts => field-import.service.spec.ts} | 6 +- .../services/sessions/field-import.service.ts | 213 ++++++++++++++++++ .../admin-management/type/session/session.ts | 8 + 12 files changed, 497 insertions(+), 381 deletions(-) delete mode 100644 front/src/app/feature/admin-management/components/base-import/base-import.component.html delete mode 100644 front/src/app/feature/admin-management/components/base-import/base-import.component.scss delete mode 100644 front/src/app/feature/admin-management/components/base-import/base-import.component.spec.ts delete mode 100644 front/src/app/feature/admin-management/components/base-import/base-import.component.ts delete mode 100644 front/src/app/feature/admin-management/services/sessions/base-import.service.ts rename front/src/app/feature/admin-management/services/sessions/{base-import.service.spec.ts => field-import.service.spec.ts} (61%) create mode 100644 front/src/app/feature/admin-management/services/sessions/field-import.service.ts diff --git a/front/src/app/feature/admin-management/components/base-import/base-import.component.html b/front/src/app/feature/admin-management/components/base-import/base-import.component.html deleted file mode 100644 index 5587a81d..00000000 --- a/front/src/app/feature/admin-management/components/base-import/base-import.component.html +++ /dev/null @@ -1 +0,0 @@ -

base-import works!

diff --git a/front/src/app/feature/admin-management/components/base-import/base-import.component.scss b/front/src/app/feature/admin-management/components/base-import/base-import.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/base-import/base-import.component.spec.ts b/front/src/app/feature/admin-management/components/base-import/base-import.component.spec.ts deleted file mode 100644 index 208d275e..00000000 --- a/front/src/app/feature/admin-management/components/base-import/base-import.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BaseImportComponent } from './base-import.component'; - -describe('BaseImportComponent', () => { - let component: BaseImportComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BaseImportComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(BaseImportComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/front/src/app/feature/admin-management/components/base-import/base-import.component.ts b/front/src/app/feature/admin-management/components/base-import/base-import.component.ts deleted file mode 100644 index 8a2d411c..00000000 --- a/front/src/app/feature/admin-management/components/base-import/base-import.component.ts +++ /dev/null @@ -1,155 +0,0 @@ -import {Component, EventEmitter, Input, Output} from '@angular/core'; -import {ImportResult} from '../../type/session/session'; -import {EventService} from '../../services/event/event.service'; - -@Component({ - selector: 'app-base-import', - imports: [], - templateUrl: './base-import.component.html', - styleUrl: './base-import.component.scss' -}) -export abstract class BaseImportComponent { - @Input() eventId!: string; - @Output() importCompleted = new EventEmitter(); - - selectedFile: File | null = null; - isImporting: boolean = false; - importResult: ImportResult | null = null; - fileError: string | null = null; - - protected readonly MAX_FILE_SIZE = 10 * 1024 * 1024; - - constructor(protected eventService: EventService) {} - - abstract importSessions(): void; - abstract validateData(data: any): void; - - onFileSelected(event: Event): void { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - - if (!this.isValidFile(file)) return; - - this.selectedFile = file; - this.resetState(); - } - } - - protected processFile(processor: (content: string) => void): void { - if (!this.selectedFile || !this.eventId) return; - - this.isImporting = true; - this.resetState(); - - const reader = new FileReader(); - reader.onload = (e) => { - try { - const jsonContent = e.target?.result as string; - - if (!jsonContent || jsonContent.trim() === '') { - throw new Error('File is empty'); - } - - processor(jsonContent); - } catch (error) { - console.error('JSON parsing error:', error); - - if (error instanceof SyntaxError) { - this.handleError('Invalid JSON format. Please check your file syntax.'); - } else { - this.handleError(`Error processing file: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - }; - - reader.onerror = () => this.handleError('Failed to read the file. Please try again.'); - reader.readAsText(this.selectedFile); - } - - protected handleImportResult(result: ImportResult): void { - this.importResult = result; - this.importCompleted.emit(result); - this.isImporting = false; - - if (result.successCount > 0) { - this.clearFileSelection(); - } - } - - protected handleError(message: string): void { - this.fileError = message; - this.isImporting = false; - } - - private isValidFile(file: File): boolean { - if (!file.name.toLowerCase().endsWith('.json')) { - this.fileError = 'Please select a valid JSON file'; - this.selectedFile = null; - return false; - } - - if (file.size > this.MAX_FILE_SIZE) { - this.fileError = 'File size must be less than 10MB'; - this.selectedFile = null; - return false; - } - - return true; - } - - private clearFileSelection(): void { - this.selectedFile = null; - const fileInputs = document.querySelectorAll('input[type="file"]'); - fileInputs.forEach(input => { - (input as HTMLInputElement).value = ''; - }); - } - - - private resetState(): void { - this.importResult = null; - this.fileError = null; - } - - formatFileSize(bytes: number): string { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes : string[] = ['Bytes', 'KB', 'MB', 'GB']; - const i : number = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - getResultClass(): string { - if (!this.importResult) return ''; - - const hasErrors : boolean = this.importResult.errors?.length > 0; - const allSuccess : boolean = this.importResult.successCount === this.importResult.totalCount; - - if (allSuccess && !hasErrors) return 'bg-green-50 border-green-200 border-l-green-500'; - if (this.importResult.successCount > 0) return 'bg-yellow-50 border-yellow-200 border-l-yellow-500'; - return 'bg-red-50 border-red-200 border-l-red-500'; - } - - getResultIcon(): string { - if (!this.importResult) return 'info'; - - const hasErrors : boolean = this.importResult.errors?.length > 0; - const allSuccess : boolean = this.importResult.successCount === this.importResult.totalCount; - - if (allSuccess && !hasErrors) return 'check_circle'; - if (this.importResult.successCount > 0) return 'warning'; - return 'error'; - } - - getIconClass(): string { - if (!this.importResult) return 'text-blue-600'; - - const hasErrors : boolean = this.importResult.errors?.length > 0; - const allSuccess : boolean = this.importResult.successCount === this.importResult.totalCount; - - if (allSuccess && !hasErrors) return 'text-green-600'; - if (this.importResult.successCount > 0) return 'text-yellow-600'; - return 'text-red-600'; - } -} diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html index 3d0f89f2..bf63cd56 100644 --- a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html +++ b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.html @@ -1,8 +1,8 @@ -
-
+
+

Select a JSON file containing session review data @@ -15,86 +15,83 @@ accept=".json" (change)="onFileSelected($event)" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - [disabled]="isImporting" + [disabled]="isImporting()" + aria-describedby="file-help" /> -

+
-
-
- - {{ selectedFile ? 'description' : 'cloud_upload' }} - -
+
+
+ + {{ selectedFile() ? 'description' : 'cloud_upload' }} + +
-
-

- {{ selectedFile ? selectedFile.name : 'Choose a JSON session REVIEW file' }} -

-

- {{ selectedFile ? formatFileSize(selectedFile.size) : 'No file selected' }} -

-
+
+

+ {{ selectedFile()?.name || 'Choose a JSON session REVIEW file' }} +

+

+ {{ selectedFile() ? formatFileSize(selectedFile()!.size) : 'No file selected' }} +

+
- -
- info +

+ Supported format: JSON files containing session review data -

+

- - {{ isImporting ? 'Importing...' : 'Import Review' }} + [disabled]="!selectedFile() || isImporting()" + [customClass]="'text-gray-600 hover:text-gray-600 flex-shrink-0 rounded-md text-base items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm disabled:opacity-50 disabled:cursor-not-allowed'"> + {{ isImporting() ? 'Importing...' : 'Import Reviews' }} +
- -
- - @if (importResult) { -
+ @if (importResult(); as result) { +
- + - Import completed: {{ importResult.successCount }}/{{ importResult.totalCount }} sessions imported - + Import completed: {{ result.successCount }}/{{ result.totalCount }} sessions imported +
- @if (importResult.errors && importResult.errors.length > 0) { -
-

Errors:

-
    - @for (error of importResult.errors; track error) { + @if (result.errors && result.errors.length > 0) { +
    + Errors ({{ result.errors.length }}) +
      + @for (error of result.errors; track error) {
    • {{ error }}
    • }
    -
+ } -
+
} - @if (fileError) { -
+ @if (fileError(); as error) { +
+ } -
+ diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts index 6136bf27..8de6d6f3 100644 --- a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts @@ -1,37 +1,69 @@ -import {Component} from '@angular/core'; -import {SessionImportData} from '../../../type/session/session'; -import {BaseImportComponent} from '../../base-import/base-import.component'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { Component, Signal, input, output, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { ImportCallbacks, ImportResult, SessionImportData } from '../../../type/session/session'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; +import { FileImportService } from '../../../services/sessions/field-import.service'; +import { EventService } from '../../../services/event/event.service'; +import { EventDataService } from '../../../services/event/event-data.service'; @Component({ selector: 'app-session-review-import', - imports: [ - ButtonComponent - ], + imports: [ButtonComponent], templateUrl: './session-review-import.component.html', - styleUrl: './session-review-import.component.scss' + styleUrl: './session-review-import.component.scss', + providers: [FileImportService] }) -export class SessionReviewImportComponent extends BaseImportComponent { +export class SessionReviewImportComponent { + private readonly eventService = inject(EventService); + private readonly eventDataService = inject(EventDataService); + private readonly fileImportService = inject(FileImportService); + + readonly eventId = input.required(); + readonly importCompleted = output(); + + readonly selectedFile: Signal = this.fileImportService.selectedFile; + readonly isImporting: Signal = this.fileImportService.isImporting; + readonly importResult: Signal = this.fileImportService.importResult; + readonly fileError: Signal = this.fileImportService.fileError; + + constructor() { + this.fileImportService.importCompleted$.pipe( + takeUntilDestroyed() + ).subscribe(result => this.importCompleted.emit(result)); + } + + onFileSelected(event: Event): void { + this.fileImportService.handleFileSelection(event); + } importSessions(): void { - this.processFile((jsonContent : string) => { - const sessionsData: any[] = JSON.parse(jsonContent); - this.validateData(sessionsData); - - const transformedData: SessionImportData[] = this.transformReviewData(sessionsData); - - this.eventService.importSessions(this.eventId, transformedData) - .subscribe({ - next: (result) => this.handleImportResult(result), - error: (error) => { - console.error('Import error:', error); - this.handleError('Failed to import sessions. Please try again.'); - } - }); - }); + const callbacks: ImportCallbacks = { + onValidateData: (data) => this.validateData(data), + onProcessData: (data) => this.processReviewData(data), + onImportComplete: () => this.refreshEventData() + }; + + this.fileImportService.processImport(callbacks); + } + + formatFileSize(bytes: number): string { + return this.fileImportService.formatFileSize(bytes); + } + + getResultClass(): string { + return this.fileImportService.getResultClasses(); } - validateData(data: any[]): void { + getResultIcon(): string { + return this.fileImportService.getResultIcon(); + } + + getIconClass(): string { + return this.fileImportService.getIconClasses(); + } + + private validateData(data: any[]): void { if (!Array.isArray(data)) { throw new Error('JSON must contain an array of sessions'); } @@ -40,39 +72,59 @@ export class SessionReviewImportComponent extends BaseImportComponent { throw new Error('No sessions found in the file'); } + const errors: string[] = []; data.forEach((session: any, index: number) => { if (!session.title || typeof session.title !== 'string') { - throw new Error(`Session at index ${index} is missing a valid title`); + errors.push(`Session ${index + 1}: missing valid title`); } if (!session.abstract || typeof session.abstract !== 'string') { - throw new Error(`Session at index ${index} is missing a valid abstract`); + errors.push(`Session ${index + 1}: missing valid abstract`); } if (!session.id || typeof session.id !== 'string') { - throw new Error(`Session at index ${index} is missing a valid id`); + errors.push(`Session ${index + 1}: missing valid ID`); } }); + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + } + + private processReviewData(sessionsData: any[]): Observable { + const transformedData = this.transformReviewData(sessionsData); + return this.eventService.importSessions(this.eventId(), transformedData); } private transformReviewData(reviewData: any[]): SessionImportData[] { - return reviewData.map(session => { - const transformed = { - id: session.id, - title: session.title, - abstractText: session.abstract, - deliberationStatus: session.deliberationStatus || '', - confirmationStatus: session.confirmationStatus || '', - level: session.level || '', - references: session.references || '', - formats: session.formats || [], - categories: session.categories || [], - tags: session.tags || [], - languages: session.languages || [], - speakers: session.speakers || [], - reviews: session.review || null, - eventId: this.eventId - }; - - return transformed; - }); + return reviewData.map(session => ({ + id: session.id, + title: session.title, + abstractText: session.abstract, + deliberationStatus: session.deliberationStatus || '', + confirmationStatus: session.confirmationStatus || '', + level: session.level || '', + references: session.references || '', + formats: session.formats || [], + categories: session.categories || [], + tags: session.tags || [], + languages: session.languages || [], + speakers: session.speakers || [], + reviews: session.review || null, + eventId: this.eventId() + })); + } + + private refreshEventData(): void { + const currentEventId = this.eventId(); + if (currentEventId) { + this.eventService.getEventById(currentEventId).pipe( + takeUntilDestroyed() + ).subscribe({ + next: (event) => { + this.eventDataService.loadEvent(event); + }, + error: (error) => console.warn('Failed to refresh event data:', error) + }); + } } } diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html index 21150f2d..16e12c6c 100644 --- a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html +++ b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.html @@ -1,5 +1,5 @@ -
-
+
+
+
- @if (importResult) { -
+ @if (importResult(); as result) { +
- + - Import completed: {{ importResult.successCount }}/{{ importResult.totalCount }} sessions imported - + Import completed: {{ result.successCount }}/{{ result.totalCount }} sessions imported +
- @if (importResult.errors && importResult.errors.length > 0) { -
-

Errors:

-
    - @for (error of importResult.errors; track error) { + @if (result.errors && result.errors.length > 0) { +
    + Errors ({{ result.errors.length }}) +
      + @for (error of result.errors; track error) {
    • {{ error }}
    • }
    -
+ } -
+
} - @if (fileError) { -
+ @if (fileError(); as error) { +
+ } -
+ diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts index f94deb8a..fb8444ce 100644 --- a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.ts @@ -1,51 +1,75 @@ -import {Component} from '@angular/core'; +import { Component, Signal, input, output, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { ImportCallbacks, ImportResult } from '../../../type/session/session'; +import { FileImportService } from '../../../services/sessions/field-import.service'; +import { EventService } from '../../../services/event/event.service'; +import { EventDataService } from '../../../services/event/event-data.service'; import { - ScheduleJsonData, ScheduleSessionData, - SessionScheduleImportDataDTO, + ScheduleJsonData, + ScheduleSessionData, + SessionScheduleImportDataDTO } from '../../../type/session/schedule-json-data'; -import {BaseImportComponent} from '../../base-import/base-import.component'; -import {ImportResult} from '../../../type/session/session'; -import {EventDTO} from '../../../type/event/eventDTO'; -import {EventService} from '../../../services/event/event.service'; -import {EventDataService} from '../../../services/event/event-data.service'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { EventDTO } from '../../../type/event/eventDTO'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; @Component({ selector: 'app-session-schedule-import', - imports: [ - ButtonComponent - ], templateUrl: './session-schedule-import.component.html', - styleUrl: './session-schedule-import.component.scss' + standalone: true, + imports: [ButtonComponent], + providers: [FileImportService] }) -export class SessionScheduleImportComponent extends BaseImportComponent { +export class SessionScheduleImportComponent { + private readonly eventService = inject(EventService); + private readonly eventDataService = inject(EventDataService); + private readonly fileImportService = inject(FileImportService); + + readonly eventId = input.required(); + readonly importCompleted = output(); + + readonly selectedFile: Signal = this.fileImportService.selectedFile; + readonly isImporting: Signal = this.fileImportService.isImporting; + readonly importResult: Signal = this.fileImportService.importResult; + readonly fileError: Signal = this.fileImportService.fileError; + + constructor() { + this.fileImportService.importCompleted$.pipe( + takeUntilDestroyed() + ).subscribe(result => this.importCompleted.emit(result)); + } - constructor( - eventService: EventService, - private eventDataService: EventDataService - ) { - super(eventService); + onFileSelected(event: Event): void { + this.fileImportService.handleFileSelection(event); } importSessions(): void { - this.processFile((jsonContent: string) => { - const scheduleData: ScheduleJsonData = JSON.parse(jsonContent); - this.validateData(scheduleData); - - const transformedSessions: SessionScheduleImportDataDTO[] = this.transformScheduleToSessionData(scheduleData); - - this.eventService.importSessionsSchedule(this.eventId, transformedSessions) - .subscribe({ - next: (result: ImportResult) => { - this.handleImportResult(result); - this.refreshEventData(); - }, - error: () => this.handleError('Failed to import schedule sessions. Please try again.') - }); - }); + const callbacks: ImportCallbacks = { + onValidateData: (data) => this.validateData(data), + onProcessData: (data) => this.processScheduleData(data), + onImportComplete: () => this.refreshEventData() + }; + + this.fileImportService.processImport(callbacks); + } + + formatFileSize(bytes: number): string { + return this.fileImportService.formatFileSize(bytes); + } + + getResultClass(): string { + return this.fileImportService.getResultClasses(); } - validateData(data: ScheduleJsonData): void { + getResultIcon(): string { + return this.fileImportService.getResultIcon(); + } + + getIconClass(): string { + return this.fileImportService.getIconClasses(); + } + + private validateData(data: ScheduleJsonData): void { if (!data.sessions || !Array.isArray(data.sessions)) { throw new Error('JSON must contain a sessions array.'); } @@ -72,6 +96,11 @@ export class SessionScheduleImportComponent extends BaseImportComponent { } } + private processScheduleData(scheduleData: ScheduleJsonData): Observable { + const transformedSessions = this.transformScheduleToSessionData(scheduleData); + return this.eventService.importSessionsSchedule(this.eventId(), transformedSessions); + } + private transformScheduleToSessionData(scheduleData: ScheduleJsonData): SessionScheduleImportDataDTO[] { return scheduleData.sessions.map(session => { const startDate = this.parseUtcDate(session.start); @@ -101,7 +130,7 @@ export class SessionScheduleImportComponent extends BaseImportComponent { socialLinks: speaker.socialLinks || [] })) || [] } : undefined, - eventId: this.eventId + eventId: this.eventId() }; }); } @@ -133,8 +162,11 @@ export class SessionScheduleImportComponent extends BaseImportComponent { } private refreshEventData(): void { - if (this.eventId) { - this.eventService.getEventById(this.eventId).subscribe({ + const currentEventId = this.eventId(); + if (currentEventId) { + this.eventService.getEventById(currentEventId).pipe( + takeUntilDestroyed() + ).subscribe({ next: (event: EventDTO) => { this.eventDataService.loadEvent(event); }, diff --git a/front/src/app/feature/admin-management/services/sessions/base-import.service.ts b/front/src/app/feature/admin-management/services/sessions/base-import.service.ts deleted file mode 100644 index a4ecbf13..00000000 --- a/front/src/app/feature/admin-management/services/sessions/base-import.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class BaseImportService {} diff --git a/front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/field-import.service.spec.ts similarity index 61% rename from front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts rename to front/src/app/feature/admin-management/services/sessions/field-import.service.spec.ts index 02dcb84e..48756ffe 100644 --- a/front/src/app/feature/admin-management/services/sessions/base-import.service.spec.ts +++ b/front/src/app/feature/admin-management/services/sessions/field-import.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { BaseImportService } from './base-import.service'; +import { FieldImportService } from './field-import.service'; describe('BaseImportService', () => { - let service: BaseImportService; + let service: FieldImportService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(BaseImportService); + service = TestBed.inject(FieldImportService); }); it('should be created', () => { diff --git a/front/src/app/feature/admin-management/services/sessions/field-import.service.ts b/front/src/app/feature/admin-management/services/sessions/field-import.service.ts new file mode 100644 index 00000000..db260dec --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/field-import.service.ts @@ -0,0 +1,213 @@ +import { Injectable, signal, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject } from 'rxjs'; +import { ImportCallbacks, ImportResult } from '../../type/session/session'; + +@Injectable() +export class FileImportService { + private readonly destroyRef = inject(DestroyRef); + private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; + + private readonly _selectedFile = signal(null); + private readonly _isImporting = signal(false); + private readonly _importResult = signal(null); + private readonly _fileError = signal(null); + + readonly selectedFile = this._selectedFile.asReadonly(); + readonly isImporting = this._isImporting.asReadonly(); + readonly importResult = this._importResult.asReadonly(); + readonly fileError = this._fileError.asReadonly(); + + private readonly importCompletedSubject = new Subject(); + readonly importCompleted$ = this.importCompletedSubject.asObservable().pipe( + takeUntilDestroyed(this.destroyRef) + ); + + constructor() { + this.destroyRef.onDestroy(() => { + this.importCompletedSubject.complete(); + this.resetState(); + }); + } + + handleFileSelection(event: Event): boolean { + const input = event.target as HTMLInputElement; + + if (!input.files?.length) { + this.resetFileState(); + return false; + } + + const file = input.files[0]; + + if (!this.validateFile(file)) { + return false; + } + + this._selectedFile.set(file); + this.resetImportState(); + return true; + } + + processImport(callbacks: ImportCallbacks): void { + const file = this._selectedFile(); + + if (!file) { + this.setError('No file selected'); + return; + } + + this._isImporting.set(true); + this.resetImportState(); + + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const jsonContent = e.target?.result as string; + + if (!jsonContent?.trim()) { + throw new Error('File is empty'); + } + + const parsedData = JSON.parse(jsonContent); + + callbacks.onValidateData(parsedData); + + callbacks.onProcessData(parsedData).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: (result) => this.handleImportSuccess(result, callbacks.onImportComplete), + error: (error) => this.handleImportError(error) + }); + + } catch (error) { + this.handleParsingError(error); + } + }; + + reader.onerror = () => this.setError('Failed to read the file. Please try again.'); + reader.readAsText(file); + } + + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB'] as const; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + } + + getResultClasses(): string { + const result = this._importResult(); + if (!result) return ''; + + const hasErrors = (result.errors?.length ?? 0) > 0; + const allSuccess = result.successCount === result.totalCount; + + if (allSuccess && !hasErrors) { + return 'bg-green-50 border-green-200 border-l-green-500'; + } + + if (result.successCount > 0) { + return 'bg-yellow-50 border-yellow-200 border-l-yellow-500'; + } + + return 'bg-red-50 border-red-200 border-l-red-500'; + } + + getResultIcon(): string { + const result = this._importResult(); + if (!result) return 'info'; + + const hasErrors = (result.errors?.length ?? 0) > 0; + const allSuccess = result.successCount === result.totalCount; + + if (allSuccess && !hasErrors) return 'check_circle'; + if (result.successCount > 0) return 'warning'; + return 'error'; + } + + getIconClasses(): string { + const result = this._importResult(); + if (!result) return 'text-blue-600'; + + const hasErrors = (result.errors?.length ?? 0) > 0; + const allSuccess = result.successCount === result.totalCount; + + if (allSuccess && !hasErrors) return 'text-green-600'; + if (result.successCount > 0) return 'text-yellow-600'; + return 'text-red-600'; + } + + resetState(): void { + this._selectedFile.set(null); + this._isImporting.set(false); + this._importResult.set(null); + this._fileError.set(null); + } + + private validateFile(file: File): boolean { + if (!file.name.toLowerCase().endsWith('.json')) { + this.setError('Please select a valid JSON file'); + return false; + } + + if (file.size > this.MAX_FILE_SIZE) { + this.setError('File size must be less than 10MB'); + return false; + } + + return true; + } + + private handleImportSuccess(result: ImportResult, onComplete?: (result: ImportResult) => void): void { + this._importResult.set(result); + this._isImporting.set(false); + + this.importCompletedSubject.next(result); + + onComplete?.(result); + + if (result.successCount > 0) { + this.clearFileSelection(); + } + } + + private handleImportError(error: any): void { + console.error('Import error:', error); + this.setError('Failed to import sessions. Please try again.'); + } + + private handleParsingError(error: unknown): void { + console.error('JSON parsing error:', error); + + if (error instanceof SyntaxError) { + this.setError('Invalid JSON format. Please check your file syntax.'); + } else { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.setError(`Error processing file: ${message}`); + } + } + + private setError(message: string): void { + this._fileError.set(message); + this._isImporting.set(false); + } + + private resetFileState(): void { + this._selectedFile.set(null); + this._fileError.set(null); + } + + private resetImportState(): void { + this._importResult.set(null); + this._fileError.set(null); + } + + private clearFileSelection(): void { + this._selectedFile.set(null); + } +} diff --git a/front/src/app/feature/admin-management/type/session/session.ts b/front/src/app/feature/admin-management/type/session/session.ts index f47615d4..7d7da882 100644 --- a/front/src/app/feature/admin-management/type/session/session.ts +++ b/front/src/app/feature/admin-management/type/session/session.ts @@ -1,3 +1,5 @@ +import {Observable} from 'rxjs'; + export type SessionImportData = { id?: string; title: string; @@ -31,6 +33,12 @@ export type ImportResult = { errors: string[]; } +export type ImportCallbacks = { + onValidateData: (data: any) => void; + onProcessData: (data: any) => Observable; + onImportComplete?: (result: ImportResult) => void; +} + export type Format = { id: string; name: string; From 93c6a6c8658f289c9ddb756191851ebdd20a187b Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:45:37 +0200 Subject: [PATCH 04/23] reduce setting-team-general-page and setting-team-members-page --- .../team/add-member/add-member.component.html | 38 ++ .../team/add-member/add-member.component.scss | 0 .../add-member/add-member.component.spec.ts | 23 ++ .../team/add-member/add-member.component.ts | 156 +++++++ .../team-general-form.component.html | 33 ++ .../team-general-form.component.scss | 0 .../team-general-form.component.spec.ts | 23 ++ .../team-general-form.component.ts | 85 ++++ .../setting-team-general-page.component.html | 65 +-- .../setting-team-general-page.component.ts | 255 +++--------- .../setting-team-members-page.component.html | 118 ++---- .../setting-team-members-page.component.ts | 381 ++++-------------- .../services/team/invitation.service.ts | 79 ++-- .../services/team/team-form.service.spec.ts | 16 + .../services/team/team-form.service.ts | 65 +++ .../team/team-management.service.spec.ts | 16 + .../services/team/team-management.service.ts | 127 ++++++ .../team/team-member-search.service.spec.ts | 16 + .../team/team-member-search.service.ts | 62 +++ .../admin-management/type/team/team-data.ts | 10 + 20 files changed, 902 insertions(+), 666 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/team/add-member/add-member.component.html create mode 100644 front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss create mode 100644 front/src/app/feature/admin-management/components/team/add-member/add-member.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/team/add-member/add-member.component.ts create mode 100644 front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.html create mode 100644 front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss create mode 100644 front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-form.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-form.service.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-management.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-management.service.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-member-search.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/team/team-member-search.service.ts create mode 100644 front/src/app/feature/admin-management/type/team/team-data.ts diff --git a/front/src/app/feature/admin-management/components/team/add-member/add-member.component.html b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.html new file mode 100644 index 00000000..851b08ea --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.html @@ -0,0 +1,38 @@ +
+
+ + + + + + +
+
+ + +
+ +
+
{{ user.displayName || 'Unknown user' }}
+
{{ user.email }}
+
+
+
diff --git a/front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/team/add-member/add-member.component.spec.ts b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.spec.ts new file mode 100644 index 00000000..84d1da4b --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddMemberComponent } from './add-member.component'; + +describe('AddMemberComponent', () => { + let component: AddMemberComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddMemberComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AddMemberComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/team/add-member/add-member.component.ts b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.ts new file mode 100644 index 00000000..3160b6da --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.ts @@ -0,0 +1,156 @@ +import {Component, DestroyRef, inject, input, output, signal} from '@angular/core'; +import {finalize} from 'rxjs/operators'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {take} from 'rxjs'; +import {TeamMember} from '../../../type/team/team-member'; +import {TeamMemberSearchService} from '../../../services/team/team-member-search.service'; +import {TeamMemberService} from '../../../services/team/team-member.service'; +import {AuthService} from '../../../../../core/login/services/auth.service'; +import {AutocompleteComponent} from '../../auto-complete/auto-complete.component'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; + +@Component({ + selector: 'app-add-member', + imports: [ + AutocompleteComponent, + ButtonComponent + ], + templateUrl: './add-member.component.html', + styleUrl: './add-member.component.scss' +}) +export class AddMemberComponent { + readonly teamId = input.required(); + readonly teamName = input.required(); + readonly currentTeamMembers = input.required(); + readonly currentUserRole = input.required(); + + readonly memberAdded = output(); + readonly invitationSent = output<{email: string, teamName: string, teamId: string, inviterName: string}>(); + readonly errorOccurred = output(); + + readonly isAddingMember = signal(false); + + readonly searchService = inject(TeamMemberSearchService); + private readonly teamMemberService = inject(TeamMemberService); + private readonly authService = inject(AuthService); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + this.searchService.setupSearchListener(() => this.currentTeamMembers()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => this.errorOccurred.emit('Error searching for users') + }); + } + + onSearchInputChanged(query: string): void { + if (!query || query.length < 2) { + this.searchService.selectedUser.set(null); + } + } + + addMember(): void { + if (this.currentUserRole() !== 'Owner') { + this.errorOccurred.emit('Only Owners can add members'); + return; + } + + const selectedUser = this.searchService.selectedUser(); + const email = this.searchService.searchControl.value; + + if (selectedUser) { + this.addExistingUser(selectedUser); + } else if (email && this.validateEmail(email)) { + this.inviteByEmail(email); + } else { + this.errorOccurred.emit('Please select a user or enter a valid email address'); + } + } + + private addExistingUser(selectedUser: TeamMember): void { + this.isAddingMember.set(true); + + const newMember: TeamMember = { + userId: selectedUser.userId, + email: selectedUser.email, + displayName: selectedUser.displayName || '', + photoURL: selectedUser.photoURL || '', + role: 'Member' + }; + + this.teamMemberService.addTeamMember(this.teamId(), newMember, this.teamName()) + .pipe( + finalize(() => this.isAddingMember.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (addedMember: TeamMember) => { + this.handleMemberAddedSuccess(addedMember, newMember); + }, + error: (err: unknown) => { + const errorMessage = this.extractErrorMessage(err); + this.errorOccurred.emit(errorMessage); + } + }); + } + + private handleMemberAddedSuccess(addedMember: TeamMember, newMember: TeamMember): void { + this.authService.user$ + .pipe(take(1)) + .subscribe(currentUser => { + const inviterName = currentUser?.displayName || 'Un membre de l\'équipe'; + + this.invitationSent.emit({ + email: newMember.email, + teamName: this.teamName(), + teamId: this.teamId(), + inviterName + }); + }); + + this.memberAdded.emit(addedMember); + this.searchService.reset(); + } + + private extractErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === 'string') { + return err; + } + return 'Failed to add team member'; + } + + private inviteByEmail(email: string): void { + this.isAddingMember.set(true); + const normalizedEmail = email.toLowerCase(); + + this.teamMemberService.inviteMemberByEmail(this.teamId(), normalizedEmail, this.teamName()) + .pipe( + finalize(() => this.isAddingMember.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (invitedMember) => { + this.authService.user$.pipe(take(1)).subscribe(currentUser => { + const inviterName = currentUser?.displayName || 'Un membre de l\'équipe'; + this.invitationSent.emit({ + email: normalizedEmail, + teamName: this.teamName(), + teamId: this.teamId(), + inviterName + }); + this.memberAdded.emit(invitedMember); + this.searchService.reset(); + }); + }, + error: () => this.errorOccurred.emit('Failed to invite member') + }); + } + + private validateEmail(email: string): boolean { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailRegex.test(email); + } +} diff --git a/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.html b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.html new file mode 100644 index 00000000..014d875c --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.html @@ -0,0 +1,33 @@ +
+
+
+

General

+

+ Change team name and URL +

+ + @for(field of formFields; track field.name) { + + + } +
+ +
+ + @if (currentUserRole() === 'Owner') { +
+ + Save + +
+ } +
+
diff --git a/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.spec.ts b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.spec.ts new file mode 100644 index 00000000..f084c08d --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TeamGeneralFormComponent } from './team-general-form.component'; + +describe('TeamGeneralFormComponent', () => { + let component: TeamGeneralFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TeamGeneralFormComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TeamGeneralFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.ts b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.ts new file mode 100644 index 00000000..be11dad9 --- /dev/null +++ b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.ts @@ -0,0 +1,85 @@ +import {Component, effect, inject, input, OnInit, output} from '@angular/core'; +import {FormControl, FormsModule} from '@angular/forms'; +import {FormField} from '../../../../../shared/input/interface/form-field'; +import {TeamFormData, TeamFormService} from '../../../services/team/team-form.service'; +import {TeamManagementService} from '../../../services/team/team-management.service'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; +import {FieldComponent} from '../../../../../shared/input/field.component'; + +@Component({ + selector: 'app-team-general-form', + imports: [ + ButtonComponent, + FieldComponent, + FormsModule + ], + templateUrl: './team-general-form.component.html', + styleUrl: './team-general-form.component.scss' +}) +export class TeamGeneralFormComponent implements OnInit { + readonly teamData = input.required(); + readonly currentUserRole = input.required(); + readonly isLoading = input(false); + + readonly formSubmitted = output(); + readonly formError = output(); + + readonly formService = inject(TeamFormService); + private readonly teamManagementService = inject(TeamManagementService); + + readonly formFields: FormField[] = [ + { + name: 'teamName', + label: 'Team name', + placeholder: '', + type: 'text', + required: true, + }, + { + name: 'teamURL', + label: 'Team URL', + placeholder: '', + type: 'text', + required: false, + disabled: true, + } + ]; + + constructor() { + effect(() => { + this.formService.updateFormPermissions(this.currentUserRole()); + }); + + effect(() => { + this.formService.updateFormData(this.teamData()); + }); + } + + ngOnInit(): void { + this.setupNameChangeListener(); + } + + private setupNameChangeListener(): void { + this.formService.setupNameChangeListener((url) => { + const urlControl = this.formService.getFormControl('teamURL'); + if (urlControl) { + const formattedUrl = this.teamManagementService.formatUrlFromName(url); + urlControl.setValue(formattedUrl); + } + }).subscribe(); + } + + onSubmit(): void { + if (!this.formService.isValid()) { + this.formError.emit('Form is invalid'); + return; + } + + const formData = this.formService.getFormValue(); + this.formSubmitted.emit(formData); + } + + getFormControl(name: string): FormControl { + return this.formService.getFormControl(name); + } +} diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html index 07833e54..20bb96b5 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.html @@ -3,68 +3,45 @@ [teamId]="teamId()" [teamName]="teamName()"> +
- @if (isLoading()) { + @if (teamManagementService.isLoading()) {
} - @if (error()) { + @if (teamManagementService.error()) { } - @if (!isLoading() && !error()) { -
-
-
-

General

-

- Change team name and URL -

- - @for(field of formFields; track field.name) { - - - } -
-
+ @if (!teamManagementService.isLoading() && !teamManagementService.error()) { + + - @if (currentUserRole() === 'Owner') { -
- - Save - -
- } -
- - @if (currentUserRole() === 'Owner') { - - - } -
+ @if (currentUserRole() === 'Owner') { + + + } } + ('settings-general'); readonly teamUrl = signal(''); readonly teamId = signal(''); readonly teamName = signal(''); - readonly isLoading = signal(true); - readonly error = signal(null); readonly isDeleting = signal(false); readonly showDeleteConfirmation = signal(false); readonly currentUserRole = signal(''); - teamForm: FormGroup; - - readonly formFields: FormField[] = [ - { - name: 'teamName', - label: 'Team name', - placeholder: '', - type: 'text', - required: true, - }, - { - name: 'teamURL', - label: 'Team URL', - placeholder: '', - type: 'text', - required: false, - disabled: true, - } - ]; + readonly teamFormData = computed(() => ({ + teamName: this.teamName(), + teamURL: this.teamUrl() + })); readonly dangerZoneConfig = computed(() => ({ title: 'Danger zone', @@ -84,37 +63,14 @@ export class SettingTeamGeneralPageComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly teamService = inject(TeamService); - private readonly teamMemberService = inject(TeamMemberService); - private readonly fb = inject(FormBuilder); - private readonly authService = inject(AuthService); + readonly teamManagementService = inject(TeamManagementService); + private readonly teamFormService = inject(TeamFormService); private readonly destroyRef = inject(DestroyRef); - constructor() { - this.teamForm = this.initializeForm(); - - effect(() => { - this.updateFormControlsBasedOnRole(); - }); - - effect(() => { - this.setupNameChangeListener(); - }); - } - ngOnInit(): void { this.activeSection.set('settings-general'); - this.isLoading.set(true); this.checkForEmailModal(); this.subscribeToRouteParams(); - this.subscribeToUserChanges(); - } - - private initializeForm(): FormGroup { - return this.fb.group({ - teamName: [{ value: '', disabled: false }, Validators.required], - teamURL: { value: '', disabled: true } - }); } private checkForEmailModal(): void { @@ -137,182 +93,103 @@ export class SettingTeamGeneralPageComponent implements OnInit { this.teamId.set(teamIdParam); if (teamIdParam) { - this.loadTeamData(); + this.loadTeamData(teamIdParam); + this.loadUserRole(teamIdParam); } else { - this.error.set('Team ID is missing'); - this.isLoading.set(false); + this.teamManagementService.setError('Team ID is missing'); } }); } - private subscribeToUserChanges(): void { - this.authService.user$ + private loadTeamData(teamId: string): void { + this.teamManagementService.loadTeamData(teamId) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(user => { - if (user && this.teamId()) { - this.loadUserRole(user.uid); - } - }); - } - - private loadTeamData(): void { - this.teamService.getTeamByUrl(this.teamId()) - .pipe( - finalize(() => this.isLoading.set(false)), - takeUntilDestroyed(this.destroyRef) - ) .subscribe({ - next: (team) => this.handleTeamDataLoaded(team), - error: (err) => this.handleTeamDataError(err) + next: (team: TeamData) => { + this.teamId.set(team.id); + this.teamName.set(team.name); + this.teamUrl.set(team.url); + this.teamManagementService.clearError(); + }, + error: () => { + this.teamManagementService.setError('Failed to load team details. Please try again.'); + } }); } - private updateFormControlsBasedOnRole(): void { - const nameControl = this.teamForm.get('teamName'); - if (!nameControl) return; - - if (this.currentUserRole() !== 'Owner') { - nameControl.disable(); - } else { - nameControl.enable(); - } - } - - private loadUserRole(userId: string): void { - this.teamMemberService.getTeamMembers(this.teamId()) + private loadUserRole(teamId: string): void { + this.teamManagementService.getCurrentUserRole(teamId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: (members: TeamMember[]) => { - const currentMember = members.find(m => m.userId === userId); - if (currentMember) { - this.currentUserRole.set(currentMember.role); - } + next: (role: string) => { + this.currentUserRole.set(role); }, - error: (err) => { - console.error('Error loading team members:', err); + error: () => { + console.error('Error loading user role'); } }); } - private handleTeamDataLoaded(team: any): void { - this.teamId.set(team.id || ''); - this.teamName.set(team.name); - - this.teamForm.patchValue({ - teamName: team.name, - teamURL: team.id - }); - - this.error.set(null); - } - - private formatUrlFromName(name: string): string { - return name.trim() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-'); - } - - private handleTeamDataError(err: any): void { - this.error.set('Failed to load team details. Please try again.'); - } - - private setupNameChangeListener(): void { - const nameControl = this.teamForm.get('teamName'); - if (nameControl) { - nameControl.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(value => { - if (value) { - const urlSuffix = this.formatUrlFromName(value); - this.teamForm.get('teamId')?.setValue(urlSuffix); - } - }); - } - } - - onSubmit(): void { - if (this.teamForm.invalid || !this.teamId()) { - this.error.set('Team ID is missing or form is invalid'); + onFormSubmitted(formData: TeamFormData): void { + if (!this.teamId()) { + this.teamManagementService.setError('Team ID is missing'); return; } - const formValues = this.teamForm.getRawValue(); - const updatedTeam = { - name: formValues.teamName, - url: formValues.teamURL + const updateData = { + name: formData.teamName, + url: formData.teamURL }; - this.isLoading.set(true); - - this.teamService.updateTeam(this.teamId(), updatedTeam) - .pipe( - finalize(() => this.isLoading.set(false)), - takeUntilDestroyed(this.destroyRef) - ) + this.teamManagementService.updateTeam(this.teamId(), updateData) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: (team) => this.handleTeamUpdated(team), - error: (err) => this.handleTeamUpdateError(err) + next: (team: TeamData) => { + this.teamName.set(team.name); + this.teamUrl.set(team.url); + this.teamManagementService.clearError(); + }, + error: () => { + this.teamManagementService.setError('Failed to update team. Please try again.'); + } }); } - private handleTeamUpdated(team: any): void { - this.teamName.set(team.name); - this.teamUrl.set(team.url || ''); - } - - private handleTeamUpdateError(err: any): void { - this.error.set('Failed to update team. Please try again.'); + onFormError(error: string): void { + this.teamManagementService.setError(error); } - confirmDeleteTeam(): void { + onDangerZoneDelete(): void { this.showDeleteConfirmation.set(true); } - cancelDeleteTeam(): void { - this.showDeleteConfirmation.set(false); - } - - deleteTeam(): void { + onDeleteConfirmed(): void { if (!this.teamId()) { - this.error.set('Team ID is missing'); + this.teamManagementService.setError('Team ID is missing'); return; } this.isDeleting.set(true); - this.teamService.deleteTeam(this.teamId()) - .pipe( - finalize(() => { - this.isDeleting.set(false); - this.showDeleteConfirmation.set(false); - }), - takeUntilDestroyed(this.destroyRef) - ) + this.teamManagementService.deleteTeam(this.teamId()) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { this.router.navigate(['/']); }, - error: (err) => { - this.error.set('Failed to delete team. Please try again.'); + error: () => { + this.teamManagementService.setError('Failed to delete team. Please try again.'); + this.isDeleting.set(false); + this.showDeleteConfirmation.set(false); + }, + complete: () => { + this.isDeleting.set(false); + this.showDeleteConfirmation.set(false); } }); } - getFormControl(name: string): FormControl { - return this.teamForm.get(name) as FormControl; - } - - onDangerZoneDelete(): void { - this.confirmDeleteTeam(); - } - - onDeleteConfirmed(): void { - this.deleteTeam(); - } - onDeleteCancelled(): void { - this.cancelDeleteTeam(); + this.showDeleteConfirmation.set(false); } } diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html index 97422296..be7844f3 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html +++ b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.html @@ -1,5 +1,5 @@ @@ -25,87 +25,57 @@ } @if (!isLoading() && !error()) { -
-
-
-

Members

- @if (currentUserRole() === 'Owner') { -

- Invite, remove or change role of team members. -

- } -
- +
+
+

Members

@if (currentUserRole() === 'Owner') { -
-
- - - - - - -
-
+

+ Invite, remove or change role of team members. +

} +
- -
- -
-
{{ user.displayName || 'Unknown user' }}
-
{{ user.email }}
-
-
-
+ @if (currentUserRole() === 'Owner') { + + + } - @if (currentTeamMembers().length === 0) { -
- No members found in this team. -
- } @else { - @for (member of currentTeamMembers(); track member.userId) { - - - } + @if (teamMembers().length === 0) { +
+ No members found in this team. +
+ } @else { + @for (member of teamMembers(); track member.userId) { + + } -
- + } +
} -
+ + - - - + + +
diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts index a66c8ec9..873834c8 100644 --- a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts +++ b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.ts @@ -1,139 +1,85 @@ -import { - Component, - OnInit, - viewChild, - input, - ElementRef, - inject, - DestroyRef, - signal, - effect -} from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { debounceTime, distinctUntilChanged, finalize, switchMap, take, tap } from 'rxjs'; +import {Component, OnInit, viewChild, ElementRef, inject, signal, DestroyRef} from '@angular/core'; import { CommonModule } from '@angular/common'; -import { map } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; +import { switchMap } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + import { SidebarTeamComponent } from '../../../components/team/sidebar-team/sidebar-team.component'; import { MembersCardComponent } from '../../../components/team/members-card/members-card.component'; -import { AutocompleteComponent } from '../../../components/auto-complete/auto-complete.component'; import { NavbarTeamPageComponent } from '../../../components/team/navbar-team-page/navbar-team-page.component'; +import { AddMemberComponent } from '../../../components/team/add-member/add-member.component'; + import { TeamMember } from '../../../type/team/team-member'; -import { FormSubmitData } from '../../../type/team/form-submit-data'; import { TeamService } from '../../../services/team/team.service'; import { TeamMemberService } from '../../../services/team/team-member.service'; import { AuthService } from '../../../../../core/login/services/auth.service'; import { UserRoleService } from '../../../services/team/user-role.service'; -import { FormField } from '../../../../../shared/input/interface/form-field'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { InvitationService } from '../../../services/team/invitation.service'; +import {finalize} from 'rxjs/operators'; @Component({ selector: 'app-setting-team-members-page', standalone: true, imports: [ CommonModule, - FormsModule, - ReactiveFormsModule, MembersCardComponent, - AutocompleteComponent, NavbarTeamPageComponent, SidebarTeamComponent, - ButtonComponent, + AddMemberComponent, ], templateUrl: './setting-team-members-page.component.html', styleUrl: './setting-team-members-page.component.scss' }) export class SettingTeamMembersPageComponent implements OnInit { - readonly member = input(); - readonly formSubmitData = input(); - readonly formSubmitElement = viewChild>('formSubmit'); - readonly activeSection = signal('settings-members'); - readonly teamUrl = signal(''); readonly teamId = signal(''); readonly teamName = signal(''); readonly isLoading = signal(false); readonly error = signal(null); readonly teamMembers = signal([]); readonly isDeleting = signal(false); - readonly isSearching = signal(false); - readonly selectedUser = signal(null); - readonly isAddingMember = signal(false); readonly currentUserRole = signal(''); readonly isCreator = signal(false); readonly currentUserId = signal(''); - readonly searchResults = signal([]); - readonly currentTeamMembers = signal([]); - readonly formSubmitDataInternal = signal(undefined); - - readonly searchControl = new FormControl(''); - - readonly field: FormField = { - name: 'findmembers', - placeholder: 'Find member by email', - icon: 'search', - type: 'text', - }; private readonly route = inject(ActivatedRoute); private readonly teamService = inject(TeamService); private readonly teamMemberService = inject(TeamMemberService); private readonly authService = inject(AuthService); private readonly userRoleService = inject(UserRoleService); + protected readonly invitationService = inject(InvitationService); private readonly destroyRef = inject(DestroyRef); constructor() { - effect(() => { - const role = this.currentUserRole(); - if (role) { - this.userRoleService.setRole(role); - } - }); - - this.setupSearchListener(); + this.setupTeamDataSubscription(); } ngOnInit(): void { - this.activeSection.set('settings-members'); - this.isLoading.set(true); + this.loadTeamData(); + } + private setupTeamDataSubscription(): void { this.authService.user$ .pipe( takeUntilDestroyed(this.destroyRef), switchMap(user => { if (!user) { + this.isLoading.set(false); return []; } - this.currentUserId.set(user.uid); + this.currentUserId.set(user.uid); return this.route.paramMap.pipe( switchMap(params => { const teamIdParam = params.get('teamId') || ''; - this.teamId.set(teamIdParam); - - if (!teamIdParam) { - this.error.set('Team ID is missing'); - this.isLoading.set(false); - return []; - } - - return this.teamService.getTeamByUrl(teamIdParam).pipe( - switchMap(team => { - this.teamId.set(team.id || ''); - this.teamName.set(team.name); - this.isCreator.set(team.userCreateId === this.currentUserId()); - - if (!team.id) { - this.error.set('Team ID is missing'); - this.isLoading.set(false); - return []; - } - - return this.teamMemberService.getTeamMembers(team.id); - }) - ); + return this.teamService.getTeamByUrl(teamIdParam); + }), + switchMap(team => { + this.teamId.set(team.id || ''); + this.teamName.set(team.name); + this.isCreator.set(team.userCreateId === this.currentUserId()); + return this.teamMemberService.getTeamMembers(team.id || ''); }) ); }) @@ -141,190 +87,64 @@ export class SettingTeamMembersPageComponent implements OnInit { .subscribe({ next: (members: TeamMember[]) => { this.teamMembers.set(members); - this.currentTeamMembers.set([...members]); - - const currentMember = members.find(m => m.userId === this.currentUserId()); - if (currentMember) { - this.currentUserRole.set(currentMember.role ?? 'Member'); - } - + this.updateCurrentUserRole(members); this.isLoading.set(false); this.error.set(null); }, - error: (err) => { + error: () => { this.isLoading.set(false); this.error.set('Failed to load team data. Please try again.'); - console.error('Error:', err); } }); } - onSearchInputChanged(query: string): void { - if (!query || query.length < 2) { - this.selectedUser.set(null); - } + private loadTeamData(): void { + this.isLoading.set(true); } - private setupSearchListener(): void { - this.searchControl.valueChanges - .pipe( - tap(query => { - if (!query || query.length < 2) { - this.searchResults.set([]); - this.isSearching.set(false); - this.selectedUser.set(null); - } - }), - debounceTime(300), - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef), - switchMap(query => { - if (!query || query.length < 2) { - return []; - } - - this.isSearching.set(true); - - return this.teamMemberService.searchUsersByEmail(query).pipe( - finalize(() => this.isSearching.set(false)) - ); - }) - ) - .subscribe({ - next: (results) => { - if (Array.isArray(results)) { - const filteredResults = results.filter(user => - !this.currentTeamMembers().some(member => member.userId === user.userId) - ); - this.searchResults.set(filteredResults); - } else { - this.searchResults.set([]); - } - }, - error: (err) => { - this.error.set('Error searching for users'); - this.searchResults.set([]); - } - }); + private updateCurrentUserRole(members: TeamMember[]): void { + const currentMember = members.find(m => m.userId === this.currentUserId()); + if (currentMember) { + this.currentUserRole.set(currentMember.role ?? 'Member'); + this.userRoleService.setRole(currentMember.role ?? 'Member'); + } } - selectUser(user: TeamMember): void { - this.selectedUser.set(user); - this.searchControl.setValue(user.email); + onMemberAdded(member: TeamMember): void { + this.teamMembers.set([...this.teamMembers(), member]); + this.error.set(null); } - addMember(): void { - if (this.currentUserRole() !== 'Owner') { - this.error.set('Only Owners can add members'); - return; - } - - const email = this.searchControl.value; - const selectedUser = this.selectedUser(); - - if (selectedUser && this.teamId()) { - this.isAddingMember.set(true); - - const newMember: TeamMember = { - userId: selectedUser.userId, - email: selectedUser.email, - displayName: selectedUser.displayName || '', - photoURL: selectedUser.photoURL || '', - role: 'Member' - }; - - this.teamMemberService.addTeamMember(this.teamId(), newMember, this.teamName()) - .pipe( - finalize(() => this.isAddingMember.set(false)), - switchMap(addedMember => { - return this.authService.user$.pipe( - take(1), - map(currentUser => { - const inviterName = currentUser?.displayName || 'Un membre de l\'équipe'; - - this.submitFormSubmit( - newMember.email, - this.teamName(), - this.teamId(), - inviterName - ); - - return addedMember; - }) - ); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: (addedMember) => { - this.currentTeamMembers.set([...this.currentTeamMembers(), addedMember]); - this.searchControl.setValue(''); - this.selectedUser.set(null); - this.error.set(null); - }, - error: (err) => { - this.error.set(err.message || 'Failed to add team member. Please try again.'); - } - }); - } else if (email && this.validateEmail(email)) { - this.inviteMemberByEmail(); - } else { - this.error.set('Please select a user or enter a valid email address'); - } + onInvitationSent(data: {email: string, teamName: string, teamId: string, inviterName: string}): void { + this.invitationService.sendInvitation( + data.email, + data.teamName, + data.teamId, + data.inviterName, + this.formSubmitElement() + ); } - submitFormSubmit(email: string, teamName: string, teamId: string, inviterName: string): void { - const baseUrl = window.location.origin; - const invitationLink = `${baseUrl}/login`; - - const message = ` - Hello, - - You have been invited by ${inviterName} to join the team "${teamName}". - - Click on this link to connect with your email: "${invitationLink}". - - Best regards,`; - - this.formSubmitDataInternal.set({ - email: email, - subject: `Invitation to join "${teamName}" team on Speaker Space by ${inviterName}`, - message: message, - inviterName: inviterName, - teamName: teamName, - invitationLink: invitationLink, - autoresponse: '' - }); - - setTimeout(() => { - const formElement = this.formSubmitElement(); - if (formElement?.nativeElement) { - formElement.nativeElement.submit(); - } - }, 100); + onError(errorMessage: string): void { + this.error.set(errorMessage); } deleteMember(member: TeamMember): void { this.isDeleting.set(true); - const userId: string = member.userId; - this.teamMemberService.removeTeamMember(this.teamId(), userId) + this.teamMemberService.removeTeamMember(this.teamId(), member.userId) .pipe( - finalize(() => { - this.isDeleting.set(false); - this.selectedUser.set(null); - }), + finalize(() => this.isDeleting.set(false)), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { - const updatedMembers = this.currentTeamMembers().filter(m => m.userId !== userId); - this.currentTeamMembers.set(updatedMembers); - this.teamMembers.set(this.teamMembers().filter(m => m.userId !== userId)); + const updatedMembers = this.teamMembers().filter(m => m.userId !== member.userId); + this.teamMembers.set(updatedMembers); this.error.set(null); }, error: (err) => { - this.error.set(err.message || 'Failed to remove team member. Please try again.'); + this.error.set(err.message || 'Failed to remove team member'); } }); } @@ -332,28 +152,7 @@ export class SettingTeamMembersPageComponent implements OnInit { updateMemberRole(data: { member: TeamMember, newRole: string }): void { const { member, newRole } = data; - if (!this.teamId()) { - this.error.set('Team ID is missing'); - return; - } - - if (this.currentUserRole() !== 'Owner') { - this.error.set('Only Owners can change member roles'); - return; - } - - if (member.userId === this.currentUserId()) { - this.error.set('You cannot change your own role'); - return; - } - - if (member.role === 'Owner' && newRole === 'Member') { - const ownerCount: number = this.currentTeamMembers().filter(m => m.role === 'Owner').length; - if (ownerCount <= 1) { - this.error.set('Cannot demote the last Owner. Promote another member to Owner first.'); - return; - } - } + if (!this.canUpdateRole(member, newRole)) return; this.isLoading.set(true); @@ -364,73 +163,37 @@ export class SettingTeamMembersPageComponent implements OnInit { ) .subscribe({ next: (updatedMember: TeamMember) => { - const updatedCurrentMembers = this.currentTeamMembers().map(m => - m.userId === updatedMember.userId ? updatedMember : m - ); - this.currentTeamMembers.set(updatedCurrentMembers); - - const updatedTeamMembers = this.teamMembers().map(m => + const updatedMembers = this.teamMembers().map(m => m.userId === updatedMember.userId ? updatedMember : m ); - this.teamMembers.set(updatedTeamMembers); - + this.teamMembers.set(updatedMembers); this.error.set(null); }, error: (err) => { - this.error.set(err.message || 'Failed to update member role. Please try again.'); + this.error.set(err.message || 'Failed to update member role'); } }); } - onSubmit(event: Event): void { - event.preventDefault(); - this.addMember(); - } - - inviteMemberByEmail(): void { - const email = this.searchControl.value; - - if (!email || !this.validateEmail(email)) { - this.error.set('Please enter a valid email address'); - return; + private canUpdateRole(member: TeamMember, newRole: string): boolean { + if (this.currentUserRole() !== 'Owner') { + this.error.set('Only Owners can change member roles'); + return false; } - this.isAddingMember.set(true); - const normalizedEmail = email.toLowerCase(); - - this.teamMemberService.inviteMemberByEmail(this.teamId(), normalizedEmail, this.teamName()) - .pipe( - finalize(() => this.isAddingMember.set(false)), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: (invitedMember) => { - this.authService.user$.pipe( - take(1) - ).subscribe(currentUser => { - const inviterName = currentUser?.displayName || 'Un membre de l\'équipe'; - - this.submitFormSubmit( - normalizedEmail, - this.teamName(), - this.teamId(), - inviterName - ); + if (member.userId === this.currentUserId()) { + this.error.set('You cannot change your own role'); + return false; + } - this.currentTeamMembers.set([...this.currentTeamMembers(), invitedMember]); - this.searchControl.setValue(''); - this.error.set(null); - }); - }, - error: (err) => { - this.error.set('Failed to invite member. Please try again.'); - console.error('Error inviting member:', err); - } - }); - } + if (member.role === 'Owner' && newRole === 'Member') { + const ownerCount = this.teamMembers().filter(m => m.role === 'Owner').length; + if (ownerCount <= 1) { + this.error.set('Cannot demote the last Owner. Promote another member to Owner first.'); + return false; + } + } - private validateEmail(email: string): boolean { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - return emailRegex.test(email); + return true; } } diff --git a/front/src/app/feature/admin-management/services/team/invitation.service.ts b/front/src/app/feature/admin-management/services/team/invitation.service.ts index 5e2e82c2..174f2f97 100644 --- a/front/src/app/feature/admin-management/services/team/invitation.service.ts +++ b/front/src/app/feature/admin-management/services/team/invitation.service.ts @@ -1,58 +1,37 @@ -import { Injectable } from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {Observable, of, switchMap, take, throwError} from 'rxjs'; -import {catchError} from 'rxjs/operators'; -import {AuthService} from '../../../../core/login/services/auth.service'; -import {environment} from '../../../../../environments/environment.development'; -import {TeamMember} from '../../type/team/team-member'; +import { Injectable, signal, ElementRef } from '@angular/core'; +import { FormSubmitData } from '../../type/team/form-submit-data'; @Injectable({ providedIn: 'root' }) export class InvitationService { - constructor( - private http: HttpClient, - private authService: AuthService - ) {} - - checkPendingInvitations(): Observable { - return this.authService.user$.pipe( - take(1), - switchMap(user => { - if (!user || !user.email) { - return of([]); - } - - return this.http.get( - `${environment.apiUrl}/invitations/pending?email=${encodeURIComponent(user.email.toLowerCase())}`, - { withCredentials: true } - ).pipe( - catchError(error => { - console.error('Error checking pending invitations:', error); - return of([]); - }) - ); - }) - ); - } + readonly formSubmitData = signal(undefined); + + sendInvitation(email: string, teamName: string, teamId: string, inviterName: string, formElement?: ElementRef): void { + const baseUrl = window.location.origin; + const invitationLink = `${baseUrl}/login`; + + const message = ` + Hello, + + You have been invited by ${inviterName} to join the team "${teamName}". + + Click on this link to connect with your email: "${invitationLink}". + + Best regards,`; + + this.formSubmitData.set({ + email, + subject: `Invitation to join "${teamName}" team on Speaker Space by ${inviterName}`, + message, + inviterName, + teamName, + invitationLink, + autoresponse: '' + }); - acceptInvitation(invitationId: string): Observable { - return this.authService.user$.pipe( - take(1), - switchMap(user => { - if (!user) { - return throwError(() => new Error('User not authenticated')); - } - - return this.http.post( - `${environment.apiUrl}/invitations/${invitationId}/accept`, - { userId: user.uid }, - { withCredentials: true } - ); - }), - catchError(error => { - return throwError(() => error); - }) - ); + setTimeout(() => { + formElement?.nativeElement?.submit(); + }, 100); } } diff --git a/front/src/app/feature/admin-management/services/team/team-form.service.spec.ts b/front/src/app/feature/admin-management/services/team/team-form.service.spec.ts new file mode 100644 index 00000000..cf566884 --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-form.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TeamFormService } from './team-form.service'; + +describe('TeamFormService', () => { + let service: TeamFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TeamFormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/team/team-form.service.ts b/front/src/app/feature/admin-management/services/team/team-form.service.ts new file mode 100644 index 00000000..dabc960c --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-form.service.ts @@ -0,0 +1,65 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms'; +import { Observable } from 'rxjs'; + +export interface TeamFormData { + teamName: string; + teamURL: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class TeamFormService { + private readonly fb = inject(FormBuilder); + + readonly form = signal(this.createForm()); + readonly isFormValid = signal(false); + + private createForm(): FormGroup { + const form = this.fb.group({ + teamName: [{ value: '', disabled: false }, Validators.required], + teamURL: { value: '', disabled: true } + }); + + form.statusChanges.subscribe(status => { + this.isFormValid.set(status === 'VALID'); + }); + + return form; + } + + updateFormData(data: Partial): void { + this.form().patchValue(data); + } + + getFormControl(name: string): FormControl { + return this.form().get(name) as FormControl; + } + + getFormValue(): TeamFormData { + return this.form().getRawValue(); + } + + updateFormPermissions(userRole: string): void { + const nameControl = this.form().get('teamName'); + if (!nameControl) return; + + if (userRole !== 'Owner') { + nameControl.disable(); + } else { + nameControl.enable(); + } + } + + setupNameChangeListener(onNameChange: (url: string) => void): Observable { + const nameControl = this.form().get('teamName'); + if (!nameControl) throw new Error('Team name control not found'); + + return nameControl.valueChanges; + } + + isValid(): boolean { + return this.form().valid; + } +} diff --git a/front/src/app/feature/admin-management/services/team/team-management.service.spec.ts b/front/src/app/feature/admin-management/services/team/team-management.service.spec.ts new file mode 100644 index 00000000..833da5e2 --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-management.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TeamManagementService } from './team-management.service'; + +describe('TeamManagementService', () => { + let service: TeamManagementService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TeamManagementService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/team/team-management.service.ts b/front/src/app/feature/admin-management/services/team/team-management.service.ts new file mode 100644 index 00000000..2ecf3597 --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-management.service.ts @@ -0,0 +1,127 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, switchMap, map, of, throwError, EMPTY } from 'rxjs'; +import { finalize, catchError } from 'rxjs/operators'; +import {TeamData, TeamUpdateData} from '../../type/team/team-data'; +import {TeamService} from './team.service'; +import {TeamMemberService} from './team-member.service'; +import {AuthService} from '../../../../core/login/services/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class TeamManagementService { + private readonly teamService = inject(TeamService); + private readonly teamMemberService = inject(TeamMemberService); + private readonly authService = inject(AuthService); + + readonly isLoading = signal(false); + readonly error = signal(null); + + loadTeamData(teamId: string): Observable { + this.isLoading.set(true); + this.error.set(null); + + return this.teamService.getTeamByUrl(teamId).pipe( + map((team: any) => this.transformToTeamData(team, teamId)), + catchError(err => { + this.setError('Failed to load team data'); + return throwError(() => err); + }), + finalize(() => this.isLoading.set(false)) + ); + } + + updateTeam(teamId: string, updateData: TeamUpdateData): Observable { + this.isLoading.set(true); + this.error.set(null); + + return this.teamService.updateTeam(teamId, updateData).pipe( + map((team: any) => this.transformToTeamData(team, teamId)), + catchError(err => { + this.setError('Failed to update team'); + return throwError(() => err); + }), + finalize(() => this.isLoading.set(false)) + ); + } + + deleteTeam(teamId: string): Observable { + this.isLoading.set(true); + this.error.set(null); + + return this.teamService.deleteTeam(teamId).pipe( + switchMap((response: any) => { + console.log('Team deletion response:', response); + return of(void 0); + }), + catchError(err => { + this.setError('Failed to delete team'); + return throwError(() => err); + }), + finalize(() => this.isLoading.set(false)) + ); + } + + getCurrentUserRole(teamId: string): Observable { + return this.authService.user$.pipe( + switchMap(user => { + if (!user) { + console.warn('User not authenticated, returning default role'); + return of('Member'); + } + + return this.teamMemberService.getTeamMembers(teamId).pipe( + map(members => { + const currentMember = members.find(m => m.userId === user.uid); + return currentMember?.role || 'Member'; + }), + catchError(err => { + console.error('Error loading team members:', err); + return of('Member'); + }) + ); + }) + ); + } + + formatUrlFromName(name: string): string { + if (!name || typeof name !== 'string') { + return ''; + } + + return name.trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + setError(message: string): void { + this.error.set(message); + console.error('TeamManagementService Error:', message); + } + + clearError(): void { + this.error.set(null); + } + + private transformToTeamData(team: any, fallbackId?: string): TeamData { + if (!team) { + throw new Error('Team data is null or undefined'); + } + + return { + id: this.ensureString(team.id || fallbackId), + name: this.ensureString(team.name), + url: this.ensureString(team.url || team.id || fallbackId) + }; + } + + private ensureString(value: any): string { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + return ''; + } +} diff --git a/front/src/app/feature/admin-management/services/team/team-member-search.service.spec.ts b/front/src/app/feature/admin-management/services/team/team-member-search.service.spec.ts new file mode 100644 index 00000000..4c469974 --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-member-search.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TeamMemberSearchService } from './team-member-search.service'; + +describe('TeamMemberSearchService', () => { + let service: TeamMemberSearchService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TeamMemberSearchService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/team/team-member-search.service.ts b/front/src/app/feature/admin-management/services/team/team-member-search.service.ts new file mode 100644 index 00000000..a7119a7d --- /dev/null +++ b/front/src/app/feature/admin-management/services/team/team-member-search.service.ts @@ -0,0 +1,62 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { debounceTime, distinctUntilChanged, finalize, switchMap, tap } from 'rxjs'; +import { TeamMemberService } from './team-member.service'; +import { TeamMember } from '../../type/team/team-member'; + +@Injectable({ + providedIn: 'root' +}) +export class TeamMemberSearchService { + private readonly teamMemberService = inject(TeamMemberService); + + readonly searchControl = new FormControl(''); + readonly searchResults = signal([]); + readonly isSearching = signal(false); + readonly selectedUser = signal(null); + + setupSearchListener(currentTeamMembers: () => TeamMember[]) { + return this.searchControl.valueChanges.pipe( + tap(query => this.handleEmptyQuery(query)), + debounceTime(300), + distinctUntilChanged(), + switchMap(query => this.performSearch(query, currentTeamMembers)) + ); + } + + private handleEmptyQuery(query: string | null): void { + if (!query || query.length < 2) { + this.searchResults.set([]); + this.isSearching.set(false); + this.selectedUser.set(null); + } + } + + private performSearch(query: string | null, currentTeamMembers: () => TeamMember[]) { + if (!query || query.length < 2) return []; + + this.isSearching.set(true); + return this.teamMemberService.searchUsersByEmail(query).pipe( + finalize(() => this.isSearching.set(false)), + tap(results => this.filterExistingMembers(results, currentTeamMembers())) + ); + } + + private filterExistingMembers(results: TeamMember[], currentMembers: TeamMember[]): void { + const filteredResults = results.filter(user => + !currentMembers.some(member => member.userId === user.userId) + ); + this.searchResults.set(filteredResults); + } + + selectUser(user: TeamMember): void { + this.selectedUser.set(user); + this.searchControl.setValue(user.email); + } + + reset(): void { + this.searchControl.setValue(''); + this.selectedUser.set(null); + this.searchResults.set([]); + } +} diff --git a/front/src/app/feature/admin-management/type/team/team-data.ts b/front/src/app/feature/admin-management/type/team/team-data.ts new file mode 100644 index 00000000..1ccdbf57 --- /dev/null +++ b/front/src/app/feature/admin-management/type/team/team-data.ts @@ -0,0 +1,10 @@ +export type TeamData = { + id: string; + name: string; + url: string; +} + +export type TeamUpdateData = { + name: string; + url: string; +} From dc57ea7c070024711ce49d500035b7cda6deaa99 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:22:05 +0200 Subject: [PATCH 05/23] reduce customize-event --- .../event-logo-uploader.component.html | 60 ++ .../event-logo-uploader.component.scss | 0 .../event-logo-uploader.component.spec.ts | 23 + .../event-logo-uploader.component.ts | 127 ++++ .../customize-event.component.html | 86 +-- .../customize-event.component.ts | 545 +++++------------- .../event/image-upload.service.spec.ts | 16 + .../services/event/image-upload.service.ts | 124 ++++ .../type/event/image-upload.ts | 13 + 9 files changed, 519 insertions(+), 475 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.html create mode 100644 front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss create mode 100644 front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.ts create mode 100644 front/src/app/feature/admin-management/services/event/image-upload.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/image-upload.service.ts create mode 100644 front/src/app/feature/admin-management/type/event/image-upload.ts diff --git a/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.html b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.html new file mode 100644 index 00000000..1f2bfc0d --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.html @@ -0,0 +1,60 @@ +
+ + +
+ + @if (showUploadArea()) { +
+ +

Click to upload

+
+ } + + @if (isProcessing()) { +
+ +

Processing...

+
+ } + + @if (hasImage()) { + Event logo preview + + × + + } +
+ + @if (errorMessage()) { + + } +
diff --git a/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.spec.ts b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.spec.ts new file mode 100644 index 00000000..a3351985 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EventLogoUploaderComponent } from './event-logo-uploader.component'; + +describe('EventLogoUploaderComponent', () => { + let component: EventLogoUploaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EventLogoUploaderComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(EventLogoUploaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.ts b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.ts new file mode 100644 index 00000000..0514ac9a --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.ts @@ -0,0 +1,127 @@ +import { Component, computed, effect, ElementRef, input, output, signal, viewChild } from '@angular/core'; +import { ImageUploadService } from '../../../services/event/image-upload.service'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; + +@Component({ + selector: 'app-event-logo-uploader', + standalone: true, + imports: [ButtonComponent], + templateUrl: './event-logo-uploader.component.html', + styleUrl: './event-logo-uploader.component.scss' +}) +export class EventLogoUploaderComponent { + readonly initialImageUrl = input(null); + readonly disabled = input(false); + + readonly imageSelected = output(); + readonly imageRemoved = output(); + readonly uploadError = output(); + + readonly fileInput = viewChild>('fileInput'); + + readonly selectedImageUrl = signal(null); + readonly isProcessing = signal(false); + readonly isDragOver = signal(false); + readonly errorMessage = signal(null); + + readonly canUpload = computed(() => !this.isProcessing() && !this.disabled()); + readonly hasImage = computed(() => !!this.selectedImageUrl()); + readonly showUploadArea = computed(() => !this.hasImage() && !this.isProcessing()); + + constructor(private readonly imageUploadService: ImageUploadService) { + + effect(() => { + const initial = this.initialImageUrl(); + + if (initial) { + this.selectedImageUrl.set(initial); + } + }); + } + + triggerFileInput(): void { + if (this.canUpload()) { + this.fileInput()?.nativeElement.click(); + } + } + + async onFileSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + if (input.files?.[0]) { + await this.handleFile(input.files[0]); + } + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + if (this.canUpload()) { + this.isDragOver.set(true); + } + } + + onDragLeave(event: DragEvent): void { + event.preventDefault(); + this.isDragOver.set(false); + } + + async onDrop(event: DragEvent): Promise { + event.preventDefault(); + this.isDragOver.set(false); + + if (!this.canUpload()) { + return; + } + + const file = event.dataTransfer?.files[0]; + if (file) { + await this.handleFile(file); + } + } + + private async handleFile(file: File): Promise { + this.errorMessage.set(null); + this.isProcessing.set(true); + + try { + const result = await this.imageUploadService.processImage(file); + + this.cleanupBlobUrl(); + + this.selectedImageUrl.set(result.base64); + this.imageSelected.emit(result.base64); + + } catch (error) { + const message = error instanceof Error + ? error.message + : 'Erreur lors du traitement de l\'image'; + + this.errorMessage.set(message); + this.uploadError.emit(message); + console.error('Image upload error:', error); + } finally { + this.isProcessing.set(false); + } + } + + removeImage(event: Event): void { + event.stopPropagation(); + + this.cleanupBlobUrl(); + this.selectedImageUrl.set(null); + this.errorMessage.set(null); + + const input = this.fileInput()?.nativeElement; + if (input) { + input.value = ''; + } + + this.imageRemoved.emit(); + } + + private cleanupBlobUrl(): void { + const url = this.selectedImageUrl(); + if (url?.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + } +} diff --git a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html index 1b6a089a..1c0140e1 100644 --- a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html +++ b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.html @@ -23,76 +23,20 @@ } @if (!isLoading() && !error()) { -
-
+ +

Customize event logo

Upload a beautiful logo for your event.

-
- - -
- - @if (showUploadArea()) { -
- -

Click to upload

-
- } - - @if (isUploading()) { -
- -

Uploading...

-
- } - - @if (hasSelectedImage()) { - Event logo preview - - × - - } -
- - @if (uploadError()) { - - } -
+
@@ -102,25 +46,25 @@

Customize event logo

JPEG, PNG, WEBP or AVIF formats supported with optimal resolution of 500x500.
300kB max. You can optimize your logo with + class="text-black underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-indigo-500" + target="_blank" + rel="noopener noreferrer"> squoosh.app
-
+
@if (currentUserRole() === 'Owner') {
- {{ isProcessing() ? 'Saving...' : 'Save' }} + [attr.aria-label]="isSaving() ? 'Saving changes' : 'Save changes'"> + {{ isSaving() ? 'Saving...' : 'Save' }}
} diff --git a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts index f6b84cbb..93bc45b5 100644 --- a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts +++ b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts @@ -1,234 +1,214 @@ -import { Component, viewChild, ElementRef, OnInit, OnDestroy, signal, computed } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; -import { finalize, Subscription } from 'rxjs'; +import { Component, OnInit, inject, signal, computed, DestroyRef } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { switchMap } from 'rxjs'; + import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { SidebarEventComponent } from '../../../components/event/sidebar-event/sidebar-event.component'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; +import { EventLogoUploaderComponent } from '../../../components/event/event-logo-uploader/event-logo-uploader.component'; type UserRole = 'Owner' | 'Admin' | 'Member'; -type ActiveSection = 'event-customize'; + +interface EventFormData { + eventName: string; + eventURL: string; + weblink: string; +} @Component({ selector: 'app-customize-event', standalone: true, imports: [ - FormsModule, + ReactiveFormsModule, NavbarEventPageComponent, SidebarEventComponent, - ReactiveFormsModule, ButtonComponent, + EventLogoUploaderComponent ], - templateUrl: './customize-event.component.html', - styleUrl: './customize-event.component.scss' + templateUrl: './customize-event.component.html' }) -export class CustomizeEventComponent implements OnInit, OnDestroy { - readonly fileInput = viewChild>('fileInput'); +export class CustomizeEventComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly eventService = inject(EventService); + private readonly eventDataService = inject(EventDataService); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); - readonly activeSection = signal('event-customize'); readonly eventId = signal(''); readonly eventUrl = signal(''); readonly eventName = signal(''); - readonly isLoading = signal(true); - readonly error = signal(null); - readonly isDeleting = signal(false); - readonly currentUserRole = signal('Owner'); readonly teamUrl = signal(''); readonly teamId = signal(''); + readonly logoBase64 = signal(null); + readonly pendingLogoBase64 = signal(null); - readonly selectedImageUrl = signal(null); - readonly selectedFile = signal(null); - readonly isUploading = signal(false); - readonly uploadError = signal(null); - readonly isDragOver = signal(false); - - readonly isProcessing = computed(() => this.isUploading() || this.isLoading()); - readonly canUpload = computed(() => !this.isUploading() && this.fileInput()); - readonly hasSelectedImage = computed(() => !!this.selectedImageUrl() && !this.isUploading()); - readonly showUploadArea = computed(() => !this.selectedImageUrl() && !this.isUploading()); + readonly isLoading = signal(true); + readonly isSaving = signal(false); + readonly error = signal(null); + readonly currentUserRole = signal('Owner'); - eventForm: FormGroup; - private nameChangeSubscription?: Subscription; - private routeSubscription?: Subscription; + readonly isProcessing = computed(() => this.isLoading() || this.isSaving()); + readonly canSave = computed(() => + !this.isProcessing() && + this.eventForm.valid && + this.currentUserRole() === 'Owner' + ); + readonly eventForm: FormGroup; private readonly BASE_URL = 'https://speaker-space.io/event/'; - private readonly MAX_FILE_SIZE = 300 * 1024; - private readonly ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'] as const; - - constructor( - private readonly route: ActivatedRoute, - private readonly eventService: EventService, - private readonly eventDataService: EventDataService, - private readonly fb: FormBuilder, - private readonly router: Router, - ) { - this.eventForm = this.initializeForm(); + + constructor() { + this.eventForm = this.createForm(); } ngOnInit(): void { - this.activeSection.set('event-customize'); - this.isLoading.set(true); - this.checkForEmailModal(); - this.currentUserRole.set('Owner'); - this.subscribeToRouteParams(); + this.setupFormEffects(); + this.setupRouteListener(); } - ngOnDestroy(): void { - this.unsubscribeAll(); - this.cleanupImageUrl(); + private createForm(): FormGroup { + return this.fb.group({ + eventName: ['', Validators.required], + eventURL: [{ value: '', disabled: true }], + weblink: [''] + }); } - private compressImage(file: File): Promise { - return new Promise((resolve) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - const img = new Image(); - - img.onload = () => { - const maxSize = 500; - let { width, height } = img; - - if (width > height) { - if (width > maxSize) { - height = height * (maxSize / width); - width = maxSize; - } - } else { - if (height > maxSize) { - width = width * (maxSize / height); - height = maxSize; - } - } + private setupFormEffects(): void { + this.eventForm.get('eventName')?.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(name => { + const urlSuffix = this.formatUrlFromName(name || ''); + this.eventForm.patchValue({ eventURL: this.BASE_URL + urlSuffix }, { emitEvent: false }); + }); + } - canvas.width = width; - canvas.height = height; - ctx.drawImage(img, 0, 0, width, height); + private setupRouteListener(): void { + this.route.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(params => { + const eventId = params.get('eventId') || ''; + this.eventId.set(eventId); - canvas.toBlob((blob) => { - if (blob) { - resolve(new File([blob], file.name, { type: 'image/jpeg' })); - } else { - resolve(file); + if (!eventId) { + this.error.set('Event ID is missing from route parameters'); + this.isLoading.set(false); + throw new Error('No event ID'); } - }, 'image/jpeg', 0.8); - }; - img.src = URL.createObjectURL(file); - }); + this.isLoading.set(true); + return this.eventService.getEventById(eventId); + }) + ) + .subscribe({ + next: (event) => this.handleEventLoaded(event), + error: (err) => this.handleLoadError(err) + }); } - private handleEventDataLoaded(event: any): void { + private handleEventLoaded(event: any): void { this.eventId.set(event.idEvent || this.eventId()); this.eventName.set(event.eventName || ''); this.eventUrl.set(event.url || ''); this.teamUrl.set(event.teamUrl || ''); this.teamId.set(event.teamId || ''); - this.currentUserRole.set('Owner'); + this.logoBase64.set(event.logoBase64 || null); - const urlSuffix = this.extractOrGenerateUrlSuffix(event); + const urlSuffix = this.extractUrlSuffix(event); this.eventForm.patchValue({ eventName: event.eventName || '', eventURL: this.BASE_URL + urlSuffix, weblink: event.weblink || '' - }); + }, { emitEvent: false }); - if (event.logoBase64) { - this.selectedImageUrl.set(event.logoBase64); - } + this.eventDataService.loadEvent({ + idEvent: event.idEvent || this.eventId(), + eventName: event.eventName || '', + teamId: event.teamId || '', + url: event.url || '', + teamUrl: event.teamUrl, + type: event.type, + }); - this.setupNameChangeListener(); this.error.set(null); + this.isLoading.set(false); } - onSubmit(): void { - if (this.eventForm.invalid) { - return; - } + private handleLoadError(err: any): void { + console.error('Error loading event:', err); + this.error.set('Failed to load event details. Please try again.'); - if (!this.eventId()) { - this.error.set('Event ID is missing - cannot update event'); - return; - } + this.isLoading.set(false); + } - if (this.selectedFile()) { - this.uploadImageAndNavigate(); - return; - } + onImageSelected(base64: string): void { + this.pendingLogoBase64.set(base64); + } - this.updateEventAndNavigate(); + onImageRemoved(): void { + this.pendingLogoBase64.set(''); } - private async uploadImageAndNavigate(): Promise { - const file = this.selectedFile(); - const eventId = this.eventId(); + onSubmit(): void { + if (!this.canSave()) { + return; + } - if (!file || !eventId) { + const eventId = this.eventId(); + if (!eventId) { + this.error.set('Event ID is missing - cannot update event'); return; } - this.isUploading.set(true); - this.uploadError.set(null); + this.isSaving.set(true); + this.error.set(null); - try { - const compressedFile = await this.compressImage(file); + const formData = this.eventForm.getRawValue() as EventFormData; + const pendingLogo = this.pendingLogoBase64(); - if (compressedFile.size > this.MAX_FILE_SIZE) { - this.uploadError.set('L\'image est encore trop volumineuse après compression. Essayez une image plus petite.'); - return; - } + const updateData: any = { + idEvent: eventId, + eventName: formData.eventName, + url: formData.eventURL.replace(this.BASE_URL, ''), + webLinkUrl: formData.weblink, + }; - const base64Image = await this.convertToBase64(compressedFile); - const updateData = { - idEvent: eventId, - logoBase64: base64Image - }; + if (pendingLogo !== null) { + updateData.logoBase64 = pendingLogo; + } - this.eventService.updateEvent(updateData).subscribe({ + this.eventService.updateEvent(updateData) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: (response) => { - console.log('Logo saved successfully:', response); - this.handleImageUploadSuccess(base64Image); - this.navigateToTeam(); + this.handleUpdateSuccess(response); + this.isSaving.set(false); // ✅ Désactive le saving }, error: (err) => { - this.handleImageUploadError(err); - }, - complete: () => { - this.isUploading.set(false); + console.error('Update error:', err); + this.error.set('Failed to update event. Please try again.'); + this.isSaving.set(false); // ✅ Désactive le saving } }); - - } catch (error) { - console.error('Error processing image:', error); - this.uploadError.set('Erreur lors du traitement de l\'image.'); - this.isUploading.set(false); - } } - private updateEventAndNavigate(): void { - const formValues = this.eventForm.getRawValue(); - const updatedEvent = { - idEvent: this.eventId(), - eventName: formValues.eventName, - url: formValues.eventURL.replace(this.BASE_URL, ''), - webLinkUrl: formValues.weblink, - }; + private handleUpdateSuccess(response: any): void { + this.eventName.set(response.eventName || response.name); + this.eventUrl.set(response.url || ''); - this.isLoading.set(true); + if (this.pendingLogoBase64() !== null) { + this.logoBase64.set(this.pendingLogoBase64()); + this.pendingLogoBase64.set(null); + } - this.eventService.updateEvent(updatedEvent) - .pipe(finalize(() => this.isLoading.set(false))) - .subscribe({ - next: (response) => { - this.handleEventUpdated(response); - this.navigateToTeam(); - }, - error: (err) => { - this.handleEventUpdateError(err); - } - }); + this.navigateToTeam(); } private navigateToTeam(): void { @@ -240,264 +220,21 @@ export class CustomizeEventComponent implements OnInit, OnDestroy { } } - private subscribeToRouteParams(): void { - this.routeSubscription = this.route.paramMap.subscribe(params => { - const eventIdParam = params.get('eventId') || ''; - this.eventId.set(eventIdParam); - - if (eventIdParam) { - this.loadEventData(); - } else { - this.error.set('Event ID is missing from route parameters'); - this.isLoading.set(false); - } - }); - } - - loadEventData(): void { - const eventId = this.eventId(); - if (!eventId) { - this.error.set('Event ID is required to load event data'); - this.isLoading.set(false); - return; - } - - this.eventService.getEventById(eventId) - .pipe(finalize(() => this.isLoading.set(false))) - .subscribe({ - next: (event) => { - this.handleEventDataLoaded(event); - this.eventUrl.set(event.url || ''); - - this.eventDataService.loadEvent({ - idEvent: event.idEvent || eventId, - eventName: event.eventName || '', - teamId: event.teamId || '', - url: event.url || '', - teamUrl: event.teamUrl, - type: event.type, - }); - }, - error: (err) => { - this.handleEventDataError(err); - } - }); - } - - triggerFileInput(): void { - const fileInputRef = this.fileInput(); - if (this.canUpload() && fileInputRef) { - fileInputRef.nativeElement.click(); - } - } - - onFileSelected(event: Event): void { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - this.handleFile(input.files[0]); - } - } - - onDragOver(event: DragEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(true); - } - - onDragLeave(event: DragEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); - } - - onDrop(event: DragEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); - - const files = event.dataTransfer?.files; - if (files && files.length > 0) { - this.handleFile(files[0]); - } - } - - private handleFile(file: File): void { - this.uploadError.set(null); - - if (!this.ALLOWED_TYPES.includes(file.type as any)) { - this.uploadError.set('Unsupported file format. Use JPEG, PNG, WEBP, or AVIF.'); - return; - } - - if (file.size > this.MAX_FILE_SIZE) { - this.uploadError.set('The file is too large. Maximum size: 300KB.'); - return; - } - - this.cleanupImageUrl(); - this.selectedFile.set(file); - this.selectedImageUrl.set(URL.createObjectURL(file)); - } - - onImageError(event: Event): void { - console.error('Error loading image:', event); - this.uploadError.set('Erreur lors du chargement de l\'image.'); - this.selectedImageUrl.set(null); - } - - removeImage(event: Event): void { - event.stopPropagation(); - - const imageUrl = this.selectedImageUrl(); - const isExistingImage = imageUrl && imageUrl.startsWith('data:image'); - - if (isExistingImage) { - this.removeImageFromServer(); - } else { - this.resetImageState(); - } - } - - private removeImageFromServer(): void { - const updateData = { - idEvent: this.eventId(), - logoBase64: '' - }; - - this.isUploading.set(true); - this.eventService.updateEvent(updateData).subscribe({ - next: (response) => { - console.log('Logo deleted successfully', response); - if (!response.logoBase64 || response.logoBase64.trim() === '') { - this.resetImageState(); - } else { - this.uploadError.set('Erreur: le logo n\'a pas été supprimé côté serveur.'); - } - }, - error: (err) => { - console.error('Error deleting logo:', err); - this.uploadError.set('Erreur lors de la suppression du logo.'); - }, - complete: () => { - this.isUploading.set(false); - } - }); - } - - private resetImageState(): void { - this.cleanupImageUrl(); - this.selectedImageUrl.set(null); - this.selectedFile.set(null); - this.uploadError.set(null); - - const fileInputRef = this.fileInput(); - if (fileInputRef) { - fileInputRef.nativeElement.value = ''; - } - } - - private cleanupImageUrl(): void { - const imageUrl = this.selectedImageUrl(); - if (imageUrl && imageUrl.startsWith('blob:')) { - URL.revokeObjectURL(imageUrl); - } - } - - private handleImageUploadSuccess(base64Image: string): void { - this.selectedImageUrl.set(base64Image); - this.cleanupImageUrl(); - } - - private handleImageUploadError(err: any): void { - console.error('Upload error:', err); - this.uploadError.set('Erreur lors de la sauvegarde. Veuillez réessayer.'); - } - - private initializeForm(): FormGroup { - return this.fb.group({ - eventName: [{ value: '', disabled: false }, Validators.required], - eventURL: { value: '', disabled: true }, - weblink: [''] - }); - } - - private checkForEmailModal(): void { - this.route.queryParams.subscribe(params => { - const showEmailModal = params['showEmailModal']; - - if (showEmailModal === 'true') { - const modal = document.getElementById('crud-modal'); - modal?.classList.remove('hidden'); - } - }); - } - - private extractOrGenerateUrlSuffix(event: any): string { + private extractUrlSuffix(event: any): string { if (event.url) { - if (event.url.startsWith(this.BASE_URL)) { - return event.url.substring(this.BASE_URL.length); - } - return event.url; + return event.url.startsWith(this.BASE_URL) + ? event.url.substring(this.BASE_URL.length) + : event.url; } - return this.formatUrlFromName(event.eventName || ''); } private formatUrlFromName(name: string): string { return name.trim() .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-'); - } - - private handleEventDataError(err: any): void { - console.error('Error loading event data:', err); - this.error.set('Failed to load event details. Please try again.'); - this.isLoading.set(false); - } - - private setupNameChangeListener(): void { - if (this.nameChangeSubscription) { - this.nameChangeSubscription.unsubscribe(); - } - - const nameControl = this.eventForm.get('eventName'); - if (nameControl) { - this.nameChangeSubscription = nameControl.valueChanges.subscribe(value => { - if (value) { - const urlSuffix = this.formatUrlFromName(value); - this.eventForm.get('eventURL')?.setValue(this.BASE_URL + urlSuffix); - } else { - this.eventForm.get('eventURL')?.setValue(this.BASE_URL); - } - }); - } - } - - private handleEventUpdated(event: any): void { - this.eventName.set(event.eventName || event.name); - this.eventUrl.set(event.url || ''); - } - - private handleEventUpdateError(err: any): void { - this.error.set('Failed to update event. Please try again.'); - } - - private unsubscribeAll(): void { - this.nameChangeSubscription?.unsubscribe(); - this.routeSubscription?.unsubscribe(); - } - - private convertToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result as string; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); } } diff --git a/front/src/app/feature/admin-management/services/event/image-upload.service.spec.ts b/front/src/app/feature/admin-management/services/event/image-upload.service.spec.ts new file mode 100644 index 00000000..d81f5698 --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/image-upload.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ImageUploadService } from './image-upload.service'; + +describe('ImageUploadService', () => { + let service: ImageUploadService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ImageUploadService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/image-upload.service.ts b/front/src/app/feature/admin-management/services/event/image-upload.service.ts new file mode 100644 index 00000000..960752fc --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/image-upload.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@angular/core'; +import { Observable, from } from 'rxjs'; +import {ImageUploadConfig, ImageUploadResult} from '../../type/event/image-upload'; + +@Injectable({ providedIn: 'root' }) +export class ImageUploadService { + private readonly DEFAULT_CONFIG: ImageUploadConfig = { + maxFileSize: 300 * 1024, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'], + maxDimension: 500, + compressionQuality: 0.8 + }; + + validateFile(file: File, config = this.DEFAULT_CONFIG): { valid: boolean; error?: string } { + if (!config.allowedTypes.includes(file.type)) { + return { + valid: false, + error: 'Format non supporté. Utilisez JPEG, PNG, WEBP ou AVIF.' + }; + } + + if (file.size > config.maxFileSize) { + return { + valid: false, + error: `Fichier trop volumineux. Taille max: ${config.maxFileSize / 1024}KB.` + }; + } + + return { valid: true }; + } + + compressImage(file: File, config = this.DEFAULT_CONFIG): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Canvas context not available')); + return; + } + + const img = new Image(); + + img.onload = () => { + const { width, height } = this.calculateDimensions( + img.width, + img.height, + config.maxDimension + ); + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (blob) { + resolve(new File([blob], file.name, { type: 'image/jpeg' })); + } else { + reject(new Error('Compression failed')); + } + }, + 'image/jpeg', + config.compressionQuality + ); + + URL.revokeObjectURL(img.src); + }; + + img.onerror = () => { + URL.revokeObjectURL(img.src); + reject(new Error('Image loading failed')); + }; + + img.src = URL.createObjectURL(file); + }); + } + + convertToBase64(file: File): Observable { + return from( + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }) + ); + } + + async processImage(file: File, config = this.DEFAULT_CONFIG): Promise { + const validation = this.validateFile(file, config); + if (!validation.valid) { + throw new Error(validation.error); + } + + const compressedFile = await this.compressImage(file, config); + const base64 = await this.convertToBase64(compressedFile).toPromise(); + + if (!base64) { + throw new Error('Base64 conversion failed'); + } + + return { + base64, + file: compressedFile, + originalSize: file.size, + compressedSize: compressedFile.size + }; + } + + private calculateDimensions(width: number, height: number, maxSize: number): { width: number; height: number } { + if (width <= maxSize && height <= maxSize) { + return { width, height }; + } + + const ratio = width / height; + + if (width > height) { + return { width: maxSize, height: Math.round(maxSize / ratio) }; + } else { + return { width: Math.round(maxSize * ratio), height: maxSize }; + } + } +} diff --git a/front/src/app/feature/admin-management/type/event/image-upload.ts b/front/src/app/feature/admin-management/type/event/image-upload.ts new file mode 100644 index 00000000..3ed4e8d9 --- /dev/null +++ b/front/src/app/feature/admin-management/type/event/image-upload.ts @@ -0,0 +1,13 @@ +export type ImageUploadConfig = { + maxFileSize: number; + allowedTypes: readonly string[]; + maxDimension: number; + compressionQuality: number; +} + +export type ImageUploadResult = { + base64: string; + file: File; + originalSize: number; + compressedSize: number; +} From 18a40191f237553e41d5728f31a72eee528f1ee9 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:17:38 +0200 Subject: [PATCH 06/23] reduce session-detail-page --- .../session-schedule-form.component.html | 108 ++++++ .../session-schedule-form.component.scss | 0 .../session-schedule-form.component.spec.ts | 23 ++ .../session-schedule-form.component.ts | 33 ++ .../session-speakers.component.html | 23 ++ .../session-speakers.component.scss | 0 .../session-speakers.component.spec.ts | 23 ++ .../session-speakers.component.ts | 14 + .../customize-event.component.ts | 4 +- .../session-detail-page.component.html | 211 +++-------- .../session-detail-page.component.ts | 336 ++---------------- .../session-formatter.service.spec.ts | 16 + .../sessions/session-formatter.service.ts | 68 ++++ .../session-schedule-form.service.spec.ts | 16 + .../sessions/session-schedule-form.service.ts | 167 +++++++++ .../type/session/schedule-json-data.ts | 12 + 16 files changed, 583 insertions(+), 471 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.html create mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss create mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.html create mode 100644 front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss create mode 100644 front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-formatter.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.ts diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.html b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.html new file mode 100644 index 00000000..f15a9b12 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.html @@ -0,0 +1,108 @@ + + @if (formService.error(); as error) { + + } + +
+
+ + +
+ +
+ +
+ + +
+ + {{ formService.selectedDuration() }} min + + + + @if (formService.showDurationDropdown()) { +
    + @for (duration of formService.durations; track duration.value) { +
  • + + {{ duration.label }} + +
  • + } +
+ } +
+
+
+ +
+ + @if (availableTracks().length > 0) { + + } @else { + + } +
+
+ +
+ + Cancel + + + + @if (formService.isUpdating()) { + Saving... + } @else { + Save + } + +
+ diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.spec.ts b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.spec.ts new file mode 100644 index 00000000..5c25e683 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionScheduleFormComponent } from './session-schedule-form.component'; + +describe('SessionScheduleFormComponent', () => { + let component: SessionScheduleFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionScheduleFormComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionScheduleFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.ts b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.ts new file mode 100644 index 00000000..0a7f826b --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.ts @@ -0,0 +1,33 @@ +import {Component, HostListener, inject, input, output} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; +import {ButtonComponent} from '../../../../../shared/button/button.component'; +import {SessionScheduleFormService} from '../../../services/sessions/session-schedule-form.service'; + +@Component({ + selector: 'app-session-schedule-form', + imports: [ + ReactiveFormsModule, + ButtonComponent + ], + templateUrl: './session-schedule-form.component.html', + styleUrl: './session-schedule-form.component.scss' +}) +export class SessionScheduleFormComponent { + readonly formService = inject(SessionScheduleFormService); + + readonly availableTracks = input.required(); + readonly save = output(); + readonly cancel = output(); + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + const target = event.target as HTMLElement; + if (!target.closest('.relative')) { + this.formService.showDurationDropdown.set(false); + } + } + + onSubmit(): void { + this.save.emit(); + } +} diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.html b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.html new file mode 100644 index 00000000..31afbe40 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.html @@ -0,0 +1,23 @@ +
+
+ @for (speaker of speakers(); track speaker.name) { + + } +
+
diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.spec.ts b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.spec.ts new file mode 100644 index 00000000..72e809ba --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionSpeakersComponent } from './session-speakers.component'; + +describe('SessionSpeakersComponent', () => { + let component: SessionSpeakersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionSpeakersComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionSpeakersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts new file mode 100644 index 00000000..f5dca0ad --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts @@ -0,0 +1,14 @@ +import {Component, input, output} from '@angular/core'; +import {Speaker} from '../../../type/session/session'; + +@Component({ + selector: 'app-session-speakers', + imports: [], + templateUrl: './session-speakers.component.html', + styleUrl: './session-speakers.component.scss' +}) +export class SessionSpeakersComponent { + readonly speakers = input.required(); + readonly speakerClick = output(); + readonly imageError = output(); +} diff --git a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts index 93bc45b5..d49d6a40 100644 --- a/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts +++ b/front/src/app/feature/admin-management/pages/events/customize-event/customize-event.component.ts @@ -189,12 +189,12 @@ export class CustomizeEventComponent implements OnInit { .subscribe({ next: (response) => { this.handleUpdateSuccess(response); - this.isSaving.set(false); // ✅ Désactive le saving + this.isSaving.set(false); }, error: (err) => { console.error('Update error:', err); this.error.set('Failed to update event. Please try again.'); - this.isSaving.set(false); // ✅ Désactive le saving + this.isSaving.set(false); } }); } diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html index cba0509d..49fa482d 100644 --- a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html @@ -9,13 +9,13 @@
@if (session(); as sessionData) { -
+
-
-

+

{{ sessionData.title }} -

+ Edit -
+
-
-
- @if (sessionData.speakers && sessionData.speakers.length > 0) { - @for (speaker of sessionData.speakers; track speaker.name) { -
-
- -
- -
-

- {{ speaker.name }} -

-
-
- } - } -
-
+ @if (sessionData.speakers && sessionData.speakers.length > 0) { + + + } -
+
@if (sessionData.abstractText) {

{{ sessionData.abstractText }}

} @else {

No description available

} -
+ @if (format() || (sessionData.formats && sessionData.formats.length > 0)) { -
-

Formats

+
+

Formats

@if (format(); as formatData) { -
+ {{ formatData.name }} -
+ }
-
+ } @if (category() || (sessionData.categories && sessionData.categories.length > 0)) { -
-

Categories

+
+

Categories

@if (category(); as categoryData) { -
+ {{ categoryData.name }} -
+ }
-
+ }
@if (sessionData.level) { -
- {{ formatLevel(sessionData.level) }} -
+ + {{ formatter.formatLevel(sessionData.level) }} + } @if (sessionData.languages && sessionData.languages.length > 0) { @for (language of sessionData.languages; track language) { -
- {{ formatLanguage(language) }} -
+ + {{ formatter.formatLanguage(language) }} + } }
-
+
-

Schedule

+

Schedule

- @if (!isEditingSchedule() && hasScheduleInfo()) { -

- } @else if (!isEditingSchedule()) { + @if (!scheduleFormService.isEditing() && hasScheduleInfo()) { +

+ } @else if (!scheduleFormService.isEditing()) {

Schedule information not yet available

}
- @if (isEditingSchedule()) { -
- @if (scheduleError()) { - - } - -
-
- - -
- -
- -
- - -
- - {{ selectedDuration() }} min - - - - @if (showDurationDropdown()) { -
    - @for (duration of durations; track duration.value) { -
  • - - {{ duration.label }} - -
  • - } -
- } -
-
-
- -
- - @if (availableTracks().length > 0) { - - } @else { - - } -
-
- -
- - Cancel - - - - @if (isUpdatingSchedule()) { - Saving... - } @else { - Save - } - -
-
+ @if (scheduleFormService.isEditing()) { + + } -
-
+ +
} @else {
diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts index 5c9a2716..39664247 100644 --- a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.ts @@ -1,56 +1,52 @@ -import { Component, DestroyRef, HostListener, inject, OnInit, signal, computed } from '@angular/core'; +import { Component, inject, OnInit, signal, computed } from '@angular/core'; import { Category, Format, SessionImportData } from '../../../type/session/session'; -import { finalize, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { SessionService } from '../../../services/sessions/session.service'; import { NavbarSessionPageComponent } from '../../../components/session/navbar-session-page/navbar-session-page.component'; -import { - AbstractControl, - FormBuilder, - FormGroup, - ReactiveFormsModule, - ValidationErrors, - Validators -} from '@angular/forms'; -import { SessionScheduleUpdate } from '../../../type/session/schedule-json-data'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isDefined } from '../../../../../shared/type/predicates'; import { BaseDetailService, DetailState } from '../../../components/services/base-detail.service'; import { AsyncPipe } from '@angular/common'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; - -interface DurationOption { - readonly label: string; - readonly value: number; -} +import { ButtonComponent } from '../../../../../shared/button/button.component'; +import {SessionSpeakersComponent} from '../../../components/session/session-speakers/session-speakers.component'; +import { + SessionScheduleFormComponent +} from '../../../components/session/session-schedule-form/session-schedule-form.component'; +import {SessionScheduleFormService} from '../../../services/sessions/session-schedule-form.service'; +import {SessionFormatterService} from '../../../services/sessions/session-formatter.service'; @Component({ selector: 'app-session-detail-page', standalone: true, imports: [ NavbarSessionPageComponent, - ReactiveFormsModule, AsyncPipe, - ButtonComponent + ButtonComponent, + SessionSpeakersComponent, + SessionScheduleFormComponent ], - providers: [BaseDetailService], + providers: [BaseDetailService, SessionScheduleFormService], templateUrl: './session-detail-page.component.html', styleUrl: './session-detail-page.component.scss' }) export class SessionDetailPageComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly sessionService = inject(SessionService); + private readonly router = inject(Router); + readonly detailService = inject(BaseDetailService); + readonly scheduleFormService = inject(SessionScheduleFormService); + readonly formatter = inject(SessionFormatterService); + readonly sessionId = signal(''); readonly session = signal(null); readonly format = signal(null); readonly category = signal(null); - readonly isEditingSchedule = signal(false); - readonly isUpdatingSchedule = signal(false); - readonly scheduleError = signal(null); - readonly showDurationDropdown = signal(false); readonly availableTracks = signal([]); - readonly selectedDuration = signal(60); readonly hasSessionData = computed(() => !!this.session()); - readonly canEditSchedule = computed(() => this.hasSessionData() && !this.isUpdatingSchedule()); + readonly canEditSchedule = computed(() => + this.hasSessionData() && !this.scheduleFormService.isUpdating() + ); readonly hasScheduleInfo = computed(() => { const sessionData = this.session(); return isDefined(sessionData?.start || sessionData?.track); @@ -59,82 +55,12 @@ export class SessionDetailPageComponent implements OnInit { readonly formattedCompleteSessionInfo = computed(() => { const sessionData = this.session(); if (!sessionData) return ''; - - const parts: string[] = []; - - if (sessionData.start) { - try { - const options: Intl.DateTimeFormatOptions = { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric' - }; - - const dateStr = sessionData.start.toLocaleDateString('en-US', options); - const timeStr = sessionData.start.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }); - - parts.push(` ${dateStr} at ${timeStr}`); - } catch (error) { - console.error('Error formatting date:', error); - } - } - - if (sessionData.track) { - parts.push(`in room ${this.getTrackName()}`); - } - - return parts.join(' '); + return this.formatter.formatCompleteSessionInfo(sessionData.start, sessionData.track); }); - readonly formattedTrackName = computed(() => { - const sessionData = this.session(); - if (!sessionData?.track) return ''; - - if (sessionData.track.includes(' ')) { - return sessionData.track; - } - - return sessionData.track - .replace(/_/g, ' ') - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); - }); - - scheduleForm!: FormGroup; - - readonly durations: readonly DurationOption[] = [20, 30, 40, 45, 50, 60, 75, 90, 105, 110, 120, 130].map(val => { - const hours = Math.floor(val / 60); - const minutes = val % 60; - let label = ''; - if (hours > 0) { - label += `${hours} hour${hours > 1 ? 's' : ''}`; - } - if (minutes > 0) { - if (hours > 0) label += ' '; - label += `${minutes} minute${minutes > 1 ? 's' : ''}`; - } - return { label, value: val } as const; - }); - - readonly detailService = inject(BaseDetailService); readonly state$: Observable = this.detailService.state$; - private readonly destroyRef = inject(DestroyRef); - - constructor( - private readonly route: ActivatedRoute, - private readonly sessionService: SessionService, - private readonly router: Router, - private readonly fb: FormBuilder - ) {} ngOnInit(): void { - this.initializeScheduleForm(); this.initializeRouteSubscription(); } @@ -168,227 +94,35 @@ export class SessionDetailPageComponent implements OnInit { } } - private initializeScheduleForm(): void { - this.scheduleForm = this.fb.group({ - startDate: ['', Validators.required], - startTime: ['', Validators.required], - duration: [60, [Validators.required, Validators.min(15)]], - track: ['', [Validators.maxLength(50)]] - }, { - validators: [this.scheduleValidator.bind(this)] - }); - } - - private scheduleValidator(control: AbstractControl): ValidationErrors | null { - const startDate = control.get('startDate')?.value; - const startTime = control.get('startTime')?.value; - const duration = control.get('duration')?.value; - - if (!startDate || !startTime || !duration) { - return null; - } - - if (duration <= 0) { - return { invalidDuration: true }; - } - - return null; - } - - @HostListener('document:click', ['$event']) - onDocumentClick(event: Event): void { - const target = event.target as HTMLElement; - if (!target.closest('.relative')) { - this.showDurationDropdown.set(false); - } - } - - onImageError = (event: Event): void => { + onImageError(event: Event): void { this.detailService.handleImageError(event); - }; + } onEditSession(): void { if (!this.canEditSchedule()) return; - this.isEditingSchedule.set(true); - this.scheduleError.set(null); - this.populateScheduleForm(); - } - - private populateScheduleForm(): void { const sessionData = this.session(); - if (!sessionData || !this.scheduleForm) return; - - const formValues: any = { - track: sessionData.track || '', - startDate: this.getStartDateValue(), - startTime: this.getStartTimeValue() - }; - - if (sessionData.start && sessionData.end) { - try { - const startDate = new Date(sessionData.start); - const endDate = new Date(sessionData.end); - const durationMinutes = Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60)); - - if (durationMinutes > 0) { - formValues.duration = durationMinutes; - this.selectedDuration.set(durationMinutes); - } - } catch (error) { - console.warn('Error calculating duration:', error); - formValues.duration = 60; - this.selectedDuration.set(60); - } - } else { - formValues.duration = 60; - this.selectedDuration.set(60); + if (sessionData) { + this.scheduleFormService.startEditing(sessionData); } - - this.scheduleForm.patchValue(formValues); - } - - getStartTimeValue(): string { - const sessionData = this.session(); - if (sessionData?.start) { - try { - return this.formatTimeForInput(new Date(sessionData.start)); - } catch (error) { - console.warn('Error formatting start time:', error); - return ''; - } - } - return ''; - } - - getStartDateValue(): string { - const sessionData = this.session(); - if (sessionData?.start) { - try { - return this.formatDateForInput(new Date(sessionData.start)); - } catch (error) { - console.warn('Error formatting start date:', error); - return ''; - } - } - return ''; - } - - getTrackName(): string { - return this.formattedTrackName(); - } - - public formatDateForInput(date: Date): string { - if (!date || isNaN(date.getTime())) { - return ''; - } - return date.toISOString().split('T')[0]; - } - - public formatTimeForInput(date: Date): string { - if (!date || isNaN(date.getTime())) { - return ''; - } - return date.toTimeString().slice(0, 5); - } - - onDurationSelect(duration: number): void { - this.selectedDuration.set(duration); - this.scheduleForm.patchValue({ duration: duration }); - this.showDurationDropdown.set(false); } onSaveSchedule(): void { - if (!this.scheduleForm || this.scheduleForm.invalid || this.isUpdatingSchedule()) { - return; - } - - const formValues = this.scheduleForm.value; - const startDate = this.combineDateAndTime(formValues.startDate, formValues.startTime); - - if (!startDate) { - this.scheduleError.set('Please provide a valid start date and time'); - return; - } - - const endDate = this.calculateEndDate(startDate, formValues.duration); const currentState = this.detailService.getCurrentState(); - const scheduleUpdate: SessionScheduleUpdate = { - start: startDate, - end: endDate, - track: formValues.track?.trim() || undefined - }; - - this.isUpdatingSchedule.set(true); - this.scheduleError.set(null); - - this.sessionService.updateSessionSchedule(currentState.eventId, this.sessionId(), scheduleUpdate) - .pipe( - finalize(() => this.isUpdatingSchedule.set(false)), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe({ - next: (updatedSession) => { - this.session.set(updatedSession); - this.isEditingSchedule.set(false); - this.scheduleError.set(null); - }, - error: (error) => { - console.error('Error updating session schedule:', error); - this.scheduleError.set(error.error?.message || 'Failed to update session schedule'); - } - }); + this.scheduleFormService.saveSchedule( + currentState.eventId, + this.sessionId(), + (updatedSession) => this.session.set(updatedSession) + ); } onCancelScheduleEdit(): void { - this.isEditingSchedule.set(false); - this.scheduleError.set(null); - this.showDurationDropdown.set(false); - if (this.scheduleForm) { - this.scheduleForm.reset(); - } - } - - formatLevel(level: string): string { - if (!level) return ''; - return level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); - } - - formatLanguage(languageCode: string): string { - if (!languageCode) return ''; - - try { - const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }); - const languageName = displayNames.of(languageCode.toLowerCase()); - - return languageName ? - languageName.charAt(0).toUpperCase() + languageName.slice(1) : - languageCode.toUpperCase(); - - } catch (error) { - console.warn(`Unable to format language code: ${languageCode}`, error); - return languageCode.toUpperCase(); - } + this.scheduleFormService.cancelEditing(); } - openItemDetail(speakerId: string): void { + onSpeakerClick(speakerId: string): void { const currentState = this.detailService.getCurrentState(); this.router.navigate(['event', currentState.eventId, 'speaker', speakerId]); } - - private calculateEndDate(startDate: Date, durationMinutes: number): Date { - return new Date(startDate.getTime() + (durationMinutes * 60 * 1000)); - } - - private combineDateAndTime(dateStr: string, timeStr: string): Date | null { - if (!dateStr || !timeStr) return null; - const combinedStr = `${dateStr}T${timeStr}:00`; - const date = new Date(combinedStr); - return isNaN(date.getTime()) ? null : date; - } - - formatCompleteSessionInfo(): string { - return this.formattedCompleteSessionInfo(); - } } diff --git a/front/src/app/feature/admin-management/services/sessions/session-formatter.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.spec.ts new file mode 100644 index 00000000..da496ad4 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SessionFormatterService } from './session-formatter.service'; + +describe('SessionFormatterService', () => { + let service: SessionFormatterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionFormatterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts new file mode 100644 index 00000000..682e566c --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class SessionFormatterService { + + formatLevel(level: string): string { + if (!level) return ''; + return level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); + } + + formatLanguage(languageCode: string): string { + if (!languageCode) return ''; + + try { + const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }); + const languageName = displayNames.of(languageCode.toLowerCase()); + + return languageName + ? languageName.charAt(0).toUpperCase() + languageName.slice(1) + : languageCode.toUpperCase(); + } catch (error) { + console.warn(`Unable to format language code: ${languageCode}`, error); + return languageCode.toUpperCase(); + } + } + + formatTrackName(track: string): string { + if (!track) return ''; + if (track.includes(' ')) return track; + + return track + .replace(/_/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } + + formatCompleteSessionInfo(start?: Date, track?: string): string { + const parts: string[] = []; + + if (start) { + try { + const dateStr = start.toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + }); + const timeStr = start.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + + parts.push(`${dateStr} at ${timeStr}`); + } catch (error) { + console.error('Error formatting date:', error); + } + } + + if (track) { + const formattedTrack = this.formatTrackName(track); + parts.push(`in room ${formattedTrack}`); + } + + return parts.join(' '); + } +} diff --git a/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.spec.ts new file mode 100644 index 00000000..8f6fec25 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SessionScheduleFormService } from './session-schedule-form.service'; + +describe('SessionScheduleFormService', () => { + let service: SessionScheduleFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionScheduleFormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.ts b/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.ts new file mode 100644 index 00000000..af693843 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-schedule-form.service.ts @@ -0,0 +1,167 @@ +import { Injectable, signal, inject, DestroyRef } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors } from '@angular/forms'; +import { SessionService } from './session.service'; +import { finalize } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import {SessionImportData} from '../../type/session/session'; +import {DurationOption, ScheduleFormValues, SessionScheduleUpdate} from '../../type/session/schedule-json-data'; + +@Injectable() +export class SessionScheduleFormService { + private readonly fb = inject(FormBuilder); + private readonly sessionService = inject(SessionService); + private readonly destroyRef = inject(DestroyRef); + + readonly isEditing = signal(false); + readonly isUpdating = signal(false); + readonly error = signal(null); + readonly showDurationDropdown = signal(false); + readonly selectedDuration = signal(60); + + readonly durations: readonly DurationOption[] = this.generateDurations(); + + readonly form: FormGroup = this.createForm(); + + private generateDurations(): readonly DurationOption[] { + return [20, 30, 40, 45, 50, 60, 75, 90, 105, 110, 120, 130].map(val => { + const hours = Math.floor(val / 60); + const minutes = val % 60; + let label = ''; + if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`; + if (minutes > 0) { + if (hours > 0) label += ' '; + label += `${minutes} minute${minutes > 1 ? 's' : ''}`; + } + return { label, value: val } as const; + }); + } + + private createForm(): FormGroup { + return this.fb.group({ + startDate: ['', Validators.required], + startTime: ['', Validators.required], + duration: [60, [Validators.required, Validators.min(15)]], + track: ['', [Validators.maxLength(50)]] + }, { + validators: [this.scheduleValidator.bind(this)] + }); + } + + private scheduleValidator(control: AbstractControl): ValidationErrors | null { + const startDate = control.get('startDate')?.value; + const startTime = control.get('startTime')?.value; + const duration = control.get('duration')?.value; + + if (!startDate || !startTime || !duration) return null; + if (duration <= 0) return { invalidDuration: true }; + + return null; + } + + populateForm(session: SessionImportData): void { + const formValues: ScheduleFormValues = { + track: session.track || '', + startDate: this.formatDateForInput(session.start), + startTime: this.formatTimeForInput(session.start), + duration: 60 + }; + + if (session.start && session.end) { + const durationMinutes = this.calculateDuration(session.start, session.end); + if (durationMinutes > 0) { + formValues.duration = durationMinutes; + this.selectedDuration.set(durationMinutes); + } + } else { + this.selectedDuration.set(60); + } + + this.form.patchValue(formValues); + } + + startEditing(session: SessionImportData): void { + this.isEditing.set(true); + this.error.set(null); + this.populateForm(session); + } + + cancelEditing(): void { + this.isEditing.set(false); + this.error.set(null); + this.showDurationDropdown.set(false); + this.form.reset(); + } + + selectDuration(duration: number): void { + this.selectedDuration.set(duration); + this.form.patchValue({ duration }); + this.showDurationDropdown.set(false); + } + + saveSchedule( + eventId: string, + sessionId: string, + onSuccess: (session: SessionImportData) => void + ): void { + if (this.form.invalid || this.isUpdating()) return; + + const formValues = this.form.value; + const startDate = this.combineDateAndTime(formValues.startDate, formValues.startTime); + + if (!startDate) { + this.error.set('Please provide a valid start date and time'); + return; + } + + const scheduleUpdate: SessionScheduleUpdate = { + start: startDate, + end: this.calculateEndDate(startDate, formValues.duration), + track: formValues.track?.trim() || undefined + }; + + this.isUpdating.set(true); + this.error.set(null); + + this.sessionService.updateSessionSchedule(eventId, sessionId, scheduleUpdate) + .pipe( + finalize(() => this.isUpdating.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (updatedSession) => { + this.isEditing.set(false); + this.error.set(null); + onSuccess(updatedSession); + }, + error: (error) => { + console.error('Error updating session schedule:', error); + this.error.set(error.error?.message || 'Failed to update session schedule'); + } + }); + } + + private formatDateForInput(date?: Date): string { + if (!date || isNaN(date.getTime())) return ''; + return date.toISOString().split('T')[0]; + } + + private formatTimeForInput(date?: Date): string { + if (!date || isNaN(date.getTime())) return ''; + return date.toTimeString().slice(0, 5); + } + + private calculateDuration(start: Date, end: Date): number { + return Math.round((end.getTime() - start.getTime()) / (1000 * 60)); + } + + private calculateEndDate(startDate: Date, durationMinutes: number): Date { + return new Date(startDate.getTime() + (durationMinutes * 60 * 1000)); + } + + private combineDateAndTime(dateStr: string, timeStr: string): Date | null { + if (!dateStr || !timeStr) return null; + const combinedStr = `${dateStr}T${timeStr}:00`; + const date = new Date(combinedStr); + return isNaN(date.getTime()) ? null : date; + } +} diff --git a/front/src/app/feature/admin-management/type/session/schedule-json-data.ts b/front/src/app/feature/admin-management/type/session/schedule-json-data.ts index 746c7132..1bdeb06a 100644 --- a/front/src/app/feature/admin-management/type/session/schedule-json-data.ts +++ b/front/src/app/feature/admin-management/type/session/schedule-json-data.ts @@ -60,3 +60,15 @@ export type SessionScheduleUpdate = { end?: Date; track?: string; } + +export type DurationOption = { + readonly label: string; + readonly value: number; +} + +export type ScheduleFormValues = { + startDate: string; + startTime: string; + duration: number; + track: string; +} From db15134af3b1dc37a4525a4966d4c4be4327aabb Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:13:18 +0200 Subject: [PATCH 07/23] reduce setting-event-page --- .../event-settings-sections.component.html | 25 ++ .../event-settings-sections.component.scss | 0 .../event-settings-sections.component.spec.ts | 23 ++ .../event-settings-sections.component.ts | 20 ++ .../setting-event-page.component.html | 72 ++--- .../setting-event-page.component.ts | 286 +++--------------- .../event-danger-actions.service.spec.ts | 16 + .../event/event-danger-actions.service.ts | 129 ++++++++ .../event/event-settings.service.spec.ts | 16 + .../services/event/event-settings.service.ts | 100 ++++++ .../type/event/event-visibility.ts | 2 + 11 files changed, 400 insertions(+), 289 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.html create mode 100644 front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss create mode 100644 front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-danger-actions.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-danger-actions.service.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-settings.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-settings.service.ts create mode 100644 front/src/app/feature/admin-management/type/event/event-visibility.ts diff --git a/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.html b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.html new file mode 100644 index 00000000..4f2fcdd1 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.html @@ -0,0 +1,25 @@ +
+
+

General Information

+

+ Please note that if the data is modified, it is automatically saved! +

+
+ + + +
+ +
+
+

Event Details

+
+ + + +
diff --git a/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.spec.ts b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.spec.ts new file mode 100644 index 00000000..1bbd4111 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EventSettingsSectionsComponent } from './event-settings-sections.component'; + +describe('EventSettingsSectionsComponent', () => { + let component: EventSettingsSectionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EventSettingsSectionsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(EventSettingsSectionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.ts b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.ts new file mode 100644 index 00000000..7670fabc --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.ts @@ -0,0 +1,20 @@ +import {Component, input} from '@angular/core'; +import {GeneralInfoEventComponent} from '../general-info-event/general-info-event.component'; +import {InformationEventComponent} from '../information-event/information-event.component'; +import {EventDTO} from '../../../type/event/eventDTO'; +import {EventVisibility} from '../../../type/event/event-visibility'; + +@Component({ + selector: 'app-event-settings-sections', + imports: [ + GeneralInfoEventComponent, + InformationEventComponent + ], + templateUrl: './event-settings-sections.component.html', + styleUrl: './event-settings-sections.component.scss' +}) +export class EventSettingsSectionsComponent { + readonly generalData = input.required | null>(); + readonly informationData = input.required | null>(); + readonly visibility = input.required(); +} diff --git a/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.html b/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.html index e593e091..a176ba65 100644 --- a/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.html +++ b/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.html @@ -1,72 +1,52 @@ + [eventUrl]="settingsService.eventUrl()" + [eventId]="settingsService.eventId()" + [eventName]="settingsService.eventName()" + [teamId]="settingsService.teamId()">
- +
- @if (isLoading()) { + @if (settingsService.isLoading()) {
Loading...
} - @if (error()) { + @if (settingsService.error() || dangerActionsService.error()) { } - @if (!isLoading() && !error()) { + @if (!settingsService.isLoading() && !settingsService.error()) { - @if (hasEventData()) { -
-
-

General Information

-

- Please note that if the data is modified, it is automatically saved! -

-
- - - -
- -
-
-

Event Details

-
- - - -
+ @if (settingsService.hasEventData()) { + + } @if (canShowDangerZone()) { @@ -74,17 +54,17 @@

Event Details

} + [eventName]="settingsService.eventName()" + [isOpen]="dangerActionsService.showArchiveConfirmation()" + [isArchiving]="dangerActionsService.isArchiving()" + (confirm)="onArchiveConfirmed()" + (cancel)="onArchiveCancelled()"> diff --git a/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.ts b/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.ts index b368acec..50f3d84c 100644 --- a/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.ts +++ b/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.ts @@ -1,291 +1,91 @@ -import { Component, DestroyRef, inject, OnInit, signal, computed } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { finalize, Subscription } from 'rxjs'; -import { ActivatedRoute, Router } from '@angular/router'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Component, OnInit, inject, computed } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { SidebarEventComponent } from '../../../components/event/sidebar-event/sidebar-event.component'; -import { EventService } from '../../../services/event/event.service'; -import { EventDataService } from '../../../services/event/event-data.service'; -import { EventDTO } from '../../../type/event/eventDTO'; -import { InformationEventComponent } from '../../../components/event/information-event/information-event.component'; -import { GeneralInfoEventComponent } from '../../../components/event/general-info-event/general-info-event.component'; -import { DangerZoneConfig } from '../../../type/components/danger-zone'; import { DangerZoneComponent } from '../../../components/danger-zone/danger-zone.component'; import { ArchiveEventPopupComponent } from '../../../components/event/archive-event-popup/archive-event-popup.component'; -import { DeleteConfirmationConfig } from '../../../type/components/delete-confirmation'; import { DeleteConfirmationPopupComponent } from '../../../components/delete-confirmation-popup/delete-confirmation-popup.component'; import { SessionReviewImportComponent } from '../../../components/session/session-review-import/session-review-import.component'; -import { ImportResult } from '../../../type/session/session'; import { SessionScheduleImportComponent } from '../../../components/session/session-schedule-import/session-schedule-import.component'; - -type UserRole = 'Owner' | 'Admin' | 'Member'; -type EventVisibility = 'private' | 'public'; +import { EventSettingsSectionsComponent } from '../../../components/event/event-settings-sections/event-settings-sections.component'; +import { EventSettingsService } from '../../../services/event/event-settings.service'; +import { EventDangerActionsService } from '../../../services/event/event-danger-actions.service'; @Component({ selector: 'app-setting-event-page', standalone: true, imports: [ NavbarEventPageComponent, - FormsModule, - ReactiveFormsModule, - InformationEventComponent, SidebarEventComponent, - GeneralInfoEventComponent, DangerZoneComponent, ArchiveEventPopupComponent, DeleteConfirmationPopupComponent, SessionReviewImportComponent, SessionScheduleImportComponent, + EventSettingsSectionsComponent ], - templateUrl: './setting-event-page.component.html', - styleUrl: './setting-event-page.component.scss' + providers: [EventSettingsService, EventDangerActionsService], + templateUrl: './setting-event-page.component.html' }) export class SettingEventPageComponent implements OnInit { - readonly eventId = signal(''); - readonly eventUrl = signal(''); - readonly eventName = signal(''); - readonly teamUrl = signal(''); - readonly teamId = signal(''); - readonly isLoading = signal(true); - readonly error = signal(null); - - readonly isDeleting = signal(false); - readonly isArchiving = signal(false); - readonly showDeleteConfirmation = signal(false); - readonly showArchiveConfirmation = signal(false); - readonly currentUserRole = signal('Owner'); - - readonly eventInformationData = signal | null>(null); - readonly eventGeneralData = signal | null>(null); - readonly visibility = signal('private'); - - readonly isProcessing = computed(() => this.isDeleting() || this.isArchiving()); - readonly canShowDangerZone = computed(() => this.currentUserRole() === 'Owner'); - readonly hasEventData = computed(() => !!this.eventGeneralData() && !!this.eventInformationData()); - - readonly dangerZoneConfig = computed(() => ({ - title: 'Danger zone', - entityName: this.eventName(), - entityType: 'event', - showArchiveSection: true, - isDeleting: this.isProcessing(), - currentUserRole: this.currentUserRole() - })); - - readonly deleteConfirmationConfig = computed(() => ({ - entityType: 'event', - entityName: this.eventName(), - title: 'Confirm Event Deletion', - confirmButtonText: 'Delete permanently', - loadingText: 'Deleting...', - requireTextConfirmation: true, - confirmationText: 'DELETE' - })); - - private routeSubscription?: Subscription; - private readonly destroyRef = inject(DestroyRef); - - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly eventService: EventService, - private readonly eventDataService: EventDataService, - ) {} + private readonly route = inject(ActivatedRoute); + readonly settingsService = inject(EventSettingsService); + readonly dangerActionsService = inject(EventDangerActionsService); + + readonly canShowDangerZone = computed(() => + this.settingsService.currentUserRole() === 'Owner' + ); + + readonly dangerZoneConfig = computed(() => + this.dangerActionsService.getDangerZoneConfig( + this.settingsService.eventName(), + this.settingsService.currentUserRole() + ) + ); + + readonly deleteConfirmationConfig = computed(() => + this.dangerActionsService.getDeleteConfirmationConfig( + this.settingsService.eventName() + ) + ); ngOnInit(): void { - this.isLoading.set(true); - this.currentUserRole.set('Owner'); - this.subscribeToRouteParams(); - } + this.subscribeToRouteParams(); } private subscribeToRouteParams(): void { - this.routeSubscription = this.route.paramMap.subscribe(params => { - const eventIdParam = params.get('eventId') || ''; - this.eventId.set(eventIdParam); + this.route.paramMap.subscribe(params => { + const eventId = params.get('eventId'); - if (eventIdParam) { - this.loadEventData(); + if (eventId) { + this.settingsService.loadEventData(eventId); } else { - this.error.set('Event ID is missing from route parameters'); - this.isLoading.set(false); + this.settingsService.error.set('Event ID is missing from route parameters'); + this.settingsService.isLoading.set(false); } }); } - loadEventData(): void { - const eventId = this.eventId(); - if (!eventId) { - this.error.set('Event ID is required to load event data'); - this.isLoading.set(false); - return; - } - - this.eventService.getEventById(eventId) - .pipe( - finalize(() => this.isLoading.set(false)), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe({ - next: (event) => { - this.handleEventDataLoaded(event); - this.eventUrl.set(event.url || ''); - - this.eventDataService.loadEvent({ - idEvent: event.idEvent || eventId, - eventName: event.eventName || '', - teamId: event.teamId || '', - url: event.url || '', - teamUrl: event.teamUrl, - webLinkUrl: event.webLinkUrl, - type: event.type, - }); - }, - error: (err) => { - this.handleEventDataError(err); - } - }); - } - - private handleEventDataLoaded(event: any): void { - this.eventId.set(event.idEvent || this.eventId()); - this.eventName.set(event.eventName || ''); - this.eventUrl.set(event.url || ''); - this.teamUrl.set(event.teamUrl || ''); - this.teamId.set(event.teamId || ''); - this.currentUserRole.set('Owner'); - this.visibility.set(event.isPrivate === true ? 'private' : 'public'); - - this.eventGeneralData.set({ - idEvent: this.eventId(), - eventName: event.eventName, - url: event.url, - conferenceHallUrl: event.conferenceHallUrl, - timeZone: event.timeZone || 'Europe/Paris', - isPrivate: event.isPrivate === true, - type: event.type, - }); - - this.eventInformationData.set({ - idEvent: this.eventId(), - startDate: event.startDate, - endDate: event.endDate, - isOnline: event.isOnline, - location: event.location, - description: event.description, - webLinkUrl: event.webLinkUrl - }); - - this.error.set(null); - } - - private handleEventDataError(err: any): void { - this.error.set('Failed to load event details. Please try again.'); - console.error('Error loading event data:', err); - } - onDangerZoneArchive(): void { - this.confirmArchiveEvent(); + this.dangerActionsService.confirmArchive(); } onDangerZoneDelete(): void { - this.confirmDeleteEvent(); - } - - confirmArchiveEvent(): void { - this.showArchiveConfirmation.set(true); - } - - cancelArchiveEvent(): void { - this.showArchiveConfirmation.set(false); + this.dangerActionsService.confirmDelete(); } - confirmDeleteEvent(): void { - this.showDeleteConfirmation.set(true); - } - - cancelDeleteEvent(): void { - this.showDeleteConfirmation.set(false); - } - - archiveEvent(): void { - const eventId = this.eventId(); - if (!eventId) { - this.error.set('Event ID is missing - cannot archive event'); - return; - } - - this.isArchiving.set(true); - - const archiveData: Partial = { - idEvent: eventId, - isFinish: true - }; - - this.eventService.updateEvent(archiveData) - .pipe( - finalize(() => { - this.isArchiving.set(false); - this.showArchiveConfirmation.set(false); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe({ - next: () => { - this.navigateToHome(); - }, - error: (err) => { - this.handleArchiveError(err); - } - }); + onArchiveConfirmed(): void { + this.dangerActionsService.archiveEvent(this.settingsService.eventId()); } - deleteEvent(): void { - const eventId = this.eventId(); - if (!eventId) { - this.error.set('Event ID is missing - cannot delete event'); - return; - } - - this.isDeleting.set(true); - - this.eventService.deleteEvent(eventId) - .pipe( - finalize(() => { - this.isDeleting.set(false); - this.showDeleteConfirmation.set(false); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe({ - next: () => { - this.navigateToHome(); - }, - error: (err) => { - this.handleDeleteError(err); - } - }); + onArchiveCancelled(): void { + this.dangerActionsService.cancelArchive(); } onDeleteConfirmed(): void { - this.deleteEvent(); + this.dangerActionsService.deleteEvent(this.settingsService.eventId()); } onDeleteCancelled(): void { - this.cancelDeleteEvent(); - } - - private navigateToHome(): void { - this.router.navigate(['/']); - } - - private handleArchiveError(err: any): void { - console.error('Error archiving event:', err); - this.error.set('Failed to archive event. Please try again.'); - } - - private handleDeleteError(err: any): void { - console.error('Error deleting event:', err); - this.error.set('Failed to delete event. Please try again.'); + this.dangerActionsService.cancelDelete(); } } diff --git a/front/src/app/feature/admin-management/services/event/event-danger-actions.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-danger-actions.service.spec.ts new file mode 100644 index 00000000..e4539c9d --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-danger-actions.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventDangerActionsService } from './event-danger-actions.service'; + +describe('EventDangerActionsService', () => { + let service: EventDangerActionsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventDangerActionsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-danger-actions.service.ts b/front/src/app/feature/admin-management/services/event/event-danger-actions.service.ts new file mode 100644 index 00000000..99a8de77 --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-danger-actions.service.ts @@ -0,0 +1,129 @@ +import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core'; +import { finalize } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { EventService } from './event.service'; +import { EventDTO } from '../../type/event/eventDTO'; +import { DangerZoneConfig } from '../../type/components/danger-zone'; +import { DeleteConfirmationConfig } from '../../type/components/delete-confirmation'; +import {UserRole} from '../../type/event/event-visibility'; + +@Injectable() +export class EventDangerActionsService { + private readonly eventService = inject(EventService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly isDeleting = signal(false); + readonly isArchiving = signal(false); + readonly showDeleteConfirmation = signal(false); + readonly showArchiveConfirmation = signal(false); + readonly error = signal(null); + + readonly isProcessing = computed(() => this.isDeleting() || this.isArchiving()); + + getDangerZoneConfig( + eventName: string, + currentUserRole: UserRole + ): DangerZoneConfig { + return { + title: 'Danger zone', + entityName: eventName, + entityType: 'event', + showArchiveSection: true, + isDeleting: this.isProcessing(), + currentUserRole + }; + } + + getDeleteConfirmationConfig(eventName: string): DeleteConfirmationConfig { + return { + entityType: 'event', + entityName: eventName, + title: 'Confirm Event Deletion', + confirmButtonText: 'Delete permanently', + loadingText: 'Deleting...', + requireTextConfirmation: true, + confirmationText: 'DELETE' + }; + } + + confirmArchive(): void { + this.showArchiveConfirmation.set(true); + } + + cancelArchive(): void { + this.showArchiveConfirmation.set(false); + } + + confirmDelete(): void { + this.showDeleteConfirmation.set(true); + } + + cancelDelete(): void { + this.showDeleteConfirmation.set(false); + } + + archiveEvent(eventId: string): void { + if (!eventId) { + this.error.set('Event ID is missing - cannot archive event'); + return; + } + + this.isArchiving.set(true); + + const archiveData: Partial = { + idEvent: eventId, + isFinish: true + }; + + this.eventService.updateEvent(archiveData) + .pipe( + finalize(() => { + this.isArchiving.set(false); + this.showArchiveConfirmation.set(false); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => this.navigateToHome(), + error: (err) => this.handleArchiveError(err) + }); + } + + deleteEvent(eventId: string): void { + if (!eventId) { + this.error.set('Event ID is missing - cannot delete event'); + return; + } + + this.isDeleting.set(true); + + this.eventService.deleteEvent(eventId) + .pipe( + finalize(() => { + this.isDeleting.set(false); + this.showDeleteConfirmation.set(false); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => this.navigateToHome(), + error: (err) => this.handleDeleteError(err) + }); + } + + private navigateToHome(): void { + this.router.navigate(['/']); + } + + private handleArchiveError(err: unknown): void { + console.error('Error archiving event:', err); + this.error.set('Failed to archive event. Please try again.'); + } + + private handleDeleteError(err: unknown): void { + console.error('Error deleting event:', err); + this.error.set('Failed to delete event. Please try again.'); + } +} diff --git a/front/src/app/feature/admin-management/services/event/event-settings.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-settings.service.spec.ts new file mode 100644 index 00000000..daa1c05b --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-settings.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventSettingsService } from './event-settings.service'; + +describe('EventSettingsService', () => { + let service: EventSettingsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventSettingsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-settings.service.ts b/front/src/app/feature/admin-management/services/event/event-settings.service.ts new file mode 100644 index 00000000..bad0f2c2 --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-settings.service.ts @@ -0,0 +1,100 @@ +import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core'; +import { finalize } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EventService } from './event.service'; +import { EventDataService } from './event-data.service'; +import { EventDTO } from '../../type/event/eventDTO'; +import {EventVisibility, UserRole} from '../../type/event/event-visibility'; + +@Injectable() +export class EventSettingsService { + private readonly eventService = inject(EventService); + private readonly eventDataService = inject(EventDataService); + private readonly destroyRef = inject(DestroyRef); + + readonly eventId = signal(''); + readonly eventUrl = signal(''); + readonly eventName = signal(''); + readonly teamUrl = signal(''); + readonly teamId = signal(''); + readonly visibility = signal('private'); + readonly currentUserRole = signal('Owner'); + + readonly isLoading = signal(true); + readonly error = signal(null); + + readonly eventInformationData = signal | null>(null); + readonly eventGeneralData = signal | null>(null); + + readonly hasEventData = computed(() => + !!this.eventGeneralData() && !!this.eventInformationData() + ); + + loadEventData(eventId: string): void { + if (!eventId) { + this.error.set('Event ID is required to load event data'); + this.isLoading.set(false); + return; + } + + this.eventId.set(eventId); + this.isLoading.set(true); + + this.eventService.getEventById(eventId) + .pipe( + finalize(() => this.isLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (event) => this.handleEventDataLoaded(event), + error: (err) => this.handleEventDataError(err) + }); + } + + private handleEventDataLoaded(event: EventDTO): void { + this.eventId.set(event.idEvent || this.eventId()); + this.eventName.set(event.eventName || ''); + this.eventUrl.set(event.url || ''); + this.teamUrl.set(event.teamUrl || ''); + this.teamId.set(event.teamId || ''); + this.currentUserRole.set('Owner'); + this.visibility.set(event.isPrivate === true ? 'private' : 'public'); + + this.eventGeneralData.set({ + idEvent: this.eventId(), + eventName: event.eventName, + url: event.url, + conferenceHallUrl: event.conferenceHallUrl, + timeZone: event.timeZone || 'Europe/Paris', + isPrivate: event.isPrivate === true, + type: event.type, + }); + + this.eventInformationData.set({ + idEvent: this.eventId(), + startDate: event.startDate, + endDate: event.endDate, + isOnline: event.isOnline, + location: event.location, + description: event.description, + webLinkUrl: event.webLinkUrl + }); + + this.eventDataService.loadEvent({ + idEvent: event.idEvent || this.eventId(), + eventName: event.eventName || '', + teamId: event.teamId || '', + url: event.url || '', + teamUrl: event.teamUrl, + webLinkUrl: event.webLinkUrl, + type: event.type, + }); + + this.error.set(null); + } + + private handleEventDataError(err: unknown): void { + this.error.set('Failed to load event details. Please try again.'); + console.error('Error loading event data:', err); + } +} diff --git a/front/src/app/feature/admin-management/type/event/event-visibility.ts b/front/src/app/feature/admin-management/type/event/event-visibility.ts new file mode 100644 index 00000000..62c6e7d2 --- /dev/null +++ b/front/src/app/feature/admin-management/type/event/event-visibility.ts @@ -0,0 +1,2 @@ +export type UserRole = 'Owner' | 'Admin' | 'Member'; +export type EventVisibility = 'private' | 'public'; From 0f99f186ab81f2c2fa2df1b863c14cc29330342e Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:13:04 +0200 Subject: [PATCH 08/23] reduce general-info-event --- .../general-info-event.component.html | 138 +----- .../general-info-event.component.ts | 419 +++++++----------- .../timezone-selector.component.html | 24 + .../timezone-selector.component.scss | 0 .../timezone-selector.component.spec.ts | 23 + .../timezone-selector.component.ts | 33 ++ .../visibility-selector.component.html | 64 +++ .../visibility-selector.component.scss | 0 .../visibility-selector.component.spec.ts | 23 + .../visibility-selector.component.ts | 28 ++ .../event/event-form-config.service.spec.ts | 16 + .../event/event-form-config.service.ts | 72 +++ .../type/event/event-visibility.ts | 7 + .../type/event/time-zone-option.ts | 1 - 14 files changed, 480 insertions(+), 368 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.html create mode 100644 front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss create mode 100644 front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.ts create mode 100644 front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.html create mode 100644 front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss create mode 100644 front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-form-config.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-form-config.service.ts diff --git a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html index e76db760..dc662d42 100644 --- a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html +++ b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html @@ -7,25 +7,29 @@ }
- - @for(field of formFields(); track field.name) { + @for (field of formFields(); track field.name) {
- @if(getFormControl(field.name).hasError('required') && isSubmitted) { - } - @if((field.name === 'name' || field.name === 'eventName') && getFormControl(field.name).hasError('minlength') && isSubmitted) { - - } - @if((field.name === 'name' || field.name === 'eventName') && getFormControl(field.name).hasError('maxlength') && isSubmitted) { - + + @if ((field.name === 'name' || field.name === 'eventName') && isSubmitted) { + @if (getFormControl(field.name).hasError('minlength')) { + + } + + @if (getFormControl(field.name).hasError('maxlength')) { + + } } - -
-
- - - - -
-
-
+ } -
- -
- -

- Select the timezone for your event -

-
-
+ @if (showGoBackButton()) { -
+
{{ submitButtonText() }} diff --git a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts index e85cd28c..1956aacc 100644 --- a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts +++ b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.ts @@ -1,164 +1,75 @@ -import { - Component, - computed, - effect, - inject, - input, - output, - DestroyRef -} from '@angular/core'; +import { Component, computed, effect, inject, input, output, DestroyRef, Signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { CommonModule } from '@angular/common'; -import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import moment from 'moment'; -import 'moment-timezone'; -import { BehaviorSubject, debounceTime, Subject } from 'rxjs'; -import 'moment-timezone'; -import {EventDTO} from '../../../type/event/eventDTO'; -import {FieldComponent} from '../../../../../shared/input/field.component'; -import {TimezoneOption} from '../../../type/event/time-zone-option'; -import {Team} from '../../../type/team/team'; -import {TeamService} from '../../../services/team/team.service'; -import {EventDataService} from '../../../services/event/event-data.service'; -import {environment} from '../../../../../../environments/environment.development'; -import {FormField} from '../../../../../shared/input/interface/form-field'; -import {SaveStatus} from '../../../../../core/types/save-status.types'; -import {AutoSaveService} from '../../services/auto-save.service'; -import {EventService} from '../../../services/event/event.service'; -import {MatSnackBar} from '@angular/material/snack-bar'; -import {SaveIndicatorComponent} from '../../../../../core/save-indicator/save-indicator.component'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BehaviorSubject, debounceTime } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EventDTO } from '../../../type/event/eventDTO'; +import { Team } from '../../../type/team/team'; +import { SaveStatus } from '../../../../../core/types/save-status.types'; +import { TeamService } from '../../../services/team/team.service'; +import { EventDataService } from '../../../services/event/event-data.service'; +import { EventService } from '../../../services/event/event.service'; +import { AutoSaveService } from '../../services/auto-save.service'; +import { FieldComponent } from '../../../../../shared/input/field.component'; +import { SaveIndicatorComponent } from '../../../../../core/save-indicator/save-indicator.component'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; +import { environment } from '../../../../../../environments/environment.development'; +import {CommonModule} from '@angular/common'; +import {VisibilitySelectorComponent} from '../visibility-selector/visibility-selector.component'; +import {TimezoneSelectorComponent} from '../timezone-selector/timezone-selector.component'; +import {FormFieldConfig, FormFieldConfigService} from '../../../services/event/event-form-config.service'; @Component({ selector: 'app-general-info-event', standalone: true, - imports: [CommonModule, FieldComponent, ReactiveFormsModule, FormsModule, SaveIndicatorComponent, ButtonComponent], - templateUrl: './general-info-event.component.html', - styleUrl: './general-info-event.component.scss' + imports: [ CommonModule, ReactiveFormsModule, FieldComponent, SaveIndicatorComponent, ButtonComponent, VisibilitySelectorComponent, TimezoneSelectorComponent ], + templateUrl: './general-info-event.component.html' }) export class GeneralInfoEventComponent { - mode = input<'create' | 'edit'>('create'); - initialData = input | null>(null); - initialVisibility = input<'private' | 'public'>('private'); - - formSubmitted = output(); - goBack = output(); - - private fb = inject(FormBuilder); - private autoSaveService = inject(AutoSaveService); - private eventService = inject(EventService); - private snackBar = inject(MatSnackBar); - private teamService = inject(TeamService); - private eventDataService = inject(EventDataService); - private route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); - - eventUrl: string = ''; - teamId: string | null = null; - form!: FormGroup; - isSubmitted: boolean = false; - dateTimeUtc = moment.utc(); - dateTimeLocal: moment.Moment | null = null; + mode = input<'create' | 'edit'>('create'); + initialData = input | null>(null); + initialVisibility = input<'private' | 'public'>('private'); + + formSubmitted = output(); + goBack = output(); + + private fb = inject(FormBuilder); + private autoSaveService = inject(AutoSaveService); + private eventService = inject(EventService); + private snackBar = inject(MatSnackBar); + private teamService = inject(TeamService); + private eventDataService = inject(EventDataService); + private route = inject(ActivatedRoute); + private destroyRef = inject(DestroyRef); + private formFieldConfigService = inject(FormFieldConfigService); - timezoneSelector = new FormControl('Europe/Paris'); - timezoneOptions: TimezoneOption[] = []; + form!: FormGroup; + isSubmitted = false; + teamId: string | null = null; + eventUrl = ''; teams: Team[] = []; saveStatus$ = new BehaviorSubject('idle'); - showAutoSaveIndicator = computed(() => this.mode() === 'edit'); - showGoBackButton = computed(() => this.mode() === 'create'); - showVisibilitySection = computed(() => this.mode() === 'edit'); - submitButtonText = computed(() => + timezoneControl = new FormControl('Europe/Paris', { nonNullable: true }); + + protected readonly showAutoSaveIndicator = computed(() => this.mode() === 'edit'); + protected readonly showGoBackButton = computed(() => this.mode() === 'create'); + protected readonly showVisibilitySection = computed(() => this.mode() === 'edit'); + + protected readonly submitButtonText = computed(() => this.mode() === 'create' ? 'Continue' : 'Update event' ); - submitButtonIcon = computed(() => - this.mode() === 'create' ? 'arrow_forward' : 'save' - ); - visibility = computed(() => - this.form?.get('visibility')?.value || 'private' as 'private' | 'public' + protected readonly submitButtonIcon = computed(() => + this.mode() === 'create' ? 'arrow_forward' : 'save' ); - formFields = computed((): FormField[] => { - const eventTypeOptions = [ - { value: 'conference', label: 'Conference' }, - { value: 'meetup', label: 'Meetup' } - ]; - - if (this.mode() === 'create') { - return [ - { - name: 'name', - label: 'Name', - type: 'text', - required: true, - placeholder: 'Enter your event name' - }, - { - name: 'url', - label: 'Event URL', - placeholder: 'https://speaker-space.io/event/', - type: 'text', - required: true, - disabled: true, - }, - { - name: 'urlConferenceHall', - label: 'Conference Hall URL Connection', - paragraph: 'Use a conference hall existing URL if you want to synchronize conference Hall data', - type: 'text', - required: false, - placeholder: 'https://conference-hall.io/...' - }, - { - name: 'type', - label: 'Event type', - type: 'select', - required: true, - options: eventTypeOptions - } - ]; - } else { - return [ - { - name: 'eventName', - label: 'Name', - placeholder: 'Enter your event name', - type: 'text', - required: true, - }, - { - name: 'eventURL', - label: 'Event URL', - placeholder: 'https://speaker-space.io/event/', - type: 'text', - required: false, - disabled: true, - }, - { - name: 'urlConferenceHall', - label: 'Conference Hall URL Connection', - paragraph: 'Use a conference hall existing URL if you want to synchronize conference Hall data', - type: 'text', - required: false, - placeholder: 'https://conference-hall.io/...' - }, - { - name: 'type', - label: 'Event type', - type: 'select', - required: true, - options: eventTypeOptions - } - ]; - } - }); + protected readonly formFields: Signal = + this.formFieldConfigService.getFormFields(this.mode); constructor() { - this.prepareTimezoneOptions(); - effect(() => { this.initializeForm(); this.setupSubscriptions(); @@ -169,29 +80,35 @@ export class GeneralInfoEventComponent { this.loadInitialData(currentInitialData); this.setupAutoSave(); } - - this.updateLocalTime(this.timezoneSelector.value || 'Europe/Paris'); }); } private initializeForm(): void { + const nameValidators = [ + Validators.required, + Validators.minLength(2), + Validators.maxLength(50) + ]; + + const baseFormConfig = { + urlConferenceHall: [''], + timeZone: [this.timezoneControl.value, Validators.required], + type: ['', Validators.required] + }; + if (this.mode() === 'edit') { this.form = this.fb.group({ - eventName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]], + eventName: ['', nameValidators], eventURL: [{ value: '', disabled: true }], - urlConferenceHall: [''], - timeZone: [this.timezoneSelector.value, Validators.required], visibility: [this.initialVisibility()], - type: ['', [Validators.required]] + ...baseFormConfig }); } else { this.form = this.fb.group({ - name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]], + name: ['', nameValidators], url: [{ value: `${environment.baseUrl}/event/`, disabled: true }], - urlConferenceHall: [''], teamId: [''], - timeZone: [this.timezoneSelector.value, Validators.required], - type: ['', [Validators.required]] + ...baseFormConfig }); } } @@ -205,10 +122,12 @@ export class GeneralInfoEventComponent { this.route.paramMap .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(params => { - const param: string | null = params.get('eventUrl') || params.get('teamId'); + const param = params.get('eventUrl') || params.get('teamId'); if (param) { - if (param.includes('team-') || /^[a-zA-Z0-9]{20,}$/.test(param)) { + const isTeamId = param.includes('team-') || /^[a-zA-Z0-9]{20,}$/.test(param); + + if (isTeamId) { this.teamId = param; this.form.get('teamId')?.setValue(param); } else { @@ -216,64 +135,59 @@ export class GeneralInfoEventComponent { } } }); - - this.eventUrl = this.route.snapshot.paramMap.get('eventUrl') || ''; } + + this.timezoneControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(timezone => { + this.form.get('timeZone')?.setValue(timezone); + }); } private setupFormListeners(): void { - if (this.mode() === 'create') { - this.form.get('name')?.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((name: string) => { - const urlSuffix: string = this.formatUrlFromName(name || ''); - this.form.get('url')?.setValue(`${environment.baseUrl}/event/` + urlSuffix); - this.eventDataService.setEventName(name || ''); - }); - } else { - this.form.get('eventName')?.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((name: string) => { - if (name) { - const urlSuffix: string = this.formatUrlFromName(name); - this.form.get('eventURL')?.setValue(`${environment.baseUrl}/event/` + urlSuffix); - } else { - this.form.get('eventURL')?.setValue(`${environment.baseUrl}/event/`); - } - }); - } + const nameFieldKey = this.mode() === 'create' ? 'name' : 'eventName'; + const urlFieldKey = this.mode() === 'create' ? 'url' : 'eventURL'; - this.timezoneSelector.valueChanges + this.form.get(nameFieldKey)?.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((timezone: string | null) => { - this.updateLocalTime(timezone || 'Europe/Paris'); - this.form.get('timeZone')?.setValue(timezone); + .subscribe((name: string) => { + const urlSuffix = this.formatUrlFromName(name || ''); + const fullUrl = `${environment.baseUrl}/event/${urlSuffix}`; + + this.form.get(urlFieldKey)?.setValue(fullUrl); + + if (this.mode() === 'create') { + this.eventDataService.setEventName(name || ''); + } }); } private setupAutoSave(): void { const currentInitialData = this.initialData(); + if (this.mode() !== 'edit' || !currentInitialData?.idEvent) { return; } - const { saveStatus$, destroy$ } = this.autoSaveService.setupAutoSave( + const { saveStatus$ } = this.autoSaveService.setupAutoSave( this.form, (data: Partial) => this.eventService.updateEvent(data), { extractValidFields: () => this.extractValidEventData(), - onSaveStart: () => { - this.form.markAsPristine(); - }, + onSaveStart: () => this.form.markAsPristine(), onSaveSuccess: (result: EventDTO) => { console.log('Event auto-saved successfully:', result); }, - onSaveError: (error: any) => { + onSaveError: (error: unknown) => { console.error('Auto-save failed:', error); - this.snackBar.open('Erreur lors de la sauvegarde automatique', 'Fermer', { - duration: 5000, - panelClass: ['error-snackbar'] - }); + this.snackBar.open( + 'Erreur lors de la sauvegarde automatique', + 'Fermer', + { + duration: 5000, + panelClass: ['error-snackbar'] + } + ); this.form.markAsDirty(); }, debounceTime: 2000 @@ -282,16 +196,13 @@ export class GeneralInfoEventComponent { this.saveStatus$ = saveStatus$ as BehaviorSubject; - this.timezoneSelector.valueChanges + this.timezoneControl.valueChanges .pipe( debounceTime(500), takeUntilDestroyed(this.destroyRef) ) - .subscribe((timezone: string | null) => { - this.updateLocalTime(timezone || 'Europe/Paris'); - this.form.get('timeZone')?.setValue(timezone); - - if (this.mode() === 'edit' && timezone !== currentInitialData?.timeZone) { + .subscribe(timezone => { + if (timezone !== currentInitialData?.timeZone) { this.form.markAsDirty(); } }); @@ -305,26 +216,41 @@ export class GeneralInfoEventComponent { idEvent: currentInitialData?.idEvent }; - if (formValue.eventName !== undefined && formValue.eventName !== currentInitialData?.eventName) { - data.eventName = formValue.eventName; - } - - if (formValue.urlConferenceHall !== undefined && formValue.urlConferenceHall !== currentInitialData?.conferenceHallUrl) { - data.conferenceHallUrl = formValue.urlConferenceHall; - } - - if (formValue.type !== undefined && formValue.type !== currentInitialData?.type) { - data.type = formValue.type; - } + const fieldMappings: Array<{ + formKey: string; + dtoKey: keyof EventDTO; + initialValue: unknown; + }> = [ + { + formKey: 'eventName', + dtoKey: 'eventName', + initialValue: currentInitialData?.eventName + }, + { + formKey: 'urlConferenceHall', + dtoKey: 'conferenceHallUrl', + initialValue: currentInitialData?.conferenceHallUrl + }, + { + formKey: 'type', + dtoKey: 'type', + initialValue: currentInitialData?.type + }, + { + formKey: 'timeZone', + dtoKey: 'timeZone', + initialValue: currentInitialData?.timeZone + } + ]; - if (formValue.timeZone !== undefined && formValue.timeZone !== currentInitialData?.timeZone) { - data.timeZone = formValue.timeZone; - } + fieldMappings.forEach(({ formKey, dtoKey, initialValue }) => { + if (formValue[formKey] !== undefined && formValue[formKey] !== initialValue) { + (data as Record)[dtoKey] = formValue[formKey]; + } + }); const currentIsPrivate = formValue.visibility === 'private'; - const initialIsPrivate = currentInitialData?.isPrivate; - - if (currentIsPrivate !== initialIsPrivate) { + if (currentIsPrivate !== currentInitialData?.isPrivate) { data.isPrivate = currentIsPrivate; } @@ -332,30 +258,33 @@ export class GeneralInfoEventComponent { } private loadInitialData(data: Partial): void { - if (this.mode() === 'edit') { - const fullUrl: string = data.url ? - (data.url.startsWith('http') ? data.url : `${environment.baseUrl}/event/${data.url}`) - : ''; - - const visibility = data.isPrivate ? 'private' : 'public'; - - this.form.patchValue({ - eventName: data.eventName || '', - eventURL: fullUrl, - urlConferenceHall: data.conferenceHallUrl || '', - timeZone: data.timeZone || 'Europe/Paris', - visibility: visibility, - type: data.type, - }); + if (this.mode() !== 'edit') return; + + const fullUrl = data.url + ? data.url.startsWith('http') + ? data.url + : `${environment.baseUrl}/event/${data.url}` + : ''; + + const visibility = data.isPrivate ? 'private' : 'public'; + + this.form.patchValue({ + eventName: data.eventName || '', + eventURL: fullUrl, + urlConferenceHall: data.conferenceHallUrl || '', + timeZone: data.timeZone || 'Europe/Paris', + visibility, + type: data.type + }); - if (data.timeZone) { - this.timezoneSelector.setValue(data.timeZone); - } + if (data.timeZone) { + this.timezoneControl.setValue(data.timeZone); } } private formatUrlFromName(name: string): string { - return name.trim() + return name + .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') @@ -363,9 +292,7 @@ export class GeneralInfoEventComponent { } onSubmit(): void { - if (this.mode() === 'edit') { - return; - } + if (this.mode() === 'edit') return; this.isSubmitted = true; @@ -375,16 +302,15 @@ export class GeneralInfoEventComponent { } const formValue = this.form.getRawValue(); + const newEvent: EventDTO = { eventName: formValue.name, url: formValue.url, - isOnline: formValue.isOnline, conferenceHallUrl: formValue.urlConferenceHall, timeZone: formValue.timeZone, teamId: formValue.teamId || this.teamId, - teamUrl: formValue.teamUrl, isPrivate: true, - type: formValue.type, + type: formValue.type }; this.formSubmitted.emit(newEvent); @@ -394,23 +320,12 @@ export class GeneralInfoEventComponent { this.goBack.emit(); } - prepareTimezoneOptions(): void { - this.timezoneOptions = moment.tz.names().map(tz => ({ - name: tz, - offset: moment.tz(tz).utcOffset() - })).sort((a, b) => a.offset - b.offset); - } - - updateLocalTime(timezone: string): void { - this.dateTimeLocal = this.dateTimeUtc.clone().tz(timezone); - } - - formatTimezoneOption(tz: TimezoneOption): string { - const offsetFormatted: string = moment.tz(tz.name).format('Z'); - return `(GMT${offsetFormatted}) ${tz.name}`; - } - getFormControl(name: string): FormControl { return this.form.get(name) as FormControl; } + + get visibilityControl(): FormControl<'private' | 'public'> { + return this.form.get('visibility') as FormControl<'private' | 'public'>; + } } + diff --git a/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.html b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.html new file mode 100644 index 00000000..291059c4 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.html @@ -0,0 +1,24 @@ +
+ + +
+ + +

+ Select the timezone for your event +

+
+
diff --git a/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.spec.ts b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.spec.ts new file mode 100644 index 00000000..672da052 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimezoneSelectorComponent } from './timezone-selector.component'; + +describe('TimezoneSelectorComponent', () => { + let component: TimezoneSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimezoneSelectorComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TimezoneSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.ts b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.ts new file mode 100644 index 00000000..b89fc320 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.ts @@ -0,0 +1,33 @@ +import {Component, computed, DestroyRef, inject, input, Signal} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {TimezoneOption} from '../../../type/event/time-zone-option'; +import moment from 'moment'; +import 'moment-timezone'; + +@Component({ + selector: 'app-timezone-selector', + imports: [ + ReactiveFormsModule + ], + templateUrl: './timezone-selector.component.html', + styleUrl: './timezone-selector.component.scss' +}) +export class TimezoneSelectorComponent { + control = input.required>(); + + private destroyRef = inject(DestroyRef); + + protected readonly timezoneOptions: Signal = computed(() => { + return moment.tz.names() + .map(tz => ({ + name: tz, + offset: moment.tz(tz).utcOffset() + })) + .sort((a, b) => a.offset - b.offset); + }); + + protected formatTimezoneOption(tz: TimezoneOption): string { + const offsetFormatted = moment.tz(tz.name).format('Z'); + return `(GMT${offsetFormatted}) ${tz.name}`; + } +} diff --git a/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.html b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.html new file mode 100644 index 00000000..f199ea4e --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.html @@ -0,0 +1,64 @@ +
+ + +
+ Event visibility options + +
+ @for (option of visibilityOptions; track option.value) { + + } +
+
+
diff --git a/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.spec.ts b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.spec.ts new file mode 100644 index 00000000..39af7d98 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VisibilitySelectorComponent } from './visibility-selector.component'; + +describe('VisibilitySelectorComponent', () => { + let component: VisibilitySelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VisibilitySelectorComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VisibilitySelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.ts b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.ts new file mode 100644 index 00000000..29cd31b6 --- /dev/null +++ b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.ts @@ -0,0 +1,28 @@ +import {Component, input} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {VisibilityOption} from '../../../type/event/event-visibility'; + +@Component({ + selector: 'app-visibility-selector', + imports: [ + ReactiveFormsModule + ], + templateUrl: './visibility-selector.component.html', + styleUrl: './visibility-selector.component.scss' +}) +export class VisibilitySelectorComponent { + control = input.required>(); + + protected readonly visibilityOptions: VisibilityOption[] = [ + { + value: 'private', + label: 'Private', + description: 'This event would be available to anyone who has the link.' + }, + { + value: 'public', + label: 'Public', + description: 'This event will be available in the Speaker Space search and visible to anyone.' + } + ]; +} diff --git a/front/src/app/feature/admin-management/services/event/event-form-config.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-form-config.service.spec.ts new file mode 100644 index 00000000..3219436e --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form-config.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventFormConfigService } from './event-form-config.service'; + +describe('EventFormConfigService', () => { + let service: EventFormConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventFormConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-form-config.service.ts b/front/src/app/feature/admin-management/services/event/event-form-config.service.ts new file mode 100644 index 00000000..f26a5f5e --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form-config.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Signal, computed } from '@angular/core'; + +export interface FormFieldConfig { + name: string; + label: string; + type: 'text' | 'select'; + required: boolean; + placeholder?: string; + paragraph?: string; + disabled?: boolean; + options?: Array<{ value: string; label: string }>; +} + +@Injectable({ providedIn: 'root' }) +export class FormFieldConfigService { + private readonly EVENT_TYPE_OPTIONS = [ + { value: 'conference', label: 'Conference' }, + { value: 'meetup', label: 'Meetup' } + ] as const; + + private readonly COMMON_FIELDS: Omit[] = [ + { + label: 'Conference Hall URL Connection', + paragraph: 'Use a conference hall existing URL if you want to synchronize conference Hall data', + type: 'text', + required: false, + placeholder: 'https://conference-hall.io/...' + }, + { + label: 'Event type', + type: 'select', + required: true, + options: [...this.EVENT_TYPE_OPTIONS] + } + ]; + + getFormFields(mode: Signal<'create' | 'edit'>): Signal { + return computed(() => { + const isCreateMode = mode() === 'create'; + + const specificFields: FormFieldConfig[] = [ + { + name: isCreateMode ? 'name' : 'eventName', + label: 'Name', + type: 'text', + required: true, + placeholder: 'Enter your event name' + }, + { + name: isCreateMode ? 'url' : 'eventURL', + label: 'Event URL', + placeholder: 'https://speaker-space.io/event/', + type: 'text', + required: isCreateMode, + disabled: true + } + ]; + + return [ + ...specificFields, + { + name: 'urlConferenceHall', + ...this.COMMON_FIELDS[0] + }, + { + name: 'type', + ...this.COMMON_FIELDS[1] + } + ]; + }); + } +} diff --git a/front/src/app/feature/admin-management/type/event/event-visibility.ts b/front/src/app/feature/admin-management/type/event/event-visibility.ts index 62c6e7d2..fad674e5 100644 --- a/front/src/app/feature/admin-management/type/event/event-visibility.ts +++ b/front/src/app/feature/admin-management/type/event/event-visibility.ts @@ -1,2 +1,9 @@ export type UserRole = 'Owner' | 'Admin' | 'Member'; + export type EventVisibility = 'private' | 'public'; + +export type VisibilityOption = { + value: 'private' | 'public'; + label: string; + description: string; +} diff --git a/front/src/app/feature/admin-management/type/event/time-zone-option.ts b/front/src/app/feature/admin-management/type/event/time-zone-option.ts index 4945622f..41306f3e 100644 --- a/front/src/app/feature/admin-management/type/event/time-zone-option.ts +++ b/front/src/app/feature/admin-management/type/event/time-zone-option.ts @@ -2,4 +2,3 @@ export type TimezoneOption = { name: string; offset: number; } - From e91f9aef866383b8fa3c4813700ab0a39c9ab818 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:43:22 +0200 Subject: [PATCH 09/23] reduce general-info-event and delete .scss files --- front/src/app/app.component.scss | 0 .../src/app/core/footer/footer.component.scss | 0 .../core/home-page/home-page.component.scss | 0 .../is-login-home-page.component.scss | 0 .../button-login/button-login.component.scss | 0 .../email-modal/email-modal.component.scss | 0 .../login-form/login-form.component.scss | 0 .../login-page/login-page.component.scss | 0 .../not-found-page.component.scss | 0 .../app/core/sidebar/sidebar.component.scss | 0 .../danger-zone/danger-zone.component.scss | 0 .../archive-event-popup.component.scss | 0 .../event-logo-uploader.component.scss | 0 .../event-settings-sections.component.scss | 0 .../event-team-card.component.scss | 0 .../general-info-event.component.html | 27 +- .../general-info-event.component.scss | 0 .../general-info-event.component.ts | 248 +++++------------- .../navbar-event-page.component.scss | 0 .../timezone-selector.component.scss | 0 .../visibility-selector.component.scss | 0 .../generic-filter-popup.component.scss | 0 .../navbar-admin-page.component.scss | 0 .../navbar-session-page.component.scss | 0 .../session-filter-popup.component.scss | 0 .../session-review-import.component.scss | 0 .../session-schedule-form.component.scss | 0 .../session-schedule-import.component.scss | 0 .../session-speakers.component.scss | 0 .../sidebar-admin-page.component.scss | 0 .../navbar-speaker-page.component.scss | 0 .../speaker-filter-popup.component.scss | 0 .../team/add-member/add-member.component.scss | 0 .../delete-popup/delete-popup.component.scss | 0 .../members-card/members-card.component.scss | 0 .../navbar-team-page.component.scss | 0 .../team-general-form.component.scss | 0 .../calendar-event-page.component.scss | 0 .../setting-event-page.component.scss | 0 .../session-detail-page.component.scss | 0 .../speaker-detail-page.component.scss | 0 .../create-team-page.component.scss | 0 .../list-event-page.component.scss | 0 .../setting-team-general-page.component.scss | 0 .../setting-team-members-page.component.scss | 0 .../event/event-data-mapper.service.spec.ts | 16 ++ .../event/event-data-mapper.service.ts | 86 ++++++ .../services/event/event-form.service.spec.ts | 16 ++ .../services/event/event-form.service.ts | 94 +++++++ .../admin-management/type/event/eventDTO.ts | 2 +- .../type/event/form-config.ts | 5 + .../biography/biography.component.scss | 0 .../navbar-profile.component.scss | 0 .../personal-info.component.scss | 0 .../social-networks.component.scss | 0 .../feature/profile/profile.component.scss | 0 .../auth-error-dialog.component.scss | 0 .../form-field-errors.component.html | 22 ++ .../form-field-errors.component.spec.ts | 23 ++ .../form-field-errors.component.ts | 20 ++ 60 files changed, 347 insertions(+), 212 deletions(-) delete mode 100644 front/src/app/app.component.scss delete mode 100644 front/src/app/core/footer/footer.component.scss delete mode 100644 front/src/app/core/home-page/home-page.component.scss delete mode 100644 front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.scss delete mode 100644 front/src/app/core/login/components/button-login/button-login.component.scss delete mode 100644 front/src/app/core/login/components/email-modal/email-modal.component.scss delete mode 100644 front/src/app/core/login/login-form/login-form.component.scss delete mode 100644 front/src/app/core/login/login-page/login-page.component.scss delete mode 100644 front/src/app/core/not-found-page/not-found-page.component.scss delete mode 100644 front/src/app/core/sidebar/sidebar.component.scss delete mode 100644 front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/event-team-card/event-team-card.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/navbar-event-page/navbar-event-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss delete mode 100644 front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss delete mode 100644 front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/navbar-session-page/navbar-session-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/session-filter-popup/session-filter-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.scss delete mode 100644 front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss delete mode 100644 front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/speaker/navbar-speaker-page/navbar-speaker-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/speaker/speaker-filter-popup/speaker-filter-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/members-card/members-card.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/navbar-team-page/navbar-team-page.component.scss delete mode 100644 front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/speaker/speaker-detail-page/speaker-detail-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.scss delete mode 100644 front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.scss create mode 100644 front/src/app/feature/admin-management/services/event/event-data-mapper.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-data-mapper.service.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-form.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-form.service.ts create mode 100644 front/src/app/feature/admin-management/type/event/form-config.ts delete mode 100644 front/src/app/feature/profile/components/biography/biography.component.scss delete mode 100644 front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.scss delete mode 100644 front/src/app/feature/profile/components/personal-info/personal-info.component.scss delete mode 100644 front/src/app/feature/profile/components/social-networks/social-networks.component.scss delete mode 100644 front/src/app/feature/profile/profile.component.scss delete mode 100644 front/src/app/shared/auth-error-dialog/auth-error-dialog.component.scss create mode 100644 front/src/app/shared/form-field-errors/form-field-errors.component.html create mode 100644 front/src/app/shared/form-field-errors/form-field-errors.component.spec.ts create mode 100644 front/src/app/shared/form-field-errors/form-field-errors.component.ts diff --git a/front/src/app/app.component.scss b/front/src/app/app.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/footer/footer.component.scss b/front/src/app/core/footer/footer.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/home-page/home-page.component.scss b/front/src/app/core/home-page/home-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.scss b/front/src/app/core/home-page/is-login-home-page/is-login-home-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/login/components/button-login/button-login.component.scss b/front/src/app/core/login/components/button-login/button-login.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/login/components/email-modal/email-modal.component.scss b/front/src/app/core/login/components/email-modal/email-modal.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/login/login-form/login-form.component.scss b/front/src/app/core/login/login-form/login-form.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/login/login-page/login-page.component.scss b/front/src/app/core/login/login-page/login-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/not-found-page/not-found-page.component.scss b/front/src/app/core/not-found-page/not-found-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/core/sidebar/sidebar.component.scss b/front/src/app/core/sidebar/sidebar.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.scss b/front/src/app/feature/admin-management/components/danger-zone/danger-zone.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.scss b/front/src/app/feature/admin-management/components/event/archive-event-popup/archive-event-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss b/front/src/app/feature/admin-management/components/event/event-logo-uploader/event-logo-uploader.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss b/front/src/app/feature/admin-management/components/event/event-settings-sections/event-settings-sections.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/event-team-card/event-team-card.component.scss b/front/src/app/feature/admin-management/components/event/event-team-card/event-team-card.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html index dc662d42..a6ea7141 100644 --- a/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html +++ b/front/src/app/feature/admin-management/components/event/general-info-event/general-info-event.component.html @@ -9,28 +9,11 @@ @for (field of formFields(); track field.name) {
- @if (getFormControl(field.name).hasError('required') && isSubmitted) { - - } - - @if ((field.name === 'name' || field.name === 'eventName') && isSubmitted) { - @if (getFormControl(field.name).hasError('minlength')) { - - } - - @if (getFormControl(field.name).hasError('maxlength')) { - - } - } + + ('create'); initialData = input | null>(null); initialVisibility = input<'private' | 'public'>('private'); - formSubmitted = output(); goBack = output(); - private fb = inject(FormBuilder); + private eventFormService = inject(EventFormService); private autoSaveService = inject(AutoSaveService); private eventService = inject(EventService); private snackBar = inject(MatSnackBar); private teamService = inject(TeamService); - private eventDataService = inject(EventDataService); - private route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); private formFieldConfigService = inject(FormFieldConfigService); + private eventDataMapper = inject(EventDataMapperService); + private destroyRef = inject(DestroyRef); form!: FormGroup; isSubmitted = false; - teamId: string | null = null; - eventUrl = ''; teams: Team[] = []; saveStatus$ = new BehaviorSubject('idle'); - timezoneControl = new FormControl('Europe/Paris', { nonNullable: true }); protected readonly showAutoSaveIndicator = computed(() => this.mode() === 'edit'); protected readonly showGoBackButton = computed(() => this.mode() === 'create'); protected readonly showVisibilitySection = computed(() => this.mode() === 'edit'); - protected readonly submitButtonText = computed(() => this.mode() === 'create' ? 'Continue' : 'Update event' ); - protected readonly submitButtonIcon = computed(() => this.mode() === 'create' ? 'arrow_forward' : 'save' ); - protected readonly formFields: Signal = this.formFieldConfigService.getFormFields(this.mode); constructor() { effect(() => { - this.initializeForm(); - this.setupSubscriptions(); - this.setupFormListeners(); - - const currentInitialData = this.initialData(); - if (currentInitialData && this.mode() === 'edit') { - this.loadInitialData(currentInitialData); - this.setupAutoSave(); - } + this.initializeComponent(); }); } - private initializeForm(): void { - const nameValidators = [ - Validators.required, - Validators.minLength(2), - Validators.maxLength(50) - ]; + private initializeComponent(): void { + this.form = this.eventFormService.createForm({ + mode: this.mode(), + initialVisibility: this.initialVisibility(), + timezoneValue: this.timezoneControl.value + }); - const baseFormConfig = { - urlConferenceHall: [''], - timeZone: [this.timezoneControl.value, Validators.required], - type: ['', Validators.required] - }; + this.setupTeamsSubscription(); + this.setupTimezoneSync(); + this.eventFormService.setupUrlGeneration(this.form, this.mode); - if (this.mode() === 'edit') { - this.form = this.fb.group({ - eventName: ['', nameValidators], - eventURL: [{ value: '', disabled: true }], - visibility: [this.initialVisibility()], - ...baseFormConfig - }); - } else { - this.form = this.fb.group({ - name: ['', nameValidators], - url: [{ value: `${environment.baseUrl}/event/`, disabled: true }], - teamId: [''], - ...baseFormConfig - }); + if (this.mode() === 'create') { + this.eventFormService.handleRouteParams(this.form); + } + + const currentInitialData = this.initialData(); + if (currentInitialData && this.mode() === 'edit') { + this.loadInitialData(currentInitialData); + this.setupAutoSave(currentInitialData); } } - private setupSubscriptions(): void { + private setupTeamsSubscription(): void { this.teamService.teams$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((teams: Team[]) => this.teams = teams); + } - if (this.mode() === 'create') { - this.route.paramMap - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(params => { - const param = params.get('eventUrl') || params.get('teamId'); - - if (param) { - const isTeamId = param.includes('team-') || /^[a-zA-Z0-9]{20,}$/.test(param); - - if (isTeamId) { - this.teamId = param; - this.form.get('teamId')?.setValue(param); - } else { - this.eventUrl = param; - } - } - }); - } - + private setupTimezoneSync(): void { this.timezoneControl.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(timezone => { @@ -144,36 +115,17 @@ export class GeneralInfoEventComponent { }); } - private setupFormListeners(): void { - const nameFieldKey = this.mode() === 'create' ? 'name' : 'eventName'; - const urlFieldKey = this.mode() === 'create' ? 'url' : 'eventURL'; - - this.form.get(nameFieldKey)?.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((name: string) => { - const urlSuffix = this.formatUrlFromName(name || ''); - const fullUrl = `${environment.baseUrl}/event/${urlSuffix}`; - - this.form.get(urlFieldKey)?.setValue(fullUrl); - - if (this.mode() === 'create') { - this.eventDataService.setEventName(name || ''); - } - }); - } - - private setupAutoSave(): void { - const currentInitialData = this.initialData(); - - if (this.mode() !== 'edit' || !currentInitialData?.idEvent) { - return; - } + private setupAutoSave(initialData: Partial): void { + if (!initialData.idEvent) return; const { saveStatus$ } = this.autoSaveService.setupAutoSave( this.form, (data: Partial) => this.eventService.updateEvent(data), { - extractValidFields: () => this.extractValidEventData(), + extractValidFields: () => this.eventDataMapper.extractModifiedFields( + this.form.getRawValue(), + this.initialData() + ), onSaveStart: () => this.form.markAsPristine(), onSaveSuccess: (result: EventDTO) => { console.log('Event auto-saved successfully:', result); @@ -202,95 +154,21 @@ export class GeneralInfoEventComponent { takeUntilDestroyed(this.destroyRef) ) .subscribe(timezone => { - if (timezone !== currentInitialData?.timeZone) { + if (timezone !== initialData.timeZone) { this.form.markAsDirty(); } }); } - private extractValidEventData(): Partial { - const formValue = this.form.getRawValue(); - const currentInitialData = this.initialData(); - - const data: Partial = { - idEvent: currentInitialData?.idEvent - }; - - const fieldMappings: Array<{ - formKey: string; - dtoKey: keyof EventDTO; - initialValue: unknown; - }> = [ - { - formKey: 'eventName', - dtoKey: 'eventName', - initialValue: currentInitialData?.eventName - }, - { - formKey: 'urlConferenceHall', - dtoKey: 'conferenceHallUrl', - initialValue: currentInitialData?.conferenceHallUrl - }, - { - formKey: 'type', - dtoKey: 'type', - initialValue: currentInitialData?.type - }, - { - formKey: 'timeZone', - dtoKey: 'timeZone', - initialValue: currentInitialData?.timeZone - } - ]; - - fieldMappings.forEach(({ formKey, dtoKey, initialValue }) => { - if (formValue[formKey] !== undefined && formValue[formKey] !== initialValue) { - (data as Record)[dtoKey] = formValue[formKey]; - } - }); - - const currentIsPrivate = formValue.visibility === 'private'; - if (currentIsPrivate !== currentInitialData?.isPrivate) { - data.isPrivate = currentIsPrivate; - } - - return data; - } - private loadInitialData(data: Partial): void { - if (this.mode() !== 'edit') return; - - const fullUrl = data.url - ? data.url.startsWith('http') - ? data.url - : `${environment.baseUrl}/event/${data.url}` - : ''; - - const visibility = data.isPrivate ? 'private' : 'public'; - - this.form.patchValue({ - eventName: data.eventName || '', - eventURL: fullUrl, - urlConferenceHall: data.conferenceHallUrl || '', - timeZone: data.timeZone || 'Europe/Paris', - visibility, - type: data.type - }); + const formData = this.eventDataMapper.prepareInitialFormData(data); + this.form.patchValue(formData); if (data.timeZone) { this.timezoneControl.setValue(data.timeZone); } } - private formatUrlFromName(name: string): string { - return name - .trim() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-'); - } - onSubmit(): void { if (this.mode() === 'edit') return; @@ -301,17 +179,10 @@ export class GeneralInfoEventComponent { return; } - const formValue = this.form.getRawValue(); - - const newEvent: EventDTO = { - eventName: formValue.name, - url: formValue.url, - conferenceHallUrl: formValue.urlConferenceHall, - timeZone: formValue.timeZone, - teamId: formValue.teamId || this.teamId, - isPrivate: true, - type: formValue.type - }; + const newEvent = this.eventDataMapper.formToEventDTO( + this.form.getRawValue(), + this.eventFormService.teamId + ); this.formSubmitted.emit(newEvent); } @@ -328,4 +199,3 @@ export class GeneralInfoEventComponent { return this.form.get('visibility') as FormControl<'private' | 'public'>; } } - diff --git a/front/src/app/feature/admin-management/components/event/navbar-event-page/navbar-event-page.component.scss b/front/src/app/feature/admin-management/components/event/navbar-event-page/navbar-event-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss b/front/src/app/feature/admin-management/components/event/timezone-selector/timezone-selector.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss b/front/src/app/feature/admin-management/components/event/visibility-selector/visibility-selector.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.scss b/front/src/app/feature/admin-management/components/filter-popup/generic-filter-popup/generic-filter-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.scss b/front/src/app/feature/admin-management/components/navbar-admin-page/navbar-admin-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/navbar-session-page/navbar-session-page.component.scss b/front/src/app/feature/admin-management/components/session/navbar-session-page/navbar-session-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/session-filter-popup/session-filter-popup.component.scss b/front/src/app/feature/admin-management/components/session/session-filter-popup/session-filter-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.scss b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss b/front/src/app/feature/admin-management/components/session/session-schedule-form/session-schedule-form.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.scss b/front/src/app/feature/admin-management/components/session/session-schedule-import/session-schedule-import.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.scss b/front/src/app/feature/admin-management/components/sidebar-admin-page/sidebar-admin-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/speaker/navbar-speaker-page/navbar-speaker-page.component.scss b/front/src/app/feature/admin-management/components/speaker/navbar-speaker-page/navbar-speaker-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/speaker/speaker-filter-popup/speaker-filter-popup.component.scss b/front/src/app/feature/admin-management/components/speaker/speaker-filter-popup/speaker-filter-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss b/front/src/app/feature/admin-management/components/team/add-member/add-member.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.scss b/front/src/app/feature/admin-management/components/team/members-card/components/delete-popup/delete-popup.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/members-card/members-card.component.scss b/front/src/app/feature/admin-management/components/team/members-card/members-card.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/navbar-team-page/navbar-team-page.component.scss b/front/src/app/feature/admin-management/components/team/navbar-team-page/navbar-team-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss b/front/src/app/feature/admin-management/components/team/team-general-form/team-general-form.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.scss b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.scss b/front/src/app/feature/admin-management/pages/events/setting-event-page/setting-event-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.scss b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-detail-page/speaker-detail-page.component.scss b/front/src/app/feature/admin-management/pages/speaker/speaker-detail-page/speaker-detail-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.scss b/front/src/app/feature/admin-management/pages/team/create-team-page/create-team-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.scss b/front/src/app/feature/admin-management/pages/team/list-event-page/list-event-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.scss b/front/src/app/feature/admin-management/pages/team/setting-team-general-page/setting-team-general-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.scss b/front/src/app/feature/admin-management/pages/team/setting-team-members-page/setting-team-members-page.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/admin-management/services/event/event-data-mapper.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-data-mapper.service.spec.ts new file mode 100644 index 00000000..26b4489f --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-data-mapper.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventDataMapperService } from './event-data-mapper.service'; + +describe('EventDataMapperService', () => { + let service: EventDataMapperService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventDataMapperService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-data-mapper.service.ts b/front/src/app/feature/admin-management/services/event/event-data-mapper.service.ts new file mode 100644 index 00000000..f1c8d57f --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-data-mapper.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import {EventDTO} from '../../type/event/eventDTO'; +import {environment} from '../../../../../environments/environment.development'; + +@Injectable({ providedIn: 'root' }) +export class EventDataMapperService { + extractModifiedFields( + formValue: Record, + initialData: Partial | null + ): Partial { + const data: Partial = { + idEvent: initialData?.idEvent + }; + + const fieldMappings: Array<{ + formKey: string; + dtoKey: keyof EventDTO; + initialValue: unknown; + }> = [ + { + formKey: 'eventName', + dtoKey: 'eventName', + initialValue: initialData?.eventName + }, + { + formKey: 'urlConferenceHall', + dtoKey: 'conferenceHallUrl', + initialValue: initialData?.conferenceHallUrl + }, + { + formKey: 'type', + dtoKey: 'type', + initialValue: initialData?.type + }, + { + formKey: 'timeZone', + dtoKey: 'timeZone', + initialValue: initialData?.timeZone + } + ]; + + fieldMappings.forEach(({ formKey, dtoKey, initialValue }) => { + if (formValue[formKey] !== undefined && formValue[formKey] !== initialValue) { + (data as Record)[dtoKey] = formValue[formKey]; + } + }); + + const currentIsPrivate = formValue['visibility'] === 'private'; + if (currentIsPrivate !== initialData?.isPrivate) { + data.isPrivate = currentIsPrivate; + } + + return data; + } + + prepareInitialFormData(data: Partial): Record { + const fullUrl = data.url + ? data.url.startsWith('http') + ? data.url + : `${environment.baseUrl}/event/${data.url}` + : ''; + + const visibility = data.isPrivate ? 'private' : 'public'; + + return { + eventName: data.eventName || '', + eventURL: fullUrl, + urlConferenceHall: data.conferenceHallUrl || '', + timeZone: data.timeZone || 'Europe/Paris', + visibility, + type: data.type + }; + } + + formToEventDTO(formValue: Record, teamId: string | null): EventDTO { + return { + eventName: formValue['name'] as string, + url: formValue['url'] as string, + conferenceHallUrl: formValue['urlConferenceHall'] as string, + timeZone: formValue['timeZone'] as string, + teamId: (formValue['teamId'] as string) || teamId, + isPrivate: true, + type: formValue['type'] as string + }; + } +} diff --git a/front/src/app/feature/admin-management/services/event/event-form.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-form.service.spec.ts new file mode 100644 index 00000000..2b08cfc0 --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventFormService } from './event-form.service'; + +describe('EventFormService', () => { + let service: EventFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventFormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-form.service.ts b/front/src/app/feature/admin-management/services/event/event-form.service.ts new file mode 100644 index 00000000..7475606d --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Signal, inject, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import {EventDataService} from './event-data.service'; +import {FormConfig} from '../../type/event/form-config'; +import {environment} from '../../../../../environments/environment.development'; + +@Injectable() +export class EventFormService { + private fb = inject(FormBuilder); + private route = inject(ActivatedRoute); + private eventDataService = inject(EventDataService); + private destroyRef = inject(DestroyRef); + + teamId: string | null = null; + eventUrl = ''; + + private readonly NAME_VALIDATORS = [ + Validators.required, + Validators.minLength(2), + Validators.maxLength(50) + ]; + + createForm(config: FormConfig): FormGroup { + const baseFormConfig = { + urlConferenceHall: [''], + timeZone: [config.timezoneValue, Validators.required], + type: ['', Validators.required] + }; + + if (config.mode === 'edit') { + return this.fb.group({ + eventName: ['', this.NAME_VALIDATORS], + eventURL: [{ value: '', disabled: true }], + visibility: [config.initialVisibility], + ...baseFormConfig + }); + } + + return this.fb.group({ + name: ['', this.NAME_VALIDATORS], + url: [{ value: `${environment.baseUrl}/event/`, disabled: true }], + teamId: [''], + ...baseFormConfig + }); + } + + setupUrlGeneration(form: FormGroup, mode: Signal<'create' | 'edit'>): void { + const nameFieldKey = mode() === 'create' ? 'name' : 'eventName'; + const urlFieldKey = mode() === 'create' ? 'url' : 'eventURL'; + + form.get(nameFieldKey)?.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((name: string) => { + const urlSuffix = this.formatUrlFromName(name || ''); + const fullUrl = `${environment.baseUrl}/event/${urlSuffix}`; + + form.get(urlFieldKey)?.setValue(fullUrl); + + if (mode() === 'create') { + this.eventDataService.setEventName(name || ''); + } + }); + } + + handleRouteParams(form: FormGroup): void { + this.route.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(params => { + const param = params.get('eventUrl') || params.get('teamId'); + + if (param) { + const isTeamId = param.includes('team-') || /^[a-zA-Z0-9]{20,}$/.test(param); + + if (isTeamId) { + this.teamId = param; + form.get('teamId')?.setValue(param); + } else { + this.eventUrl = param; + } + } + }); + } + + private formatUrlFromName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-'); + } +} diff --git a/front/src/app/feature/admin-management/type/event/eventDTO.ts b/front/src/app/feature/admin-management/type/event/eventDTO.ts index ff69080c..b2d4e2be 100644 --- a/front/src/app/feature/admin-management/type/event/eventDTO.ts +++ b/front/src/app/feature/admin-management/type/event/eventDTO.ts @@ -13,7 +13,7 @@ export type EventDTO = { isFinish?: boolean; userCreateId?: string; conferenceHallUrl?: string; - teamId?: string; + teamId: string | null; timeZone?: string; logoBase64?: string | null; type: string; diff --git a/front/src/app/feature/admin-management/type/event/form-config.ts b/front/src/app/feature/admin-management/type/event/form-config.ts new file mode 100644 index 00000000..6f4dc26a --- /dev/null +++ b/front/src/app/feature/admin-management/type/event/form-config.ts @@ -0,0 +1,5 @@ +export type FormConfig = { + mode: 'create' | 'edit'; + initialVisibility: 'private' | 'public'; + timezoneValue: string; +} diff --git a/front/src/app/feature/profile/components/biography/biography.component.scss b/front/src/app/feature/profile/components/biography/biography.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.scss b/front/src/app/feature/profile/components/navbar-profile/navbar-profile.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/profile/components/personal-info/personal-info.component.scss b/front/src/app/feature/profile/components/personal-info/personal-info.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/profile/components/social-networks/social-networks.component.scss b/front/src/app/feature/profile/components/social-networks/social-networks.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/feature/profile/profile.component.scss b/front/src/app/feature/profile/profile.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/shared/auth-error-dialog/auth-error-dialog.component.scss b/front/src/app/shared/auth-error-dialog/auth-error-dialog.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/front/src/app/shared/form-field-errors/form-field-errors.component.html b/front/src/app/shared/form-field-errors/form-field-errors.component.html new file mode 100644 index 00000000..abad7cfe --- /dev/null +++ b/front/src/app/shared/form-field-errors/form-field-errors.component.html @@ -0,0 +1,22 @@ +@if (control().hasError('required') && isSubmitted()) { + +} + +@if (isNameField() && isSubmitted()) { + @if (control().hasError('minlength')) { + + } + + @if (control().hasError('maxlength')) { + + } +} diff --git a/front/src/app/shared/form-field-errors/form-field-errors.component.spec.ts b/front/src/app/shared/form-field-errors/form-field-errors.component.spec.ts new file mode 100644 index 00000000..01fbabf7 --- /dev/null +++ b/front/src/app/shared/form-field-errors/form-field-errors.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FormFieldErrorsComponent } from './form-field-errors.component'; + +describe('FormFieldErrorsComponent', () => { + let component: FormFieldErrorsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormFieldErrorsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FormFieldErrorsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/shared/form-field-errors/form-field-errors.component.ts b/front/src/app/shared/form-field-errors/form-field-errors.component.ts new file mode 100644 index 00000000..16f9a399 --- /dev/null +++ b/front/src/app/shared/form-field-errors/form-field-errors.component.ts @@ -0,0 +1,20 @@ +import {Component, input} from '@angular/core'; +import {FormControl} from '@angular/forms'; + +@Component({ + selector: 'app-form-field-errors', + imports: [], + templateUrl: './form-field-errors.component.html', + styleUrl: './form-field-errors.component.scss' +}) + +export class FormFieldErrorsComponent { + control = input.required(); + fieldName = input.required(); + isSubmitted = input.required(); + + protected isNameField = (): boolean => { + const name = this.fieldName(); + return name === 'name' || name === 'eventName'; + }; +} From d630f08066e6ded432155bcf4b3246ac64c77fe2 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:06:16 +0200 Subject: [PATCH 10/23] reduce speaker and session list with pagination-component --- .../pagination-list.component.html | 50 ++++++++ .../pagination-list.component.spec.ts | 23 ++++ .../pagination-list.component.ts | 60 ++++++++++ .../components/services/base-list.service.ts | 109 ++++-------------- .../services/pagination.service.spec.ts | 16 +++ .../components/services/pagination.service.ts | 92 +++++++++++++++ .../components/type/liste-state.ts | 17 +++ .../calendar-event-page.component.ts | 3 +- .../session-list-page.component.html | 51 +------- .../session-list-page.component.ts | 100 ++++++++-------- .../speaker-list-page.component.html | 75 +++--------- .../speaker-list-page.component.ts | 85 ++++++-------- 12 files changed, 380 insertions(+), 301 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.html create mode 100644 front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.ts create mode 100644 front/src/app/feature/admin-management/components/services/pagination.service.spec.ts create mode 100644 front/src/app/feature/admin-management/components/services/pagination.service.ts create mode 100644 front/src/app/feature/admin-management/components/type/liste-state.ts diff --git a/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.html b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.html new file mode 100644 index 00000000..7f421501 --- /dev/null +++ b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.html @@ -0,0 +1,50 @@ + diff --git a/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.spec.ts b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.spec.ts new file mode 100644 index 00000000..10f0873e --- /dev/null +++ b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PaginationListComponent } from './pagination-list.component'; + +describe('PaginationListComponent', () => { + let component: PaginationListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PaginationListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PaginationListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.ts b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.ts new file mode 100644 index 00000000..4e493105 --- /dev/null +++ b/front/src/app/feature/admin-management/components/pagination-list/pagination-list.component.ts @@ -0,0 +1,60 @@ +import { Component, computed, inject } from '@angular/core'; +import { ButtonComponent } from '../../../../shared/button/button.component'; +import { BaseListService } from '../services/base-list.service'; +import { SessionImportData } from '../../type/session/session'; + +@Component({ + selector: 'app-pagination-list', + imports: [ButtonComponent], + templateUrl: './pagination-list.component.html', + styleUrl: './pagination-list.component.scss' +}) +export class PaginationListComponent { + readonly listService = inject(BaseListService); + private readonly pagination = this.listService.paginationService; + + readonly pageNumbers = computed(() => this.pagination.getPageNumbers()); + readonly currentPage = computed(() => this.pagination.currentPageSignal()); + readonly itemsPerPage = computed(() => this.pagination.itemsPerPageSignal()); + readonly totalElement = computed(() => this.pagination.totalItemsSignal()); + readonly totalPages = computed(() => this.pagination.totalPagesSignal()); + + readonly paginationInfo = computed(() => { + const current = this.currentPage(); + const itemsPerPageValue = this.itemsPerPage(); + const total = this.totalElement(); + + return { + start: (current - 1) * itemsPerPageValue + 1, + end: Math.min(current * itemsPerPageValue, total), + total + }; + }); + + readonly canGoToPreviousPage = computed(() => { + const canGo = this.currentPage() > 1; + return canGo; + }); + + readonly canGoToNextPage = computed(() => { + const canGo = this.currentPage() < this.totalPages(); + return canGo; + }); + + goToPage(page: number): void { + if (page < 1 || page > this.totalPages()) return; + this.pagination.goToPage(page); + } + + goToPreviousPage(): void { + if (this.canGoToPreviousPage()) { + this.goToPage(this.currentPage() - 1); + } + } + + goToNextPage(): void { + if (this.canGoToNextPage()) { + this.goToPage(this.currentPage() + 1); + } + } +} diff --git a/front/src/app/feature/admin-management/components/services/base-list.service.ts b/front/src/app/feature/admin-management/components/services/base-list.service.ts index 8f3e2b08..554273ce 100644 --- a/front/src/app/feature/admin-management/components/services/base-list.service.ts +++ b/front/src/app/feature/admin-management/components/services/base-list.service.ts @@ -5,26 +5,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EventDTO } from '../../type/event/eventDTO'; import { EventService } from '../../services/event/event.service'; import { EventDataService } from '../../services/event/event-data.service'; - -export type ListState = { - eventId: string; - eventUrl: string; - eventName: string; - teamId: string; - teamUrl: string; - event: EventDTO | null; - isLoading: boolean; - error: string | null; - isLoadingItems: boolean; - searchTerm: string; - totalItems: number; - currentPage: number; - itemsPerPage: number; - totalPages: number; - selectedItems: string[]; - selectAll: boolean; - currentUserRole: string; -} +import { PaginationService } from './pagination.service'; +import {ListState} from '../type/liste-state'; @Injectable() export class BaseListService { @@ -39,10 +21,6 @@ export class BaseListService { error: null, isLoadingItems: false, searchTerm: '', - totalItems: 0, - currentPage: 1, - itemsPerPage: 10, - totalPages: 0, selectedItems: [], selectAll: false, currentUserRole: 'Owner' @@ -50,6 +28,7 @@ export class BaseListService { public readonly state$: Observable = this._state$.asObservable(); + readonly paginationService = new PaginationService(); private readonly _items$ = new BehaviorSubject([]); private readonly _filteredItems$ = new BehaviorSubject([]); private routeSubscription?: any; @@ -138,67 +117,28 @@ export class BaseListService { updateItems(items: T[]): void { this._items$.next(items); this._filteredItems$.next([...items]); - this.updateState({ - totalItems: items.length, - currentPage: 1 - }); - this.calculatePagination(); + + this.paginationService.setItems(items); } updateFilteredItems(filteredItems: T[]): void { this._filteredItems$.next(filteredItems); - this.updateItemsAfterFilter(filteredItems.length); + this.paginationService.updateFilteredItems(filteredItems); + + this.updateState({ + selectedItems: [], + selectAll: false + }); } onSearch(searchTerm: string): void { this.updateState({ searchTerm: searchTerm.toLowerCase() }); } - private calculatePagination(): void { - const state = this.getCurrentState(); - const totalPages = Math.ceil(state.totalItems / state.itemsPerPage); - this.updateState({ totalPages }); - } - - goToPage(page: number): void { - const state = this.getCurrentState(); - if (page >= 1 && page <= state.totalPages) { - this.updateState({ currentPage: page }); - } - } - - getPaginatedItems(): T[] { - const state = this.getCurrentState(); - const filteredItems = this._filteredItems$.value; - const startIndex : number = (state.currentPage - 1) * state.itemsPerPage; - const endIndex : number = startIndex + state.itemsPerPage; - return filteredItems.slice(startIndex, endIndex); - } - - getPageNumbers(): number[] { - const state = this.getCurrentState(); - const pages: number[] = []; - const maxVisiblePages = 5; - const halfVisible : number = Math.floor(maxVisiblePages / 2); - - let startPage : number = Math.max(1, state.currentPage - halfVisible); - let endPage : number = Math.min(state.totalPages, startPage + maxVisiblePages - 1); - - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(1, endPage - maxVisiblePages + 1); - } - - for (let i : number = startPage; i <= endPage; i++) { - pages.push(i); - } - - return pages; - } - toggleItemSelection(itemId: string): void { const state = this.getCurrentState(); - const selectedItems : string[] = [...state.selectedItems]; - const index : number = selectedItems.indexOf(itemId); + const selectedItems = [...state.selectedItems]; + const index = selectedItems.indexOf(itemId); if (index > -1) { selectedItems.splice(index, 1); @@ -212,7 +152,7 @@ export class BaseListService { toggleSelectAll(getItemId: (item: T) => string): void { const state = this.getCurrentState(); - const paginatedItems = this.getPaginatedItems(); + const paginatedItems = this.paginationService.getPaginatedItems(); if (state.selectAll) { this.updateState({ selectedItems: [], selectAll: false }); @@ -226,19 +166,12 @@ export class BaseListService { private updateSelectAllState(): void { const state = this.getCurrentState(); - const paginatedItems = this.getPaginatedItems(); - const selectAll : boolean = state.selectedItems.length > 0 && paginatedItems.length > 0; - this.updateState({ selectAll }); - } - private updateItemsAfterFilter(totalFilteredItems: number): void { - this.updateState({ - totalItems: totalFilteredItems, - currentPage: 1, - selectedItems: [], - selectAll: false - }); - this.calculatePagination(); + const paginatedItems = this.paginationService.getPaginatedItems(); + + const selectAll = state.selectedItems.length > 0 && + paginatedItems.length > 0; + this.updateState({ selectAll }); } handleImageError(event: Event): void { @@ -248,7 +181,8 @@ export class BaseListService { updateState(partialState: Partial): void { const currentState = this._state$.value; - this._state$.next({ ...currentState, ...partialState }); + const newState = { ...currentState, ...partialState }; + this._state$.next(newState); } getCurrentState(): ListState { @@ -270,5 +204,6 @@ export class BaseListService { this._state$.complete(); this._items$.complete(); this._filteredItems$.complete(); + this.paginationService.reset(); } } diff --git a/front/src/app/feature/admin-management/components/services/pagination.service.spec.ts b/front/src/app/feature/admin-management/components/services/pagination.service.spec.ts new file mode 100644 index 00000000..2a07175c --- /dev/null +++ b/front/src/app/feature/admin-management/components/services/pagination.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PaginationService } from './pagination.service'; + +describe('PaginationService', () => { + let service: PaginationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PaginationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/services/pagination.service.ts b/front/src/app/feature/admin-management/components/services/pagination.service.ts new file mode 100644 index 00000000..5cf8a73e --- /dev/null +++ b/front/src/app/feature/admin-management/components/services/pagination.service.ts @@ -0,0 +1,92 @@ +import { computed, Injectable, signal } from '@angular/core'; + +@Injectable() +export class PaginationService { + private readonly _currentPageSignal = signal(1); + private readonly _filteredItemsSignal = signal([]); + readonly itemsPerPageSignal = signal(10); + readonly totalPagesSignal = signal(0); + readonly totalItemsSignal = signal(0); + + readonly currentPageSignal = computed(() => this._currentPageSignal()); + + readonly paginatedItemsSignal = computed(() => { + const currentPage = this._currentPageSignal(); + const filteredItems = this._filteredItemsSignal(); + const itemsPerPage = this.itemsPerPageSignal(); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const result = filteredItems.slice(startIndex, endIndex); + + return result; + }); + + setItems(items: T[]): void { + this._filteredItemsSignal.set([...items]); + this.totalItemsSignal.set(items.length); + this._currentPageSignal.set(1); + this.calculateTotalPages(); + } + + updateFilteredItems(filteredItems: T[]): void { + this._filteredItemsSignal.set(filteredItems); + this.totalItemsSignal.set(filteredItems.length); + this._currentPageSignal.set(1); + this.calculateTotalPages(); + } + + goToPage(page: number): void { + const totalPages = this.totalPagesSignal(); + const currentPage = this._currentPageSignal(); + + if (!Number.isInteger(page) || page < 1 || page > totalPages) { + return; + } + + if (page === currentPage) { + return; + } + + this._currentPageSignal.set(page); + } + + getPageNumbers(): number[] { + const currentPage = this._currentPageSignal(); + const totalPages = this.totalPagesSignal(); + const pages: number[] = []; + const maxVisiblePages = 5; + const halfVisible = Math.floor(maxVisiblePages / 2); + + let startPage = Math.max(1, currentPage - halfVisible); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return pages; + } + + reset(): void { + this._currentPageSignal.set(1); + this._filteredItemsSignal.set([]); + this.totalItemsSignal.set(0); + this.totalPagesSignal.set(0); + } + + getPaginatedItems(): T[] { + return this.paginatedItemsSignal(); + } + + private calculateTotalPages(): void { + const totalItems = this.totalItemsSignal(); + const itemsPerPage = this.itemsPerPageSignal(); + const totalPages = Math.ceil(totalItems / itemsPerPage); + + this.totalPagesSignal.set(totalPages); + } +} diff --git a/front/src/app/feature/admin-management/components/type/liste-state.ts b/front/src/app/feature/admin-management/components/type/liste-state.ts new file mode 100644 index 00000000..2c423ab2 --- /dev/null +++ b/front/src/app/feature/admin-management/components/type/liste-state.ts @@ -0,0 +1,17 @@ +import {EventDTO} from '../../type/event/eventDTO'; + +export type ListState = { + eventId: string; + eventUrl: string; + eventName: string; + teamId: string; + teamUrl: string; + event: EventDTO | null; + isLoading: boolean; + error: string | null; + isLoadingItems: boolean; + searchTerm: string; + selectedItems: string[]; + selectAll: boolean; + currentUserRole: string; +} diff --git a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts index 8f9f43e5..27d0c718 100644 --- a/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts +++ b/front/src/app/feature/admin-management/pages/calendar/calendar-event-page/calendar-event-page.component.ts @@ -8,8 +8,9 @@ import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { CalendarDayData, CalendarSession, CalendarSessionData } from '../../../type/calendar/calendar'; -import { BaseListService, ListState } from '../../../components/services/base-list.service'; +import { BaseListService } from '../../../components/services/base-list.service'; import {ButtonComponent} from '../../../../../shared/button/button.component'; +import {ListState} from '../../../components/type/liste-state'; @Component({ selector: 'app-calendar-event-page', diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html index b704c0f6..f6ebc047 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html @@ -173,56 +173,7 @@ @if (totalPages() > 1) { - + }
} diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index e202f5ff..422a1ce3 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -3,16 +3,17 @@ import { ActivatedRoute, Router } from '@angular/router'; import { finalize, Observable } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AsyncPipe } from '@angular/common'; - import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Category, Format, SessionImportData, Speaker } from '../../../type/session/session'; import { SessionFilters } from '../../../type/session/session-filters'; import { SessionFilterPopupComponent } from '../../../components/session/session-filter-popup/session-filter-popup.component'; -import { BaseListService, ListState } from '../../../components/services/base-list.service'; +import { BaseListService } from '../../../components/services/base-list.service'; import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; -import {ButtonComponent} from '../../../../../shared/button/button.component'; +import { ButtonComponent } from '../../../../../shared/button/button.component'; +import { PaginationListComponent } from '../../../components/pagination-list/pagination-list.component'; +import {ListState} from '../../../components/type/liste-state'; @Component({ selector: 'app-session-list-page', @@ -23,7 +24,8 @@ import {ButtonComponent} from '../../../../../shared/button/button.component'; ReactiveFormsModule, SessionFilterPopupComponent, AsyncPipe, - ButtonComponent + ButtonComponent, + PaginationListComponent ], providers: [BaseListService], templateUrl: './session-list-page.component.html', @@ -31,7 +33,6 @@ import {ButtonComponent} from '../../../../../shared/button/button.component'; }) export class SessionListPageComponent implements OnInit { readonly icon = input('search'); - readonly showFilterPopup = signal(false); readonly availableFormats = signal([]); readonly availableCategories = signal([]); @@ -42,7 +43,8 @@ export class SessionListPageComponent implements OnInit { readonly hasActiveFilters = computed(() => { const filters = this.currentFilters(); - return filters.selectedFormats.length > 0 || filters.selectedCategories.length > 0; + return filters.selectedFormats.length > 0 || + filters.selectedCategories.length > 0; }); readonly activeFiltersCount = computed(() => { @@ -50,31 +52,33 @@ export class SessionListPageComponent implements OnInit { return filters.selectedFormats.length + filters.selectedCategories.length; }); - readonly totalSessions = computed(() => this.listService.getCurrentState().totalItems); - readonly isLoadingSessions = computed(() => this.listService.getCurrentState().isLoadingItems); - readonly paginatedSessions = computed(() => this.listService.getPaginatedItems()); - readonly currentPage = computed(() => this.listService.getCurrentState().currentPage); - readonly totalPages = computed(() => this.listService.getCurrentState().totalPages); - readonly itemsPerPage = computed(() => this.listService.getCurrentState().itemsPerPage); - readonly pageNumbers = computed(() => this.listService.getPageNumbers()); - readonly selectAll = computed(() => this.listService.getCurrentState().selectAll); - readonly selectedItems = computed(() => this.listService.getCurrentState().selectedItems); - readonly searchTerm = computed(() => this.listService.getCurrentState().searchTerm); - - readonly paginationInfo = computed(() => { - const current = this.currentPage(); - const itemsPerPageValue = this.itemsPerPage(); - const total = this.totalSessions(); - - return { - start: (current - 1) * itemsPerPageValue + 1, - end: Math.min(current * itemsPerPageValue, total), - total - }; - }); + readonly totalSessions = computed(() => + this.listService.paginationService.totalItemsSignal() + ); + + readonly isLoadingSessions = computed(() => + this.listService.getCurrentState().isLoadingItems + ); + + readonly paginatedSessions = computed(() => + this.listService.paginationService.paginatedItemsSignal() + ); - readonly canGoToPreviousPage = computed(() => this.currentPage() > 1); - readonly canGoToNextPage = computed(() => this.currentPage() < this.totalPages()); + readonly totalPages = computed(() => + this.listService.paginationService.totalPagesSignal() + ); + + readonly selectAll = computed(() => + this.listService.getCurrentState().selectAll + ); + + readonly selectedItems = computed(() => + this.listService.getCurrentState().selectedItems + ); + + readonly searchTerm = computed(() => + this.listService.getCurrentState().searchTerm + ); readonly Math = Math; readonly listService = inject(BaseListService); @@ -156,11 +160,6 @@ export class SessionListPageComponent implements OnInit { this.applyFilters(); } - goToPage(page: number): void { - if (page < 1 || page > this.totalPages()) return; - this.listService.goToPage(page); - } - onSubmit(event: Event): void { event.preventDefault(); } @@ -183,7 +182,9 @@ export class SessionListPageComponent implements OnInit { } toggleSelectAll(): void { - this.listService.toggleSelectAll((session: SessionImportData) => this.getItemId(session)); + this.listService.toggleSelectAll( + (session: SessionImportData) => this.getItemId(session) + ); } onRowClick(session: SessionImportData, event: Event): void { @@ -284,7 +285,10 @@ export class SessionListPageComponent implements OnInit { this.listService.updateFilteredItems(filtered); } - private filterByFormats(sessions: SessionImportData[], selectedFormats: string[]): SessionImportData[] { + private filterByFormats( + sessions: SessionImportData[], + selectedFormats: string[] + ): SessionImportData[] { return sessions.filter(session => session.formats?.some(format => selectedFormats.includes(format.id) @@ -292,7 +296,10 @@ export class SessionListPageComponent implements OnInit { ); } - private filterByCategories(sessions: SessionImportData[], selectedCategories: string[]): SessionImportData[] { + private filterByCategories( + sessions: SessionImportData[], + selectedCategories: string[] + ): SessionImportData[] { return sessions.filter(session => session.categories?.some(category => selectedCategories.includes(category.id) @@ -300,7 +307,10 @@ export class SessionListPageComponent implements OnInit { ); } - private filterBySearchTerm(sessions: SessionImportData[], searchTerm: string): SessionImportData[] { + private filterBySearchTerm( + sessions: SessionImportData[], + searchTerm: string + ): SessionImportData[] { const searchLower = searchTerm.toLowerCase(); return sessions.filter(session => @@ -311,16 +321,4 @@ export class SessionListPageComponent implements OnInit { ) ); } - - goToPreviousPage(): void { - if (this.canGoToPreviousPage()) { - this.goToPage(this.currentPage() - 1); - } - } - - goToNextPage(): void { - if (this.canGoToNextPage()) { - this.goToPage(this.currentPage() + 1); - } - } } diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html index e4ebe6ec..6353448c 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html @@ -27,7 +27,7 @@

Speakers Management

@if (!state.isLoading && !state.error) {
- @if (totalSpeakers !== 0) { + @if (totalSpeakers() !== 0) {
@@ -39,7 +39,7 @@

Speakers Management

placeholder="Search for speakers..." class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" (input)="onSearch($event)" - [value]="searchTerm" + [value]="searchTerm()" aria-describedby="search-help" />
} - @if (isLoadingSpeakers) { + @if (isLoadingSpeakers()) {
Loading speakers... @@ -100,7 +100,7 @@

Speakers Management

- @if (totalSpeakers !== 0) { + @if (totalSpeakers() !== 0) { } - @if (paginatedSpeakers.length === 0) { + @if (paginatedSpeakers().length === 0) { } @else { - @for (speaker of paginatedSpeakers; track speaker.email) { + @for (speaker of paginatedSpeakers(); track speaker.email) {
@@ -108,28 +108,28 @@

Speakers Management

+ [attr.aria-label]="'Select all ' + totalSpeakers() + ' speakers'">
- {{ totalSpeakers }} speaker{{ totalSpeakers > 1 ? 's' : '' }} + {{ totalSpeakers() }} speaker{{ totalSpeakers() > 1 ? 's' : '' }}
- @if (searchTerm) { + @if (searchTerm()) {

No speakers found

-

for "{{ searchTerm }}"

+

for "{{ searchTerm() }}"

} @else {
@@ -141,7 +141,7 @@

Speakers Management

- @if (totalPages > 1) { - + @if (totalPages() > 1) { + }
} diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts index fff1d064..6afb34b9 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts @@ -1,4 +1,4 @@ -import { Component, input, OnInit, OnDestroy, inject } from '@angular/core'; +import {Component, input, OnInit, OnDestroy, inject, computed} from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { finalize, Observable } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -11,10 +11,12 @@ import { SpeakerWithSessionsDTO } from '../../../type/speaker/speaker-with-sessi import { SpeakerService } from '../../../services/speaker/speaker.service'; import { SpeakerFilterPopupComponent } from '../../../components/speaker/speaker-filter-popup/speaker-filter-popup.component'; import { isDefined } from '../../../../../shared/type/predicates'; -import { BaseListService, ListState } from '../../../components/services/base-list.service'; +import { BaseListService } from '../../../components/services/base-list.service'; import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import {ButtonComponent} from '../../../../../shared/button/button.component'; +import {PaginationListComponent} from '../../../components/pagination-list/pagination-list.component'; +import {ListState} from '../../../components/type/liste-state'; @Component({ selector: 'app-speaker-list-page', @@ -24,7 +26,8 @@ import {ButtonComponent} from '../../../../../shared/button/button.component'; ReactiveFormsModule, SpeakerFilterPopupComponent, AsyncPipe, - ButtonComponent + ButtonComponent, + PaginationListComponent ], providers: [BaseListService], templateUrl: './speaker-list-page.component.html', @@ -55,6 +58,34 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { readonly Math = Math; + readonly totalSpeakers = computed(() => + this.listService.paginationService.totalItemsSignal() + ); + + readonly isLoadingSpeakers = computed(() => + this.listService.getCurrentState().isLoadingItems + ); + + readonly paginatedSpeakers = computed(() => + this.listService.paginationService.paginatedItemsSignal() + ); + + readonly totalPages = computed(() => + this.listService.paginationService.totalPagesSignal() + ); + + readonly selectAll = computed(() => + this.listService.getCurrentState().selectAll + ); + + readonly selectedItems = computed(() => + this.listService.getCurrentState().selectedItems + ); + + readonly searchTerm = computed(() => + this.listService.getCurrentState().searchTerm + ); + constructor() { (this.listService as any).eventService = this.eventService; (this.listService as any).eventDataService = this.eventDataService; @@ -89,8 +120,6 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { ) .subscribe({ next: (speakersWithSessions: SpeakerWithSessionsDTO[]) => { - console.log('Received speakers with sessions:', speakersWithSessions); - this.speakersWithSessions = speakersWithSessions; const speakers: Speaker[] = speakersWithSessions.map(sws => sws.speaker); @@ -123,46 +152,6 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { this.speakersWithSessions = []; } - get totalSpeakers(): number { - return this.listService.getCurrentState().totalItems; - } - - get isLoadingSpeakers(): boolean { - return this.listService.getCurrentState().isLoadingItems; - } - - get paginatedSpeakers(): Speaker[] { - return this.listService.getPaginatedItems(); - } - - get currentPage(): number { - return this.listService.getCurrentState().currentPage; - } - - get totalPages(): number { - return this.listService.getCurrentState().totalPages; - } - - get itemsPerPage(): number { - return this.listService.getCurrentState().itemsPerPage; - } - - get pageNumbers(): number[] { - return this.listService.getPageNumbers(); - } - - get selectAll(): boolean { - return this.listService.getCurrentState().selectAll; - } - - get selectedItems(): string[] { - return this.listService.getCurrentState().selectedItems; - } - - get searchTerm(): string { - return this.listService.getCurrentState().searchTerm; - } - onFiltersApplied(filters: SpeakerFilters): void { this.currentFilters = filters; this.applyFilters(); @@ -207,10 +196,6 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { this.applyFilters(); } - goToPage(page: number): void { - this.listService.goToPage(page); - } - onSubmit(event: Event): void { event.preventDefault(); } @@ -221,7 +206,7 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { isSpeakerSelected(speakerName: string | undefined): boolean { if (!speakerName) return false; - return this.selectedItems.includes(speakerName); + return this.selectedItems().includes(speakerName); } toggleSpeakerSelection(speakerName: string): void { From 2a69cf2aa873620018993aaef10c1a21f3876585 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:46:04 +0200 Subject: [PATCH 11/23] reduce speaker and session list --- .../session-list-page.component.html | 4 +- .../session-list-page.component.ts | 279 ++++-------------- .../speaker-list-page.component.html | 18 +- .../speaker-list-page.component.ts | 241 ++++----------- .../sessions/session-filter.service.spec.ts | 16 + .../sessions/session-filter.service.ts | 108 +++++++ .../sessions/session-formatter.service.ts | 22 ++ .../speaker/speaker-filter.service.spec.ts | 16 + .../speaker/speaker-filter.service.ts | 167 +++++++++++ .../speaker/speaker-formatter.service.spec.ts | 16 + .../speaker/speaker-formatter.service.ts | 22 ++ 11 files changed, 496 insertions(+), 413 deletions(-) create mode 100644 front/src/app/feature/admin-management/services/sessions/session-filter.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-filter.service.ts create mode 100644 front/src/app/feature/admin-management/services/speaker/speaker-filter.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/speaker/speaker-filter.service.ts create mode 100644 front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.ts diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html index f6ebc047..39de7e5b 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html @@ -154,10 +154,10 @@
- +
-

+

{{ session.title || 'Titre non défini' }}

diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index 422a1ce3..28974e5e 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -1,19 +1,19 @@ -import { Component, input, OnInit, inject, signal, computed } from '@angular/core'; +import { Component, input, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { finalize, Observable } from 'rxjs'; +import { finalize } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AsyncPipe } from '@angular/common'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Category, Format, SessionImportData, Speaker } from '../../../type/session/session'; -import { SessionFilters } from '../../../type/session/session-filters'; +import { SessionImportData } from '../../../type/session/session'; import { SessionFilterPopupComponent } from '../../../components/session/session-filter-popup/session-filter-popup.component'; import { BaseListService } from '../../../components/services/base-list.service'; import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import { ButtonComponent } from '../../../../../shared/button/button.component'; import { PaginationListComponent } from '../../../components/pagination-list/pagination-list.component'; -import {ListState} from '../../../components/type/liste-state'; +import {SessionFilterService} from '../../../services/sessions/session-filter.service'; +import {SessionFormatterService} from '../../../services/sessions/session-formatter.service'; @Component({ selector: 'app-session-list-page', @@ -27,82 +27,45 @@ import {ListState} from '../../../components/type/liste-state'; ButtonComponent, PaginationListComponent ], - providers: [BaseListService], + providers: [BaseListService, SessionFilterService], templateUrl: './session-list-page.component.html', styleUrl: './session-list-page.component.scss' }) export class SessionListPageComponent implements OnInit { readonly icon = input('search'); - readonly showFilterPopup = signal(false); - readonly availableFormats = signal([]); - readonly availableCategories = signal([]); - readonly currentFilters = signal({ - selectedFormats: [], - selectedCategories: [] - }); - - readonly hasActiveFilters = computed(() => { - const filters = this.currentFilters(); - return filters.selectedFormats.length > 0 || - filters.selectedCategories.length > 0; - }); - - readonly activeFiltersCount = computed(() => { - const filters = this.currentFilters(); - return filters.selectedFormats.length + filters.selectedCategories.length; - }); - - readonly totalSessions = computed(() => - this.listService.paginationService.totalItemsSignal() - ); - - readonly isLoadingSessions = computed(() => - this.listService.getCurrentState().isLoadingItems - ); - - readonly paginatedSessions = computed(() => - this.listService.paginationService.paginatedItemsSignal() - ); - - readonly totalPages = computed(() => - this.listService.paginationService.totalPagesSignal() - ); - - readonly selectAll = computed(() => - this.listService.getCurrentState().selectAll - ); + readonly listService = inject(BaseListService); + readonly filterService = inject(SessionFilterService); + readonly formatterService = inject(SessionFormatterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly eventService = inject(EventService); - readonly selectedItems = computed(() => - this.listService.getCurrentState().selectedItems - ); + readonly showFilterPopup = signal(false); - readonly searchTerm = computed(() => - this.listService.getCurrentState().searchTerm - ); + readonly state$ = this.listService.state$; + readonly totalSessions = this.listService.paginationService.totalItemsSignal; + readonly isLoadingSessions = () => this.listService.getCurrentState().isLoadingItems; + readonly paginatedSessions = this.listService.paginationService.paginatedItemsSignal; + readonly totalPages = this.listService.paginationService.totalPagesSignal; + readonly selectAll = () => this.listService.getCurrentState().selectAll; + readonly selectedItems = () => this.listService.getCurrentState().selectedItems; + readonly searchTerm = () => this.listService.getCurrentState().searchTerm; + readonly availableFormats = this.filterService.availableFormats; + readonly availableCategories = this.filterService.availableCategories; + readonly currentFilters = this.filterService.currentFilters; + readonly hasActiveFilters = this.filterService.hasActiveFilters; + readonly activeFiltersCount = this.filterService.activeFiltersCount; + formatSpeakers = this.formatterService.formatSpeakers.bind(this.formatterService); readonly Math = Math; - readonly listService = inject(BaseListService); - readonly state$: Observable = this.listService.state$; - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly eventService: EventService, - eventDataService: EventDataService - ) { - (this.listService as any).eventService = eventService; + constructor(eventDataService: EventDataService) { + (this.listService as any).eventService = this.eventService; (this.listService as any).eventDataService = eventDataService; } ngOnInit(): void { - this.initializeRouteSubscription(); - } - - private initializeRouteSubscription(): void { - this.listService.initializeRouteSubscription( - this.route, - () => this.loadItems() - ); + this.listService.initializeRouteSubscription(this.route, () => this.loadItems()); } private async loadItems(): Promise { @@ -119,15 +82,13 @@ export class SessionListPageComponent implements OnInit { ) .subscribe({ next: (sessions: SessionImportData[]) => { - const sortedSessions = this.sortSessionsByTitle(sessions); + const sortedSessions = this.formatterService.sortByTitle(sessions); this.listService.updateItems(sortedSessions); - this.extractAvailableFilters(sessions); + this.filterService.extractFiltersFromSessions(sessions); resolve(); }, error: () => { - this.listService.updateState({ - error: 'Failed to load sessions. Please try again.' - }); + this.listService.updateState({ error: 'Failed to load sessions. Please try again.' }); this.listService.updateItems([]); reject(); } @@ -135,23 +96,30 @@ export class SessionListPageComponent implements OnInit { }); } - private sortSessionsByTitle(sessions: SessionImportData[]): SessionImportData[] { - return sessions.sort((a, b) => { - const titleA = a.title?.toLowerCase() || ''; - const titleB = b.title?.toLowerCase() || ''; - return titleA.localeCompare(titleB); - }); + openFilterPopup(): void { + this.showFilterPopup.set(true); } - getItemId(session: SessionImportData): string { - return session.id || ''; + closeFilterPopup(): void { + this.showFilterPopup.set(false); } - openItemDetail(sessionId: string): void { - const currentState = this.listService.getCurrentState(); - if (sessionId) { - this.router.navigate(['/event', currentState.eventId, 'session', sessionId]); - } + onFiltersApplied(filters: any): void { + this.filterService.updateFilters(filters); + this.applyFilters(); + this.closeFilterPopup(); + } + + onFiltersReset(): void { + this.filterService.resetFilters(); + this.applyFilters(); + } + + private applyFilters(): void { + const items = this.listService.getCurrentItems(); + const searchTerm = this.listService.getCurrentState().searchTerm; + const filtered = this.filterService.applyAllFilters(items, searchTerm); + this.listService.updateFilteredItems(filtered); } onSearch(event: Event): void { @@ -164,14 +132,6 @@ export class SessionListPageComponent implements OnInit { event.preventDefault(); } - formatSpeakers(speakers: Speaker[] | undefined): string { - if (!speakers || speakers.length === 0) return 'Aucun speaker'; - return speakers - .map(speaker => speaker.name) - .filter(name => name) - .join(', '); - } - isSessionSelected(sessionId: string | undefined): boolean { if (!sessionId) return false; return this.selectedItems().includes(sessionId); @@ -182,18 +142,24 @@ export class SessionListPageComponent implements OnInit { } toggleSelectAll(): void { - this.listService.toggleSelectAll( - (session: SessionImportData) => this.getItemId(session) + this.listService.toggleSelectAll((session: SessionImportData) => + this.formatterService.getSessionId(session) ); } + openItemDetail(sessionId: string): void { + const currentState = this.listService.getCurrentState(); + if (sessionId) { + this.router.navigate(['/event', currentState.eventId, 'session', sessionId]); + } + } + onRowClick(session: SessionImportData, event: Event): void { event.preventDefault(); - const target = event.target as HTMLElement; const isCheckboxArea = target.closest('.checkbox-area'); + const sessionId = this.formatterService.getSessionId(session); - const sessionId = this.getItemId(session); if (!sessionId) return; if (isCheckboxArea) { @@ -202,123 +168,4 @@ export class SessionListPageComponent implements OnInit { this.openItemDetail(sessionId); } } - - private extractAvailableFilters(sessions: SessionImportData[]): void { - const formats = this.extractUniqueFormats(sessions); - const categories = this.extractUniqueCategories(sessions); - - this.availableFormats.set(formats); - this.availableCategories.set(categories); - } - - private extractUniqueFormats(sessions: SessionImportData[]): Format[] { - const formatMap = new Map(); - - sessions.forEach(session => { - session.formats?.forEach(format => { - if (format.id && !formatMap.has(format.id)) { - formatMap.set(format.id, format); - } - }); - }); - - return Array.from(formatMap.values()) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - private extractUniqueCategories(sessions: SessionImportData[]): Category[] { - const categoryMap = new Map(); - - sessions.forEach(session => { - session.categories?.forEach(category => { - if (category.id && !categoryMap.has(category.id)) { - categoryMap.set(category.id, category); - } - }); - }); - - return Array.from(categoryMap.values()) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - openFilterPopup(): void { - this.showFilterPopup.set(true); - } - - closeFilterPopup(): void { - this.showFilterPopup.set(false); - } - - onFiltersApplied(filters: SessionFilters): void { - this.currentFilters.set(filters); - this.applyFilters(); - this.closeFilterPopup(); - } - - onFiltersReset(): void { - this.currentFilters.set({ - selectedFormats: [], - selectedCategories: [] - }); - this.applyFilters(); - } - - private applyFilters(): void { - const items = this.listService.getCurrentItems(); - let filtered = [...items]; - - const filters = this.currentFilters(); - - if (filters.selectedFormats.length > 0) { - filtered = this.filterByFormats(filtered, filters.selectedFormats); - } - - if (filters.selectedCategories.length > 0) { - filtered = this.filterByCategories(filtered, filters.selectedCategories); - } - - const searchTerm = this.listService.getCurrentState().searchTerm; - if (searchTerm.trim()) { - filtered = this.filterBySearchTerm(filtered, searchTerm); - } - - this.listService.updateFilteredItems(filtered); - } - - private filterByFormats( - sessions: SessionImportData[], - selectedFormats: string[] - ): SessionImportData[] { - return sessions.filter(session => - session.formats?.some(format => - selectedFormats.includes(format.id) - ) - ); - } - - private filterByCategories( - sessions: SessionImportData[], - selectedCategories: string[] - ): SessionImportData[] { - return sessions.filter(session => - session.categories?.some(category => - selectedCategories.includes(category.id) - ) - ); - } - - private filterBySearchTerm( - sessions: SessionImportData[], - searchTerm: string - ): SessionImportData[] { - const searchLower = searchTerm.toLowerCase(); - - return sessions.filter(session => - session.title?.toLowerCase().includes(searchLower) || - session.abstractText?.toLowerCase().includes(searchLower) || - session.speakers?.some(speaker => - speaker.name?.toLowerCase().includes(searchLower) - ) - ); - } } diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html index 6353448c..e2de6bde 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html @@ -62,22 +62,22 @@

Speakers Management

customTextClass="text-gray-600 hover:text-gray-600" [customClass]="'text-gray-600 hover:text-gray-600 flex-shrink-0 rounded-md text-base items-center gap-2 bg-white hover:bg-gray-100 border border-gray-300 cursor-pointer shadow-sm relative'" (click)="openFilterPopup()" - [attr.aria-label]="'Open filters' + (hasActiveFilters ? ' (' + activeFiltersCount + ' active)' : '')" - [attr.aria-expanded]="showFilterPopup"> + [attr.aria-label]="'Open filters' + (hasActiveFilters() ? ' (' + activeFiltersCount + ' active)' : '')" + [attr.aria-expanded]="showFilterPopup()"> Filters - @if (hasActiveFilters) { + @if (hasActiveFilters()) { - {{ activeFiltersCount }} + {{ activeFiltersCount() }} } - @if (showFilterPopup) { + @if (showFilterPopup()) { Speakers Management
- +
('person'); - showFilterPopup: boolean = false; - availableFormats: Format[] = []; - availableCategories: Category[] = []; - speakersWithSessions: SpeakerWithSessionsDTO[] = []; + readonly showFilterPopup = signal(false); - currentFilters: SpeakerFilters = { - selectedFormats: [], - selectedCategories: [], - hasCompleteTasks: null - }; + private speakersWithSessions: SpeakerWithSessionsDTO[] = []; readonly listService = inject(BaseListService); - readonly route = inject(ActivatedRoute); - readonly router = inject(Router); - readonly speakerService = inject(SpeakerService); - readonly eventService = inject(EventService); - readonly eventDataService = inject(EventDataService); + readonly filterService = inject(SpeakerFilterService); + readonly formatterService = inject(SpeakerFormatterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly speakerService = inject(SpeakerService); + private readonly eventService = inject(EventService); readonly state$: Observable = this.listService.state$; + readonly totalSpeakers = this.listService.paginationService.totalItemsSignal; + readonly isLoadingSpeakers = () => this.listService.getCurrentState().isLoadingItems; + readonly paginatedSpeakers = this.listService.paginationService.paginatedItemsSignal; + readonly totalPages = this.listService.paginationService.totalPagesSignal; + readonly selectAll = () => this.listService.getCurrentState().selectAll; + readonly selectedItems = () => this.listService.getCurrentState().selectedItems; + readonly searchTerm = () => this.listService.getCurrentState().searchTerm; + readonly availableFormats = this.filterService.availableFormats; + readonly availableCategories = this.filterService.availableCategories; + readonly currentFilters = this.filterService.currentFilters; + readonly hasActiveFilters = this.filterService.hasActiveFilters; + readonly activeFiltersCount = this.filterService.activeFiltersCount; + onImageError = this.formatterService.handleImageError.bind(this.formatterService); readonly Math = Math; - readonly totalSpeakers = computed(() => - this.listService.paginationService.totalItemsSignal() - ); - - readonly isLoadingSpeakers = computed(() => - this.listService.getCurrentState().isLoadingItems - ); - - readonly paginatedSpeakers = computed(() => - this.listService.paginationService.paginatedItemsSignal() - ); - - readonly totalPages = computed(() => - this.listService.paginationService.totalPagesSignal() - ); - - readonly selectAll = computed(() => - this.listService.getCurrentState().selectAll - ); - - readonly selectedItems = computed(() => - this.listService.getCurrentState().selectedItems - ); - - readonly searchTerm = computed(() => - this.listService.getCurrentState().searchTerm - ); - - constructor() { + constructor(eventDataService: EventDataService) { (this.listService as any).eventService = this.eventService; - (this.listService as any).eventDataService = this.eventDataService; + (this.listService as any).eventDataService = eventDataService; } ngOnInit(): void { - this.initializeRouteSubscription(); + this.listService.initializeRouteSubscription(this.route, () => this.loadItems()); } ngOnDestroy(): void { this.listService.destroy(); } - private initializeRouteSubscription(): void { - this.listService.initializeRouteSubscription( - this.route, - () => this.loadItems() - ); - } - private async loadItems(): Promise { const currentState = this.listService.getCurrentState(); if (!currentState.eventId) return; @@ -122,15 +94,11 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { next: (speakersWithSessions: SpeakerWithSessionsDTO[]) => { this.speakersWithSessions = speakersWithSessions; - const speakers: Speaker[] = speakersWithSessions.map(sws => sws.speaker); - const sortedSpeakers: Speaker[] = speakers.sort((a, b) => { - const nameA: string = a.name?.toLowerCase() || ''; - const nameB: string = b.name?.toLowerCase() || ''; - return nameA.localeCompare(nameB); - }); + const speakers = speakersWithSessions.map(sws => sws.speaker); + const sortedSpeakers = this.formatterService.sortByName(speakers); this.listService.updateItems(sortedSpeakers); - this.extractAvailableFilters(); + this.filterService.extractFiltersFromSpeakers(speakersWithSessions); resolve(); }, error: (error: Error) => { @@ -147,48 +115,36 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { error: 'Failed to load speakers. Please check if sessions are imported first.', isLoadingItems: false }); - this.listService.updateItems([]); this.speakersWithSessions = []; } - onFiltersApplied(filters: SpeakerFilters): void { - this.currentFilters = filters; - this.applyFilters(); - this.closeFilterPopup(); + openFilterPopup(): void { + this.showFilterPopup.set(true); } - onFiltersReset(): void { - this.currentFilters = { - selectedFormats: [], - selectedCategories: [], - hasCompleteTasks: null - }; - this.applyFilters(); + closeFilterPopup(): void { + this.showFilterPopup.set(false); } - get hasActiveFilters(): boolean { - return this.currentFilters.selectedFormats.length > 0 || - this.currentFilters.selectedCategories.length > 0 || - this.currentFilters.hasCompleteTasks !== null; + onFiltersApplied(filters: any): void { + this.filterService.updateFilters(filters); + this.applyFilters(); + this.closeFilterPopup(); } - get activeFiltersCount(): number { - let count: number = 0; - count += this.currentFilters.selectedFormats.length; - count += this.currentFilters.selectedCategories.length; - if (this.currentFilters.hasCompleteTasks !== null) count += 1; - return count; + onFiltersReset(): void { + this.filterService.resetFilters(); + this.applyFilters(); } - getItemId(speaker: Speaker): string { - return speaker.email || ''; + private applyFilters(): void { + const items = this.listService.getCurrentItems(); + const searchTerm = this.listService.getCurrentState().searchTerm; + const filtered = this.filterService.applyAllFilters( items, this.speakersWithSessions, searchTerm ); + this.listService.updateFilteredItems(filtered); } - openItemDetail(speakerId: string): void { - const currentState = this.listService.getCurrentState(); - this.router.navigate(['event', currentState.eventId, 'speaker', speakerId]); - } onSearch(event: Event): void { const target = event.target as HTMLInputElement; @@ -200,9 +156,6 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { event.preventDefault(); } - onImageError = (event: Event): void => { - this.listService.handleImageError(event); - }; isSpeakerSelected(speakerName: string | undefined): boolean { if (!speakerName) return false; @@ -214,96 +167,12 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { } toggleSelectAll(): void { - this.listService.toggleSelectAll((speaker: Speaker) => this.getItemId(speaker)); + this.listService.toggleSelectAll((speaker: Speaker) => this.formatterService.getSpeakerId(speaker) ); } - openFilterPopup(): void { - this.showFilterPopup = true; - } - - closeFilterPopup(): void { - this.showFilterPopup = false; - } - - private extractAvailableFilters(): void { - const formatMap = new Map(); - const categoryMap = new Map(); - - this.speakersWithSessions.forEach(speakerWithSessions => { - speakerWithSessions.formats?.forEach(format => { - if (format.id && !formatMap.has(format.id)) { - formatMap.set(format.id, format); - } - }); - - speakerWithSessions.categories?.forEach(category => { - if (category.id && !categoryMap.has(category.id)) { - categoryMap.set(category.id, category); - } - }); - }); - - this.availableFormats = Array.from(formatMap.values()) - .sort((a, b) => a.name.localeCompare(b.name)); - - this.availableCategories = Array.from(categoryMap.values()) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - private applyFilters(): void { - const items = this.listService.getCurrentItems(); - let filtered: Speaker[] = [...items]; - - if (this.currentFilters.selectedFormats.length > 0) { - filtered = filtered.filter(speaker => { - const speakerWithSessions = this.speakersWithSessions.find( - sws => sws.speaker.email === speaker.email || sws.speaker.name === speaker.name - ); - - if (!speakerWithSessions || !speakerWithSessions.formats) { - return false; - } - - return speakerWithSessions.formats.some(format => - this.currentFilters.selectedFormats.includes(format.id) - ); - }); - } - - if (this.currentFilters.selectedCategories.length > 0) { - filtered = filtered.filter(speaker => { - const speakerWithSessions = this.speakersWithSessions.find( - sws => sws.speaker.email === speaker.email || sws.speaker.name === speaker.name - ); - - if (!speakerWithSessions || !speakerWithSessions.categories) { - return false; - } - - return speakerWithSessions.categories.some(category => - this.currentFilters.selectedCategories.includes(category.id) - ); - }); - } - if (this.currentFilters.hasCompleteTasks !== null) { - filtered = filtered.filter(speaker => { - const isComplete: boolean = isDefined(speaker.name && speaker.email && speaker.company && speaker.bio); - return this.currentFilters.hasCompleteTasks ? isComplete : !isComplete; - }); - } - - const searchTerm = this.listService.getCurrentState().searchTerm; - if (searchTerm.trim()) { - const searchLower: string = searchTerm.toLowerCase(); - filtered = filtered.filter(speaker => - speaker.name?.toLowerCase().includes(searchLower) || - speaker.email?.toLowerCase().includes(searchLower) || - speaker.company?.toLowerCase().includes(searchLower) || - speaker.bio?.toLowerCase().includes(searchLower) - ); - } - - this.listService.updateFilteredItems(filtered); + openItemDetail(speakerId: string): void { + const currentState = this.listService.getCurrentState(); + this.router.navigate(['event', currentState.eventId, 'speaker', speakerId]); } } diff --git a/front/src/app/feature/admin-management/services/sessions/session-filter.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/session-filter.service.spec.ts new file mode 100644 index 00000000..2ac9dd8c --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-filter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SessionFilterService } from './session-filter.service'; + +describe('SessionFilterService', () => { + let service: SessionFilterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionFilterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts b/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts new file mode 100644 index 00000000..d2c3cbb2 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts @@ -0,0 +1,108 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { Category, Format, SessionImportData } from '../../type/session/session'; +import { SessionFilters } from '../../type/session/session-filters'; + +@Injectable() +export class SessionFilterService { + readonly availableFormats = signal([]); + readonly availableCategories = signal([]); + readonly currentFilters = signal({ + selectedFormats: [], + selectedCategories: [] + }); + + readonly hasActiveFilters = computed(() => { + const filters = this.currentFilters(); + return filters.selectedFormats.length > 0 || filters.selectedCategories.length > 0; + }); + + readonly activeFiltersCount = computed(() => { + const filters = this.currentFilters(); + return filters.selectedFormats.length + filters.selectedCategories.length; + }); + + extractFiltersFromSessions(sessions: SessionImportData[]): void { + this.availableFormats.set(this.extractUniqueFormats(sessions)); + this.availableCategories.set(this.extractUniqueCategories(sessions)); + } + + applyAllFilters(sessions: SessionImportData[], searchTerm: string): SessionImportData[] { + let filtered = [...sessions]; + const filters = this.currentFilters(); + + if (filters.selectedFormats.length > 0) { + filtered = this.filterByFormats(filtered, filters.selectedFormats); + } + + if (filters.selectedCategories.length > 0) { + filtered = this.filterByCategories(filtered, filters.selectedCategories); + } + + if (searchTerm.trim()) { + filtered = this.filterBySearchTerm(filtered, searchTerm); + } + + return filtered; + } + + updateFilters(filters: SessionFilters): void { + this.currentFilters.set(filters); + } + + resetFilters(): void { + this.currentFilters.set({ + selectedFormats: [], + selectedCategories: [] + }); + } + + private extractUniqueFormats(sessions: SessionImportData[]): Format[] { + const formatMap = new Map(); + + sessions.forEach(session => { + session.formats?.forEach(format => { + if (format.id && !formatMap.has(format.id)) { + formatMap.set(format.id, format); + } + }); + }); + + return Array.from(formatMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + private extractUniqueCategories(sessions: SessionImportData[]): Category[] { + const categoryMap = new Map(); + + sessions.forEach(session => { + session.categories?.forEach(category => { + if (category.id && !categoryMap.has(category.id)) { + categoryMap.set(category.id, category); + } + }); + }); + + return Array.from(categoryMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + private filterByFormats(sessions: SessionImportData[], selectedFormats: string[]): SessionImportData[] { + return sessions.filter(session => + session.formats?.some(format => selectedFormats.includes(format.id)) + ); + } + + private filterByCategories(sessions: SessionImportData[], selectedCategories: string[]): SessionImportData[] { + return sessions.filter(session => + session.categories?.some(category => selectedCategories.includes(category.id)) + ); + } + + private filterBySearchTerm(sessions: SessionImportData[], searchTerm: string): SessionImportData[] { + const searchLower = searchTerm.toLowerCase(); + + return sessions.filter(session => + session.title?.toLowerCase().includes(searchLower) || + session.abstractText?.toLowerCase().includes(searchLower) || + session.speakers?.some(speaker => speaker.name?.toLowerCase().includes(searchLower)) + ); + } +} diff --git a/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts index 682e566c..011f21fa 100644 --- a/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts +++ b/front/src/app/feature/admin-management/services/sessions/session-formatter.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import {SessionImportData, Speaker} from '../../type/session/session'; @Injectable({ providedIn: 'root' }) export class SessionFormatterService { @@ -65,4 +66,25 @@ export class SessionFormatterService { return parts.join(' '); } + + formatSpeakers(speakers: Speaker[] | undefined): string { + if (!speakers || speakers.length === 0) return 'Aucun speaker'; + + return speakers + .map(speaker => speaker.name) + .filter(name => name) + .join(', '); + } + + sortByTitle(sessions: SessionImportData[]): SessionImportData[] { + return sessions.sort((a, b) => { + const titleA = a.title?.toLowerCase() || ''; + const titleB = b.title?.toLowerCase() || ''; + return titleA.localeCompare(titleB); + }); + } + + getSessionId(session: SessionImportData): string { + return session.id || ''; + } } diff --git a/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.spec.ts b/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.spec.ts new file mode 100644 index 00000000..42eed6d1 --- /dev/null +++ b/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SpeakerFilterService } from './speaker-filter.service'; + +describe('SpeakerFilterService', () => { + let service: SpeakerFilterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SpeakerFilterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.ts b/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.ts new file mode 100644 index 00000000..64e71166 --- /dev/null +++ b/front/src/app/feature/admin-management/services/speaker/speaker-filter.service.ts @@ -0,0 +1,167 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { Category, Format, Speaker } from '../../type/session/session'; +import { SpeakerFilters } from '../../type/speaker/speaker-filters'; +import { SpeakerWithSessionsDTO } from '../../type/speaker/speaker-with-sessions'; +import { isDefined } from '../../../../shared/type/predicates'; + +@Injectable() +export class SpeakerFilterService { + readonly availableFormats = signal([]); + readonly availableCategories = signal([]); + readonly currentFilters = signal({ + selectedFormats: [], + selectedCategories: [], + hasCompleteTasks: null + }); + + readonly hasActiveFilters = computed(() => { + const filters = this.currentFilters(); + return filters.selectedFormats.length > 0 || + filters.selectedCategories.length > 0 || + filters.hasCompleteTasks !== null; + }); + + readonly activeFiltersCount = computed(() => { + const filters = this.currentFilters(); + let count = 0; + count += filters.selectedFormats.length; + count += filters.selectedCategories.length; + if (filters.hasCompleteTasks !== null) count += 1; + return count; + }); + + extractFiltersFromSpeakers(speakersWithSessions: SpeakerWithSessionsDTO[]): void { + this.availableFormats.set(this.extractUniqueFormats(speakersWithSessions)); + this.availableCategories.set(this.extractUniqueCategories(speakersWithSessions)); + } + + applyAllFilters( + speakers: Speaker[], + speakersWithSessions: SpeakerWithSessionsDTO[], + searchTerm: string + ): Speaker[] { + let filtered = [...speakers]; + const filters = this.currentFilters(); + + if (filters.selectedFormats.length > 0) { + filtered = this.filterByFormats(filtered, speakersWithSessions, filters.selectedFormats); + } + + if (filters.selectedCategories.length > 0) { + filtered = this.filterByCategories(filtered, speakersWithSessions, filters.selectedCategories); + } + + if (filters.hasCompleteTasks !== null) { + filtered = this.filterByCompleteTasks(filtered, filters.hasCompleteTasks); + } + + if (searchTerm.trim()) { + filtered = this.filterBySearchTerm(filtered, searchTerm); + } + + return filtered; + } + + updateFilters(filters: SpeakerFilters): void { + this.currentFilters.set(filters); + } + + resetFilters(): void { + this.currentFilters.set({ + selectedFormats: [], + selectedCategories: [], + hasCompleteTasks: null + }); + } + + private extractUniqueFormats(speakersWithSessions: SpeakerWithSessionsDTO[]): Format[] { + const formatMap = new Map(); + + speakersWithSessions.forEach(speakerWithSessions => { + speakerWithSessions.formats?.forEach(format => { + if (format.id && !formatMap.has(format.id)) { + formatMap.set(format.id, format); + } + }); + }); + + return Array.from(formatMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + private extractUniqueCategories(speakersWithSessions: SpeakerWithSessionsDTO[]): Category[] { + const categoryMap = new Map(); + + speakersWithSessions.forEach(speakerWithSessions => { + speakerWithSessions.categories?.forEach(category => { + if (category.id && !categoryMap.has(category.id)) { + categoryMap.set(category.id, category); + } + }); + }); + + return Array.from(categoryMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + private filterByFormats( + speakers: Speaker[], + speakersWithSessions: SpeakerWithSessionsDTO[], + selectedFormats: string[] + ): Speaker[] { + return speakers.filter(speaker => { + const speakerWithSessions = this.findSpeakerWithSessions(speaker, speakersWithSessions); + + if (!speakerWithSessions || !speakerWithSessions.formats) { + return false; + } + + return speakerWithSessions.formats.some(format => + selectedFormats.includes(format.id) + ); + }); + } + + private filterByCategories( + speakers: Speaker[], + speakersWithSessions: SpeakerWithSessionsDTO[], + selectedCategories: string[] + ): Speaker[] { + return speakers.filter(speaker => { + const speakerWithSessions = this.findSpeakerWithSessions(speaker, speakersWithSessions); + + if (!speakerWithSessions || !speakerWithSessions.categories) { + return false; + } + + return speakerWithSessions.categories.some(category => + selectedCategories.includes(category.id) + ); + }); + } + + private filterByCompleteTasks(speakers: Speaker[], hasCompleteTasks: boolean): Speaker[] { + return speakers.filter(speaker => { + const isComplete = isDefined(speaker.name && speaker.email && speaker.company && speaker.bio); + return hasCompleteTasks ? isComplete : !isComplete; + }); + } + + private filterBySearchTerm(speakers: Speaker[], searchTerm: string): Speaker[] { + const searchLower = searchTerm.toLowerCase(); + + return speakers.filter(speaker => + speaker.name?.toLowerCase().includes(searchLower) || + speaker.email?.toLowerCase().includes(searchLower) || + speaker.company?.toLowerCase().includes(searchLower) || + speaker.bio?.toLowerCase().includes(searchLower) + ); + } + + private findSpeakerWithSessions( + speaker: Speaker, + speakersWithSessions: SpeakerWithSessionsDTO[] + ): SpeakerWithSessionsDTO | undefined { + return speakersWithSessions.find( + sws => sws.speaker.email === speaker.email || sws.speaker.name === speaker.name + ); + } +} diff --git a/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.spec.ts b/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.spec.ts new file mode 100644 index 00000000..a7460a1b --- /dev/null +++ b/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SpeakerFormatterService } from './speaker-formatter.service'; + +describe('SpeakerFormatterService', () => { + let service: SpeakerFormatterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SpeakerFormatterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.ts b/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.ts new file mode 100644 index 00000000..a9114d03 --- /dev/null +++ b/front/src/app/feature/admin-management/services/speaker/speaker-formatter.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { Speaker } from '../../type/session/session'; + +@Injectable({ providedIn: 'root' }) +export class SpeakerFormatterService { + sortByName(speakers: Speaker[]): Speaker[] { + return speakers.sort((a, b) => { + const nameA = a.name?.toLowerCase() || ''; + const nameB = b.name?.toLowerCase() || ''; + return nameA.localeCompare(nameB); + }); + } + + getSpeakerId(speaker: Speaker): string { + return speaker.email || ''; + } + + handleImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.src = 'assets/img/profil-picture.svg'; + } +} From 8787763d1242540123f43b2ba95e636a3ad2bf54 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:53:01 +0200 Subject: [PATCH 12/23] envent name information default --- .../events/create-event-page/create-event-page.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/app/feature/admin-management/pages/events/create-event-page/create-event-page.component.html b/front/src/app/feature/admin-management/pages/events/create-event-page/create-event-page.component.html index 26113046..43efa833 100644 --- a/front/src/app/feature/admin-management/pages/events/create-event-page/create-event-page.component.html +++ b/front/src/app/feature/admin-management/pages/events/create-event-page/create-event-page.component.html @@ -7,7 +7,7 @@

Create a new event.

You will able to setup the call for paper later and make the event public or private.

} @else { -

{{ eventName || 'Event' }} informations.

+

{{ eventName() || 'Event' }} informations.

Provide details about the event, like address, dates and description to generate the event page.

From 723b8728bab69fd0ba010ad31bfa593b4c466cab Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:52:29 +0200 Subject: [PATCH 13/23] link speaker with session collection --- .../controller/SessionController.java | 62 ++- .../controller/SpeakerController.java | 37 +- .../controller/SpeakerSessionController.java | 102 ++++ .../dto/session/ImportResultDTO.java | 9 +- .../dto/session/SessionCreateRequestDTO.java | 38 ++ .../session/SessionScheduleImportDataDTO.java | 1 - .../dto/session/SpeakerCreateRequestDTO.java | 39 ++ .../dto/session/SpeakerWithSessionsDTO.java | 14 +- .../mapper/session/SessionCreateMapper.java | 95 ++++ .../mapper/session/SessionImportMapper.java | 95 ++++ .../mapper/session/SessionMapper.java | 124 ++--- .../mapper/session/SessionScheduleMapper.java | 188 ++++++++ .../mapper/session/SpeakerMapper.java | 62 ++- .../speakerspace/model/session/Session.java | 3 +- .../model/session/SessionImportData.java | 43 ++ .../speakerspace/model/session/Speaker.java | 12 +- .../repository/SessionRepository.java | 13 +- .../repository/SessionRepositoryImpl.java | 132 +++++- .../repository/SpeakerRepositoryImpl.java | 11 +- .../speakerspace/service/SessionService.java | 442 ++++++++---------- .../SessionSpeakerManagementService.java | 118 +++++ .../speakerspace/service/SpeakerService.java | 121 ++--- .../session-review-import.component.ts | 2 +- .../session-detail-page.component.html | 4 +- .../sessions/session-filter.service.ts | 2 +- .../admin-management/type/session/session.ts | 4 +- 26 files changed, 1336 insertions(+), 437 deletions(-) create mode 100644 back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java create mode 100644 back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java create mode 100644 back/src/main/java/com/speakerspace/dto/session/SpeakerCreateRequestDTO.java create mode 100644 back/src/main/java/com/speakerspace/mapper/session/SessionCreateMapper.java create mode 100644 back/src/main/java/com/speakerspace/mapper/session/SessionImportMapper.java create mode 100644 back/src/main/java/com/speakerspace/mapper/session/SessionScheduleMapper.java create mode 100644 back/src/main/java/com/speakerspace/model/session/SessionImportData.java create mode 100644 back/src/main/java/com/speakerspace/service/SessionSpeakerManagementService.java diff --git a/back/src/main/java/com/speakerspace/controller/SessionController.java b/back/src/main/java/com/speakerspace/controller/SessionController.java index 042a6a43..6a4e547b 100644 --- a/back/src/main/java/com/speakerspace/controller/SessionController.java +++ b/back/src/main/java/com/speakerspace/controller/SessionController.java @@ -4,9 +4,10 @@ import com.speakerspace.exception.EntityNotFoundException; import com.speakerspace.exception.EventAuthorizationHelper; import com.speakerspace.model.session.Session; -import com.speakerspace.model.session.SessionReviewImportData; +import com.speakerspace.model.session.SessionImportData; import com.speakerspace.model.session.Speaker; import com.speakerspace.service.SessionService; +import com.speakerspace.service.SpeakerService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -23,6 +24,7 @@ public class SessionController { private final SessionService sessionService; private final EventAuthorizationHelper authorizationHelper; + private final SpeakerService speakerService; @PostMapping("/event/{eventId}/import") public ResponseEntity importSessionsReview( @@ -49,15 +51,28 @@ public ResponseEntity importSessionsSchedule( }); } + @PostMapping("/event/{eventId}") + public ResponseEntity createSession( + @PathVariable String eventId, + @RequestBody + SessionCreateRequestDTO createRequest, + Authentication authentication) throws AccessDeniedException { + + return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { + validateCreateRequest(createRequest); + return sessionService.createSession(eventId, createRequest); + }); + } + @GetMapping("/event/{eventId}") - public ResponseEntity> getSessionsByEventId( + public ResponseEntity> getSessionsByEventId( @PathVariable String eventId, HttpServletRequest request, Authentication authentication) { return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { - List sessions = sessionService.getSessionsReviewAsImportData(eventId); - List mutableSessions = new ArrayList<>(sessions); + List sessions = sessionService.getSessionsReviewAsImportData(eventId); + List mutableSessions = new ArrayList<>(sessions); mutableSessions.sort(Comparator.comparing(s -> s.getTitle() != null ? s.getTitle().toLowerCase() : "" )); @@ -66,7 +81,7 @@ public ResponseEntity> getSessionsByEventId( } @GetMapping("/event/{eventId}/session/{sessionId}/review") - public ResponseEntity getSessionReviewById( + public ResponseEntity getSessionReviewById( @PathVariable String eventId, @PathVariable String sessionId, HttpServletRequest request, @@ -77,13 +92,19 @@ public ResponseEntity getSessionReviewById( } @GetMapping("/event/{eventId}/session/{sessionId}") - public ResponseEntity getSessionDetailById( + public ResponseEntity getSessionDetailById( @PathVariable String eventId, @PathVariable String sessionId, - Authentication authentication) throws AccessDeniedException { + HttpServletRequest request, + Authentication authentication) { - return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> - sessionService.getSessionByIdAndEventId(sessionId, eventId)); + return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { + SessionImportData session = sessionService.getSessionById(eventId, sessionId); + if (session == null) { + throw new EntityNotFoundException("Session not found with id: " + sessionId); + } + return session; + }); } @GetMapping("/event/{eventId}/speakers") @@ -177,4 +198,27 @@ private void validateSessionsData(List sessions) { throw new IllegalArgumentException("No sessions data provided"); } } + + private void validateCreateRequest(SessionCreateRequestDTO request) { + if (request.title() == null || request.title().trim().isEmpty()) { + throw new IllegalArgumentException("Session title is required"); + } + if (request.title().length() > 200) { + throw new IllegalArgumentException("Session title must not exceed 200 characters"); + } + if (request.abstractText() != null && request.abstractText().length() > 2000) { + throw new IllegalArgumentException("Abstract must not exceed 2000 characters"); + } + } + + @GetMapping("/event/{eventId}/empty-sessions") + public ResponseEntity> getEmptySessionsForEvent( + @PathVariable String eventId, + Authentication authentication) throws AccessDeniedException { + + return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { + List emptySessions = speakerService.getEmptySessionsForEvent(eventId); + return emptySessions; + }); + } } diff --git a/back/src/main/java/com/speakerspace/controller/SpeakerController.java b/back/src/main/java/com/speakerspace/controller/SpeakerController.java index 10d0348c..981576e3 100644 --- a/back/src/main/java/com/speakerspace/controller/SpeakerController.java +++ b/back/src/main/java/com/speakerspace/controller/SpeakerController.java @@ -1,12 +1,16 @@ package com.speakerspace.controller; +import com.speakerspace.dto.session.SpeakerCreateRequestDTO; import com.speakerspace.dto.session.SpeakerDTO; import com.speakerspace.exception.EntityNotFoundException; import com.speakerspace.exception.EventAuthorizationHelper; import com.speakerspace.mapper.session.SpeakerMapper; import com.speakerspace.model.session.Speaker; import com.speakerspace.service.SpeakerService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -24,18 +28,15 @@ public class SpeakerController { private final SpeakerMapper speakerMapper; private final EventAuthorizationHelper authorizationHelper; - @PostMapping("/event/{eventId}") - public ResponseEntity createSpeaker( - @PathVariable String eventId, - @RequestBody SpeakerDTO speakerDTO, + @PostMapping("/event/{eventId}/new-speaker") + public ResponseEntity> createNewSpeaker( + @PathVariable @NotBlank String eventId, + @RequestBody @Valid SpeakerCreateRequestDTO createRequest, Authentication authentication) throws AccessDeniedException { return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { - Speaker speaker = speakerMapper.convertToEntity(speakerDTO); - speaker.setEventId(eventId); - - Speaker savedSpeaker = speakerService.saveSpeaker(speaker); - return speakerMapper.convertToDTO(savedSpeaker); + SpeakerDTO createdSpeaker = speakerService.createSpeaker(eventId, createRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(createdSpeaker); }); } @@ -52,20 +53,22 @@ public ResponseEntity> getSpeakersByEvent( }); } - @GetMapping("/{speakerId}") + @GetMapping("/{speakerId}/event/{eventId}") public ResponseEntity getSpeaker( @PathVariable String speakerId, + @PathVariable String eventId, Authentication authentication) throws AccessDeniedException { - Speaker speaker = speakerService.findById(speakerId); - if (speaker == null) { - throw new EntityNotFoundException("Speaker not found with id: " + speakerId); - } - - return authorizationHelper.executeWithEventAuthorization(speaker.getEventId(), authentication, () -> - speakerMapper.convertToDTO(speaker)); + return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { + Speaker speaker = speakerService.findByIdAndEventId(speakerId, eventId); + if (speaker == null) { + throw new EntityNotFoundException("Speaker not found with id: " + speakerId); + } + return speakerMapper.convertToDTO(speaker); + }); } + @DeleteMapping("/{id}") public ResponseEntity deleteSpeaker(@PathVariable String id) { boolean deleted = speakerService.deleteSpeaker(id); diff --git a/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java new file mode 100644 index 00000000..863d58dc --- /dev/null +++ b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java @@ -0,0 +1,102 @@ +package com.speakerspace.controller; + +import com.google.firebase.auth.FirebaseToken; +import com.speakerspace.exception.EntityNotFoundException; +import com.speakerspace.exception.EventAuthorizationHelper; +import com.speakerspace.model.session.SessionImportData; +import com.speakerspace.model.session.Speaker; +import com.speakerspace.service.SessionService; +import com.speakerspace.service.SpeakerService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/speaker-sessions") +@RequiredArgsConstructor +public class SpeakerSessionController { + + private final SpeakerService speakerService; + private final EventAuthorizationHelper authorizationHelper; + private final SessionService sessionService; + + @GetMapping("/event/{eventId}") + public ResponseEntity> getMySessions( + @PathVariable String eventId, + HttpServletRequest request, + Authentication authentication) { + + return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { + String userEmail = extractEmailFromAuthentication(authentication); + + List sessions = speakerService.getSessionsByEventAndSpeakerEmail(eventId, userEmail); + + List mutableSessions = new ArrayList<>(sessions); + mutableSessions.sort(Comparator.comparing(s -> + s.getTitle() != null ? s.getTitle().toLowerCase() : "" + )); + return mutableSessions; + }); + } + + @GetMapping("/event/{eventId}/session/{sessionId}") + public ResponseEntity getMySessionById( + @PathVariable String eventId, + @PathVariable String sessionId, + HttpServletRequest request, + Authentication authentication) { + + return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { + String userEmail = extractEmailFromAuthentication(authentication); + + SessionImportData session = sessionService.getSessionByIdForSpeaker(eventId, sessionId, userEmail); + + if (session == null) { + throw new EntityNotFoundException("Session not found or not accessible"); + } + + return session; + }); + } + + @GetMapping("/event/{eventId}/my-profile") + public ResponseEntity getMyProfile( + @PathVariable String eventId, + HttpServletRequest request, + Authentication authentication) { + + return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { + String userEmail = extractEmailFromAuthentication(authentication); + + Speaker speaker = speakerService.getSpeakerByEmailAndEventId(userEmail, eventId); + + if (speaker == null) { + throw new EntityNotFoundException("Speaker profile not found for current user in this event"); + } + + return speaker; + }); + } + + private String extractEmailFromAuthentication(org.springframework.security.core.Authentication authentication) { + if (authentication.getPrincipal() instanceof FirebaseToken token) { + return token.getEmail(); + } + + if (authentication.getDetails() instanceof Map details) { + return (String) details.get("email"); + } + + throw new IllegalStateException("Unable to extract email from authentication"); + } +} diff --git a/back/src/main/java/com/speakerspace/dto/session/ImportResultDTO.java b/back/src/main/java/com/speakerspace/dto/session/ImportResultDTO.java index 6f0b78bf..75913b66 100644 --- a/back/src/main/java/com/speakerspace/dto/session/ImportResultDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/ImportResultDTO.java @@ -5,4 +5,11 @@ import java.util.List; @Builder -public record ImportResultDTO (List successfulImports, List failedImports, int totalCount, int successCount, List errors) {} +public record ImportResultDTO( + List successfulImports, + List failedImports, + List skippedImports, + int totalCount, + int successCount, + List errors +) {} diff --git a/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java new file mode 100644 index 00000000..a5d39853 --- /dev/null +++ b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java @@ -0,0 +1,38 @@ +package com.speakerspace.dto.session; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.Date; +import java.util.List; + +@Builder +public record SessionCreateRequestDTO( + @NotBlank(message = "Title is required") + @Size(max = 50, message = "Title must not exceed 50 characters") + String title, + + @Size(max = 2000, message = "Abstract must not exceed 2000 characters") + String abstractText, + + @Size(max = 1000, message = "References must not exceed 1000 characters") + String references, + + String level, + String track, + + List languages, + List formats, + List categories, + List speakers, + + @NotBlank(message = "Event ID is required") + String eventId, + + String deliberationStatus, + String confirmationStatus, + + Date start, + Date end +) {} diff --git a/back/src/main/java/com/speakerspace/dto/session/SessionScheduleImportDataDTO.java b/back/src/main/java/com/speakerspace/dto/session/SessionScheduleImportDataDTO.java index 17975fdb..3831de21 100644 --- a/back/src/main/java/com/speakerspace/dto/session/SessionScheduleImportDataDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/SessionScheduleImportDataDTO.java @@ -15,4 +15,3 @@ public record SessionScheduleImportDataDTO ( ProposalScheduleDTO proposal, String eventId ) {} - diff --git a/back/src/main/java/com/speakerspace/dto/session/SpeakerCreateRequestDTO.java b/back/src/main/java/com/speakerspace/dto/session/SpeakerCreateRequestDTO.java new file mode 100644 index 00000000..f487cb7d --- /dev/null +++ b/back/src/main/java/com/speakerspace/dto/session/SpeakerCreateRequestDTO.java @@ -0,0 +1,39 @@ +package com.speakerspace.dto.session; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.List; + +@Builder +public record SpeakerCreateRequestDTO( + @NotBlank(message = "Name is required") + @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") + String name, + + @Size(max = 2000, message = "Bio must not exceed 2000 characters") + String bio, + + @Size(max = 100, message = "Company must not exceed 100 characters") + String company, + + @Size(max = 2000, message = "References must not exceed 2000 characters") + String references, + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + @Size(max = 100, message = "Email must not exceed 100 characters") + String email, + + @Size(max = 500, message = "Picture URL must not exceed 500 characters") + String picture, + + @Size(max = 100, message = "Location must not exceed 100 characters") + String location, + + @Valid + List<@Size(max = 200, message = "Social link must not exceed 200 characters") String> socialLinks +) {} diff --git a/back/src/main/java/com/speakerspace/dto/session/SpeakerWithSessionsDTO.java b/back/src/main/java/com/speakerspace/dto/session/SpeakerWithSessionsDTO.java index 3206fcf9..c7f453de 100644 --- a/back/src/main/java/com/speakerspace/dto/session/SpeakerWithSessionsDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/SpeakerWithSessionsDTO.java @@ -2,7 +2,7 @@ import com.speakerspace.model.session.Category; import com.speakerspace.model.session.Format; -import com.speakerspace.model.session.SessionReviewImportData; +import com.speakerspace.model.session.SessionImportData; import com.speakerspace.model.session.Speaker; import lombok.Builder; @@ -12,7 +12,7 @@ @Builder public record SpeakerWithSessionsDTO( Speaker speaker, - List sessions, + List sessions, Set formats, Set categories ) { @@ -27,22 +27,22 @@ public record SpeakerWithSessionsDTO( categories = extractedData.categories(); } - public SpeakerWithSessionsDTO(Speaker speaker, List sessions) { + public SpeakerWithSessionsDTO(Speaker speaker, List sessions) { this(speaker, sessions, null, null); } - public static SpeakerWithSessionsDTO of(Speaker speaker, List sessions) { + public static SpeakerWithSessionsDTO of(Speaker speaker, List sessions) { return new SpeakerWithSessionsDTO(speaker, sessions); } - private static ExtractedData extractFormatsAndCategories(List sessions) { + private static ExtractedData extractFormatsAndCategories(List sessions) { if (sessions == null || sessions.isEmpty()) { return new ExtractedData(Set.of(), Set.of()); } Set extractedFormats = sessions.stream() .filter(Objects::nonNull) - .map(SessionReviewImportData::getFormats) + .map(SessionImportData::getFormats) .filter(Objects::nonNull) .flatMap(Collection::stream) .filter(Objects::nonNull) @@ -50,7 +50,7 @@ private static ExtractedData extractFormatsAndCategories(List extractedCategories = sessions.stream() .filter(Objects::nonNull) - .map(SessionReviewImportData::getCategories) + .map(SessionImportData::getCategories) .filter(Objects::nonNull) .flatMap(Collection::stream) .filter(Objects::nonNull) diff --git a/back/src/main/java/com/speakerspace/mapper/session/SessionCreateMapper.java b/back/src/main/java/com/speakerspace/mapper/session/SessionCreateMapper.java new file mode 100644 index 00000000..91f62a7f --- /dev/null +++ b/back/src/main/java/com/speakerspace/mapper/session/SessionCreateMapper.java @@ -0,0 +1,95 @@ +package com.speakerspace.mapper.session; + +import com.speakerspace.dto.session.SessionCreateRequestDTO; +import com.speakerspace.dto.session.SpeakerDTO; +import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.Speaker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class SessionCreateMapper { + + private final SpeakerMapper speakerMapper; + private final FormatMapper formatMapper; + private final CategoryMapper categoryMapper; + + public Session convertCreateRequestToSession(String sessionId, String eventId, SessionCreateRequestDTO createRequest) { + Session session = new Session(); + + session.setId(sessionId); + session.setTitle(createRequest.title().trim()); + session.setAbstractText(trimOrNull(createRequest.abstractText())); + session.setReferences(trimOrNull(createRequest.references())); + session.setLevel(createRequest.level()); + session.setTrack(createRequest.track()); + session.setEventId(eventId); + + session.setDeliberationStatus(createRequest.deliberationStatus() != null ? + createRequest.deliberationStatus() : "ACCEPTED"); + session.setConfirmationStatus(createRequest.confirmationStatus() != null ? + createRequest.confirmationStatus() : "CONFIRMED"); + + if (createRequest.start() != null) { + session.setStart(createRequest.start()); + } + if (createRequest.end() != null) { + session.setEnd(createRequest.end()); + } + + session.setLanguages(createRequest.languages() != null ? createRequest.languages() : new ArrayList<>()); + session.setTags(new ArrayList<>()); + + if (createRequest.formats() != null) { + session.setFormats(createRequest.formats().stream() + .map(formatMapper::convertToEntity) + .collect(Collectors.toList())); + } else { + session.setFormats(new ArrayList<>()); + } + + if (createRequest.categories() != null) { + session.setCategories(createRequest.categories().stream() + .map(categoryMapper::convertToEntity) + .collect(Collectors.toList())); + } else { + session.setCategories(new ArrayList<>()); + } + + if (createRequest.speakers() != null && !createRequest.speakers().isEmpty()) { + List speakers = createRequest.speakers().stream() + .map(speakerDTO -> convertSpeakerForCreation(speakerDTO, eventId)) + .collect(Collectors.toList()); + session.setSpeakers(speakers); + } else { + session.setSpeakers(new ArrayList<>()); + } + + return session; + } + + private Speaker convertSpeakerForCreation(SpeakerDTO speakerDTO, String eventId) { + Speaker speaker = speakerMapper.convertToEntity(speakerDTO); + + if (speaker.getId() == null) { + speaker.setId(generateSpeakerId()); + } + speaker.setEventId(eventId); + + return speaker; + } + + private String trimOrNull(String value) { + return value != null && !value.trim().isEmpty() ? value.trim() : null; + } + + private String generateSpeakerId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } +} diff --git a/back/src/main/java/com/speakerspace/mapper/session/SessionImportMapper.java b/back/src/main/java/com/speakerspace/mapper/session/SessionImportMapper.java new file mode 100644 index 00000000..68a9f3d8 --- /dev/null +++ b/back/src/main/java/com/speakerspace/mapper/session/SessionImportMapper.java @@ -0,0 +1,95 @@ +package com.speakerspace.mapper.session; + +import com.speakerspace.dto.session.SessionDTO; +import com.speakerspace.dto.session.SpeakerDTO; +import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.SessionImportData; +import com.speakerspace.model.session.Speaker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class SessionImportMapper { + + public SessionDTO convertImportDataToSessionDTO(SessionDTO importData, String eventId, String appId) { + return SessionDTO.builder() + .id(appId) + .title(importData.title()) + .abstractText(importData.abstractText()) + .deliberationStatus(importData.deliberationStatus()) + .confirmationStatus(importData.confirmationStatus()) + .level(importData.level()) + .references(importData.references()) + .eventId(eventId) + .start(importData.start()) + .end(importData.end()) + .track(importData.track()) + .formats(defaultIfNull(importData.formats(), new ArrayList<>())) + .categories(defaultIfNull(importData.categories(), new ArrayList<>())) + .tags(defaultIfNull(importData.tags(), new ArrayList<>())) + .languages(defaultIfNull(importData.languages(), new ArrayList<>())) + .speakers(defaultIfNull(importData.speakers(), new ArrayList<>())) + .reviews(importData.reviews()) + .build(); + } + + public List processSpeakersForImport(List speakerDTOs, String eventId, String conferenceHallSessionId) { + if (speakerDTOs == null || speakerDTOs.isEmpty()) { + return new ArrayList<>(); + } + + return speakerDTOs.stream() + .map(speakerDTO -> convertSpeakerDTOForImport(speakerDTO, eventId)) + .collect(Collectors.toList()); + } + + private Speaker convertSpeakerDTOForImport(SpeakerDTO speakerDTO, String eventId) { + Speaker speaker = new Speaker(); + speaker.setId(generateSpeakerId()); + speaker.setIdConferenceHall(speakerDTO.id()); + speaker.setName(speakerDTO.name()); + speaker.setBio(speakerDTO.bio()); + speaker.setCompany(speakerDTO.company()); + speaker.setReferences(speakerDTO.references()); + speaker.setPicture(speakerDTO.picture()); + speaker.setLocation(speakerDTO.location()); + speaker.setEmail(speakerDTO.email()); + speaker.setSocialLinks(speakerDTO.socialLinks() != null ? + speakerDTO.socialLinks() : new ArrayList<>()); + speaker.setEventId(eventId); + + Date now = new Date(); + speaker.setCreatedAt(now); + speaker.setUpdatedAt(now); + + return speaker; + } + + public SessionImportData convertSessionToImportData(Session session) { + SessionImportData importData = new SessionImportData(); + importData.setId(session.getId()); + importData.setTitle(session.getTitle()); + importData.setAbstractText(session.getAbstractText()); + importData.setStart(session.getStart()); + importData.setEnd(session.getEnd()); + importData.setTrack(session.getTrack()); + importData.setLevel(session.getLevel()); + importData.setSpeakers(session.getSpeakers() != null ? session.getSpeakers() : new ArrayList<>()); + return importData; + } + + private String generateSpeakerId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private T defaultIfNull(T value, T defaultValue) { + return value != null ? value : defaultValue; + } +} diff --git a/back/src/main/java/com/speakerspace/mapper/session/SessionMapper.java b/back/src/main/java/com/speakerspace/mapper/session/SessionMapper.java index d3a51d0f..d54ae51c 100644 --- a/back/src/main/java/com/speakerspace/mapper/session/SessionMapper.java +++ b/back/src/main/java/com/speakerspace/mapper/session/SessionMapper.java @@ -5,7 +5,6 @@ import com.speakerspace.dto.session.SessionDTO; import com.speakerspace.dto.session.SpeakerDTO; import com.speakerspace.model.session.*; -import com.speakerspace.service.SpeakerService; import com.speakerspace.utils.date.EventDateCalculator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -13,7 +12,7 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.List; -import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; @Component @@ -29,40 +28,40 @@ public class SessionMapper { private ReviewsMapper reviewsMapper; @Autowired - private SpeakerService speakerService; + private SpeakerMapper speakerMapper; public SessionDTO convertToDTO(Session session) { - if(session == null) return null; + if(session == null) return null; - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object + ZoneId eventZone = ZoneId.of("Europe/Paris"); return new SessionDTO( - session.getId(), - session.getTitle(), - session.getAbstractText(), - session.getDeliberationStatus(), - session.getConfirmationStatus(), - session.getLevel(), - session.getReferences(), - convertFormatsToDTO(session.getFormats()), - convertCategoriesToDTO(session.getCategories()), - session.getTags(), - session.getLanguages(), - convertSpeakerIdsToDTO(session.getSpeakerIds()), - reviewsMapper.convertToDTO(session.getReviews()), - session.getEventId(), - EventDateCalculator.convertLocalDateTimeToDate(session.getStart(), eventZone), - EventDateCalculator.convertLocalDateTimeToDate(session.getEnd(), eventZone), - session.getTrack(), - session.getCreatedAt(), - session.getUpdatedAt() + session.getId(), + session.getTitle(), + session.getAbstractText(), + session.getDeliberationStatus(), + session.getConfirmationStatus(), + session.getLevel(), + session.getReferences(), + convertFormatsToDTO(session.getFormats()), + convertCategoriesToDTO(session.getCategories()), + session.getTags(), + session.getLanguages(), + convertSpeakersToDTO(session.getSpeakers()), + reviewsMapper.convertToDTO(session.getReviews()), + session.getEventId(), + EventDateCalculator.convertLocalDateTimeToDate(session.getStart(), eventZone), + EventDateCalculator.convertLocalDateTimeToDate(session.getEnd(), eventZone), + session.getTrack(), + session.getCreatedAt(), + session.getUpdatedAt() ); } public Session convertToEntity(SessionDTO sessionDTO) { - if(sessionDTO == null) return null; + if(sessionDTO == null) return null; - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object + ZoneId eventZone = ZoneId.of("Europe/Paris"); Session session = new Session(); session.setId(sessionDTO.id()); @@ -76,7 +75,7 @@ public Session convertToEntity(SessionDTO sessionDTO) { session.setCategories(convertCategoriesToEntity(sessionDTO.categories())); session.setTags(sessionDTO.tags()); session.setLanguages(sessionDTO.languages()); - session.setSpeakerIds(extractSpeakerIds(sessionDTO.speakers())); + session.setSpeakers(convertSpeakersToEntity(sessionDTO.speakers())); session.setReviews(reviewsMapper.convertToEntity(sessionDTO.reviews())); session.setEventId(sessionDTO.eventId()); session.setStart(EventDateCalculator.convertLocalDateTimeToDate(sessionDTO.start(), eventZone)); @@ -88,10 +87,10 @@ public Session convertToEntity(SessionDTO sessionDTO) { return session; } - public SessionReviewImportData toSessionImportData(Session session) { - if (session == null) return null; + public SessionImportData toSessionImportData(Session session) { + if (session == null) return null; - SessionReviewImportData importData = new SessionReviewImportData(); + SessionImportData importData = new SessionImportData(); importData.setId(session.getId()); importData.setTitle(session.getTitle()); importData.setAbstractText(session.getAbstractText()); @@ -100,18 +99,15 @@ public SessionReviewImportData toSessionImportData(Session session) { importData.setLevel(session.getLevel()); importData.setReferences(session.getReferences()); importData.setEventId(session.getEventId()); - + importData.setStart(session.getStart()); + importData.setEnd(session.getEnd()); + importData.setTrack(session.getTrack()); importData.setFormats(session.getFormats() != null ? session.getFormats() : new ArrayList<>()); importData.setCategories(session.getCategories() != null ? session.getCategories() : new ArrayList<>()); importData.setTags(session.getTags() != null ? session.getTags() : new ArrayList<>()); importData.setLanguages(session.getLanguages() != null ? session.getLanguages() : new ArrayList<>()); - if (session.getSpeakerIds() != null && !session.getSpeakerIds().isEmpty()) { - List speakers = speakerService.findByIds(session.getSpeakerIds()); - importData.setSpeakers(speakers != null ? speakers : new ArrayList<>()); - } else { - importData.setSpeakers(new ArrayList<>()); - } + importData.setSpeakers(session.getSpeakers() != null ? session.getSpeakers() : new ArrayList<>()); if (session.getReviews() != null) { importData.setReviews(session.getReviews()); @@ -120,46 +116,46 @@ public SessionReviewImportData toSessionImportData(Session session) { return importData; } - private List convertSpeakerIdsToDTO(List speakerIds) { - if (speakerIds == null || speakerIds.isEmpty()) { + public Session createEmptySessionForSpeaker(String eventId, Speaker speaker) { + String sessionId = generateSessionId(); + + Session session = new Session(); + session.setId(sessionId); + session.setTitle(""); + session.setEventId(eventId); + session.setDeliberationStatus("PENDING"); + session.setConfirmationStatus("PENDING"); + + session.setSpeakers(List.of(speaker)); + + session.setFormats(new ArrayList<>()); + session.setCategories(new ArrayList<>()); + session.setLanguages(new ArrayList<>()); + session.setTags(new ArrayList<>()); + + return session; + } + + private List convertSpeakersToDTO(List speakers) { + if (speakers == null || speakers.isEmpty()) { return new ArrayList<>(); } - List speakers = speakerService.findByIds(speakerIds); return speakers.stream() - .map(this::convertSpeakerToDTO) + .map(speakerMapper::convertToDTO) .collect(Collectors.toList()); } - private List extractSpeakerIds(List speakerDTOs) { + private List convertSpeakersToEntity(List speakerDTOs) { if (speakerDTOs == null || speakerDTOs.isEmpty()) { return new ArrayList<>(); } return speakerDTOs.stream() - .map(SpeakerDTO::id) - .filter(Objects::nonNull) + .map(speakerMapper::convertToEntity) .collect(Collectors.toList()); } - private SpeakerDTO convertSpeakerToDTO(Speaker speaker) { - if (speaker == null) { - return null; - } - - return new SpeakerDTO( - speaker.getId(), - speaker.getName(), - speaker.getBio(), - speaker.getCompany(), - speaker.getReferences(), - speaker.getPicture(), - speaker.getLocation(), - speaker.getEmail(), - speaker.getSocialLinks() - ); - } - private List convertFormatsToDTO(List formats) { if (formats == null) return new ArrayList<>(); return formats.stream() @@ -187,4 +183,8 @@ private List convertCategoriesToEntity(List categoryDTOs) .map(categoryMapper::convertToEntity) .collect(Collectors.toList()); } + + private String generateSessionId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } } diff --git a/back/src/main/java/com/speakerspace/mapper/session/SessionScheduleMapper.java b/back/src/main/java/com/speakerspace/mapper/session/SessionScheduleMapper.java new file mode 100644 index 00000000..edae81ed --- /dev/null +++ b/back/src/main/java/com/speakerspace/mapper/session/SessionScheduleMapper.java @@ -0,0 +1,188 @@ +package com.speakerspace.mapper.session; + +import com.speakerspace.dto.session.ProposalScheduleDTO; +import com.speakerspace.dto.session.SessionScheduleImportDataDTO; +import com.speakerspace.dto.session.SpeakerDTO; +import com.speakerspace.model.session.Category; +import com.speakerspace.model.session.Format; +import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.Speaker; +import com.speakerspace.utils.date.EventDateCalculator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Component +@Slf4j +@RequiredArgsConstructor +public class SessionScheduleMapper { + + private final Clock clock; + + public SessionScheduleImportDataDTO convertUtcToLocalDateTime(SessionScheduleImportDataDTO original) { + try { + LocalDateTime convertedStart = convertUtcStringToLocalDateTime(original.start()); + LocalDateTime convertedEnd = convertUtcStringToLocalDateTime(original.end()); + + return SessionScheduleImportDataDTO.builder() + .id(original.id()) + .start(convertedStart) + .end(convertedEnd) + .track(original.track()) + .title(original.title()) + .languages(original.languages()) + .proposal(original.proposal()) + .eventId(original.eventId()) + .build(); + + } catch (Exception e) { + log.warn("Failed to convert UTC times for session {}, using original values", original.id()); + return original; + } + } + + public Session createSessionFromScheduleData(SessionScheduleImportDataDTO scheduleData, String eventId) { + Session session = new Session(); + ZoneId eventZone = ZoneId.of("Europe/Paris"); + + String conferenceHallId = extractConferenceHallId(scheduleData); + + session.setId(generateSessionId()); + session.setIdConferenceHall(conferenceHallId); + session.setTitle(scheduleData.title()); + session.setStart(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.start(), eventZone)); + session.setEnd(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.end(), eventZone)); + session.setTrack(scheduleData.track()); + session.setEventId(eventId); + + if (scheduleData.languages() != null && !scheduleData.languages().trim().isEmpty()) { + session.setLanguages(List.of(scheduleData.languages())); + } + + if (scheduleData.proposal() != null) { + enrichSessionWithProposalData(session, scheduleData.proposal(), eventId); + } + + return session; + } + + public void enrichExistingSessionWithScheduleData(Session existingSession, SessionScheduleImportDataDTO scheduleData) { + ZoneId eventZone = ZoneId.of("Europe/Paris"); + Date now = EventDateCalculator.convertLocalDateTimeToDate(LocalDateTime.now(clock), eventZone); + + existingSession.setStart(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.start(), eventZone)); + existingSession.setEnd(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.end(), eventZone)); + existingSession.setTrack(scheduleData.track()); + + if (scheduleData.title() != null && !scheduleData.title().trim().isEmpty()) { + existingSession.setTitle(scheduleData.title()); + } + + if (scheduleData.languages() != null && !scheduleData.languages().trim().isEmpty()) { + existingSession.setLanguages(List.of(scheduleData.languages())); + } + + existingSession.setUpdatedAt(now); + } + + public List convertScheduleSpeakersToSpeakers(List scheduleSpeakers, String eventId) { + if (scheduleSpeakers == null || scheduleSpeakers.isEmpty()) { + return new ArrayList<>(); + } + + return scheduleSpeakers.stream() + .map(scheduleSpeaker -> convertScheduleSpeakerToSpeaker(scheduleSpeaker, eventId)) + .toList(); + } + + public String extractConferenceHallId(SessionScheduleImportDataDTO scheduleData) { + return scheduleData.proposal() != null && scheduleData.proposal().id() != null + ? scheduleData.proposal().id() + : scheduleData.id(); + } + + private void enrichSessionWithProposalData(Session session, ProposalScheduleDTO proposal, String eventId) { + session.setAbstractText(proposal.abstractText()); + session.setLevel(proposal.level()); + + if (proposal.formats() != null) { + session.setFormats(convertStringFormatsToObjects(proposal.formats())); + } + if (proposal.categories() != null) { + session.setCategories(convertStringCategoriesToObjects(proposal.categories())); + } + if (proposal.speakers() != null) { + List speakers = convertScheduleSpeakersToSpeakers(proposal.speakers(), eventId); + session.setSpeakers(speakers); + } + } + + private Speaker convertScheduleSpeakerToSpeaker(SpeakerDTO scheduleSpeaker, String eventId) { + Speaker speaker = new Speaker(); + speaker.setId(generateSpeakerId()); + speaker.setIdConferenceHall(scheduleSpeaker.id()); + speaker.setName(scheduleSpeaker.name()); + speaker.setBio(scheduleSpeaker.bio()); + speaker.setCompany(scheduleSpeaker.company()); + speaker.setPicture(scheduleSpeaker.picture()); + speaker.setEventId(eventId); + speaker.setSocialLinks(scheduleSpeaker.socialLinks() != null ? + scheduleSpeaker.socialLinks() : new ArrayList<>()); + return speaker; + } + + private LocalDateTime convertUtcStringToLocalDateTime(LocalDateTime dateTime) { + return dateTime; + } + + private List convertStringFormatsToObjects(List formatStrings) { + return formatStrings.stream() + .map(this::createFormatFromString) + .toList(); + } + + private List convertStringCategoriesToObjects(List categoryStrings) { + return categoryStrings.stream() + .map(this::createCategoryFromString) + .toList(); + } + + private Format createFormatFromString(String formatString) { + Format format = new Format(); + format.setId(generateIdFromString(formatString)); + format.setName(formatString); + format.setDescription(formatString); + return format; + } + + private Category createCategoryFromString(String categoryString) { + Category category = new Category(); + category.setId(generateIdFromString(categoryString)); + category.setName(categoryString); + category.setDescription(categoryString); + return category; + } + + private String generateIdFromString(String content) { + return content.toLowerCase() + .replaceAll("[^a-z0-9]", "_") + .replaceAll("_+", "_") + .replaceAll("^_|_$", ""); + } + + private String generateSessionId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private String generateSpeakerId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } +} diff --git a/back/src/main/java/com/speakerspace/mapper/session/SpeakerMapper.java b/back/src/main/java/com/speakerspace/mapper/session/SpeakerMapper.java index fb0a1491..f5f7a752 100644 --- a/back/src/main/java/com/speakerspace/mapper/session/SpeakerMapper.java +++ b/back/src/main/java/com/speakerspace/mapper/session/SpeakerMapper.java @@ -1,30 +1,36 @@ package com.speakerspace.mapper.session; +import com.speakerspace.dto.session.SpeakerCreateRequestDTO; import com.speakerspace.dto.session.SpeakerDTO; import com.speakerspace.model.session.Speaker; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + @Component public class SpeakerMapper { public SpeakerDTO convertToDTO(Speaker speaker) { - if(speaker == null) return null; + if(speaker == null) return null; return new SpeakerDTO( - speaker.getId(), - speaker.getName(), - speaker.getBio(), - speaker.getCompany(), - speaker.getReferences(), - speaker.getPicture(), - speaker.getLocation(), - speaker.getEmail(), - speaker.getSocialLinks() + speaker.getId(), + speaker.getName(), + speaker.getBio(), + speaker.getCompany(), + speaker.getReferences(), + speaker.getPicture(), + speaker.getLocation(), + speaker.getEmail(), + speaker.getSocialLinks() ); } public Speaker convertToEntity(SpeakerDTO dto) { - if(dto == null) return null; + if(dto == null) return null; Speaker speaker = new Speaker(); speaker.setId(dto.id()); @@ -38,4 +44,38 @@ public Speaker convertToEntity(SpeakerDTO dto) { speaker.setSocialLinks(dto.socialLinks()); return speaker; } + + public Speaker buildSpeakerFromRequest(String eventId, SpeakerCreateRequestDTO createRequest) { + String speakerId = generateSpeakerId(); + + Speaker speaker = new Speaker(); + speaker.setId(speakerId); + speaker.setName(createRequest.name().trim()); + speaker.setBio(trimOrNull(createRequest.bio())); + speaker.setCompany(trimOrNull(createRequest.company())); + speaker.setReferences(trimOrNull(createRequest.references())); + speaker.setEmail(createRequest.email().toLowerCase().trim()); + speaker.setEventId(eventId); + speaker.setPicture(trimOrNull(createRequest.picture())); + speaker.setLocation(trimOrNull(createRequest.location())); + + List socialLinks = createRequest.socialLinks() != null ? + createRequest.socialLinks().stream() + .filter(link -> link != null && !link.trim().isEmpty()) + .map(String::trim) + .distinct() + .collect(Collectors.toList()) : + new ArrayList<>(); + speaker.setSocialLinks(socialLinks); + + return speaker; + } + + private String generateSpeakerId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private String trimOrNull(String value) { + return value != null && !value.trim().isEmpty() ? value.trim() : null; + } } diff --git a/back/src/main/java/com/speakerspace/model/session/Session.java b/back/src/main/java/com/speakerspace/model/session/Session.java index 51414726..4e333a53 100644 --- a/back/src/main/java/com/speakerspace/model/session/Session.java +++ b/back/src/main/java/com/speakerspace/model/session/Session.java @@ -17,6 +17,7 @@ public class Session { @NotBlank(message = "ID is required") @EqualsAndHashCode.Include private String id; + private String idConferenceHall; private Date start; private Date end; private String track; @@ -30,7 +31,7 @@ public class Session { private List categories; private List tags; private List languages; - private List speakerIds; + private List speakers; private Reviews reviews; private String eventId; private Date createdAt; diff --git a/back/src/main/java/com/speakerspace/model/session/SessionImportData.java b/back/src/main/java/com/speakerspace/model/session/SessionImportData.java new file mode 100644 index 00000000..12b9f4f5 --- /dev/null +++ b/back/src/main/java/com/speakerspace/model/session/SessionImportData.java @@ -0,0 +1,43 @@ +package com.speakerspace.model.session; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.cloud.spring.data.firestore.Document; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@Builder +@Document +public class SessionImportData { + + @NotBlank(message = "ID is required") + @EqualsAndHashCode.Include + private String id; + private String title; + + @JsonProperty("abstract") + private String abstractText; + + private String deliberationStatus; + private String confirmationStatus; + private String level; + private String references; + private String eventId; + private List formats = new ArrayList<>(); + private List categories = new ArrayList<>(); + private List tags = new ArrayList<>(); + private List languages = new ArrayList<>(); + private List speakers = new ArrayList<>(); + private Reviews reviews; + private Date start; + private Date end; + private String track; +} \ No newline at end of file diff --git a/back/src/main/java/com/speakerspace/model/session/Speaker.java b/back/src/main/java/com/speakerspace/model/session/Speaker.java index fc81c983..a5a337cf 100644 --- a/back/src/main/java/com/speakerspace/model/session/Speaker.java +++ b/back/src/main/java/com/speakerspace/model/session/Speaker.java @@ -4,20 +4,20 @@ import jakarta.validation.constraints.NotBlank; import lombok.*; +import java.util.Date; import java.util.List; @Getter @Setter -@NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Builder @Document public class Speaker { - @NotBlank(message = "ID is required") @EqualsAndHashCode.Include private String id; + private String idConferenceHall; private String name; private String bio; private String company; @@ -27,4 +27,12 @@ public class Speaker { private String email; private List socialLinks; private String eventId; + private Date createdAt; + private Date updatedAt; + + public Speaker() { + Date now = new Date(); + this.createdAt = now; + this.updatedAt = now; + } } diff --git a/back/src/main/java/com/speakerspace/repository/SessionRepository.java b/back/src/main/java/com/speakerspace/repository/SessionRepository.java index 2d9579d7..d7cd76ba 100644 --- a/back/src/main/java/com/speakerspace/repository/SessionRepository.java +++ b/back/src/main/java/com/speakerspace/repository/SessionRepository.java @@ -1,21 +1,28 @@ package com.speakerspace.repository; import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.Speaker; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; import java.util.Date; import java.util.List; +import java.util.Optional; +import java.util.Set; @Repository public interface SessionRepository { void saveSession(Session session); - Session findSessionById(String id); + Optional findSessionById(String id); List findByEventId(String eventId); List findDistinctTracksByEventId(String eventId); Session findByIdAndEventId(String sessionId, String eventId); - Session updateScheduleFields(String sessionId, LocalDateTime start, LocalDateTime end, String track); + Session updateScheduleFields(String sessionId, Date start, Date end, String track); boolean existsByIdAndEventId(String id, String eventId); boolean deleteSession(String id); int deleteByEventId(String eventId); + List findByEventIdAndSpeakerEmail(String eventId, String speakerEmail); + List findUniqueSpeekersByEventId(String eventId); + List findAll(); + Set findAllExistingConferenceHallIds(); + Session findByIdConferenceHall(String idConferenceHall); } diff --git a/back/src/main/java/com/speakerspace/repository/SessionRepositoryImpl.java b/back/src/main/java/com/speakerspace/repository/SessionRepositoryImpl.java index c6547117..9c0218ff 100644 --- a/back/src/main/java/com/speakerspace/repository/SessionRepositoryImpl.java +++ b/back/src/main/java/com/speakerspace/repository/SessionRepositoryImpl.java @@ -2,7 +2,9 @@ import com.google.cloud.firestore.*; import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.Speaker; import com.speakerspace.utils.date.EventDateCalculator; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.time.Clock; @@ -10,7 +12,9 @@ import java.time.ZoneId; import java.util.*; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +@Slf4j @Repository public class SessionRepositoryImpl extends AbstractFirestoreRepository implements SessionRepository { @@ -34,8 +38,7 @@ protected DocumentReference getDocumentReference(Session session) { @Override public void saveSession(Session session) { - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object - + ZoneId eventZone = ZoneId.of("Europe/Paris"); Date now = EventDateCalculator.convertLocalDateTimeToDate(LocalDateTime.now(clock), eventZone); if (session.getCreatedAt() == null) { session.setCreatedAt(now); @@ -45,8 +48,8 @@ public void saveSession(Session session) { } @Override - public Session findSessionById(String id) { - return findByIdSync(id).orElse(null); + public Optional findSessionById(String id) { + return findByIdSync(id); } @Override @@ -72,34 +75,41 @@ public Session findByIdAndEventId(String sessionId, String eventId) { } @Override - public Session updateScheduleFields(String sessionId, LocalDateTime start, LocalDateTime end, String track) { - try { - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object + public boolean existsByIdAndEventId(String id, String eventId) { + return executeQuerySingle(getCollection() + .whereEqualTo("id", id) + .whereEqualTo("eventId", eventId)).isPresent(); + } - Date now = EventDateCalculator.convertLocalDateTimeToDate(LocalDateTime.now(clock), eventZone); + @Override + public Session updateScheduleFields(String sessionId, Date start, Date end, String track) { + try { DocumentReference docRef = getCollection().document(sessionId); Map updates = new HashMap<>(); - if (start != null) updates.put("start", start); if (end != null) updates.put("end", end); if (track != null) updates.put("track", track); - updates.put("updatedAt", now); + + updates.put("updatedAt", new Date()); docRef.update(updates).get(); - return docRef.get().get().toObject(Session.class); + + DocumentSnapshot snapshot = docRef.get().get(); + if (snapshot.exists()) { + Session session = snapshot.toObject(Session.class); + if (session != null) { + session.setId(snapshot.getId()); + } + return session; + } + return null; + } catch (InterruptedException | ExecutionException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("Failed to update session", e); + throw new RuntimeException("Failed to update session schedule", e); } } - @Override - public boolean existsByIdAndEventId(String id, String eventId) { - return executeQuerySingle(getCollection() - .whereEqualTo("id", id) - .whereEqualTo("eventId", eventId)).isPresent(); - } - @Override public boolean deleteSession(String id) { return deleteByIdSync(id); @@ -122,4 +132,88 @@ public int deleteByEventId(String eventId) { throw new RuntimeException("Failed to batch delete", e); } } + + public List findByEventIdAndSpeakerEmail(String eventId, String speakerEmail) { + try { + List allSessions = findByEventId(eventId); + + return allSessions.stream() + .filter(session -> session.getSpeakers() != null && + session.getSpeakers().stream() + .anyMatch(speaker -> speakerEmail.equalsIgnoreCase(speaker.getEmail()))) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to find sessions by speaker email", e); + } + } + + public List findUniqueSpeekersByEventId(String eventId) { + List sessions = findByEventId(eventId); + + Map uniqueSpeakers = new HashMap<>(); + + sessions.forEach(session -> { + if (session.getSpeakers() != null) { + session.getSpeakers().forEach(speaker -> { + String key = speaker.getEmail() != null ? + speaker.getEmail().toLowerCase() : speaker.getId(); + uniqueSpeakers.put(key, speaker); + }); + } + }); + + return new ArrayList<>(uniqueSpeakers.values()); + } + + @Override + public List findAll() { + try { + log.debug("Fetching all sessions from Firestore"); + + List documents = getCollection() + .get() + .get() + .getDocuments(); + + List sessions = documents.stream() + .map(doc -> { + Session session = doc.toObject(Session.class); + session.setId(doc.getId()); + return session; + }) + .collect(Collectors.toList()); + + log.info("Successfully retrieved {} sessions", sessions.size()); + return sessions; + + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + log.error("Failed to retrieve all sessions", e); + throw new RuntimeException("Failed to retrieve all sessions", e); + } + } + + public Set findAllExistingConferenceHallIds() { + try { + return getCollection() + .select("idConferenceHall") + .get() + .get() + .getDocuments() + .stream() + .map(doc -> doc.getString("idConferenceHall")) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + log.error("Failed to fetch all ConferenceHall IDs", e); + return new HashSet<>(); + } + } + + public Session findByIdConferenceHall(String idConferenceHall) { + return executeQuerySingle(getCollection() + .whereEqualTo("idConferenceHall", idConferenceHall) + .limit(1)).orElse(null); + } } diff --git a/back/src/main/java/com/speakerspace/repository/SpeakerRepositoryImpl.java b/back/src/main/java/com/speakerspace/repository/SpeakerRepositoryImpl.java index 2d2e24ae..f715f5de 100644 --- a/back/src/main/java/com/speakerspace/repository/SpeakerRepositoryImpl.java +++ b/back/src/main/java/com/speakerspace/repository/SpeakerRepositoryImpl.java @@ -2,6 +2,7 @@ import com.google.cloud.firestore.*; import com.speakerspace.model.session.Speaker; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.ArrayList; @@ -10,6 +11,7 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +@Slf4j @Repository public class SpeakerRepositoryImpl extends AbstractFirestoreRepository implements SpeakerRepository { @@ -52,7 +54,14 @@ public List findByIds(List ids) { @Override public List findByEventId(String eventId) { - return executeQuery(getCollection().whereEqualTo("eventId", eventId)); + try { + List speakers = executeQuery(getCollection().whereEqualTo("eventId", eventId)); + log.debug("Found {} speakers for eventId: {}", speakers.size(), eventId); + return speakers; + } catch (Exception e) { + log.error("Failed to find speakers by eventId {}: {}", eventId, e.getMessage(), e); + return new ArrayList<>(); + } } @Override diff --git a/back/src/main/java/com/speakerspace/service/SessionService.java b/back/src/main/java/com/speakerspace/service/SessionService.java index 0723f920..bbd6b7cb 100644 --- a/back/src/main/java/com/speakerspace/service/SessionService.java +++ b/back/src/main/java/com/speakerspace/service/SessionService.java @@ -2,8 +2,11 @@ import com.speakerspace.dto.EventDTO; import com.speakerspace.dto.session.*; +import com.speakerspace.exception.EntityNotFoundException; +import com.speakerspace.mapper.session.SessionCreateMapper; +import com.speakerspace.mapper.session.SessionImportMapper; import com.speakerspace.mapper.session.SessionMapper; -import com.speakerspace.mapper.session.SpeakerMapper; +import com.speakerspace.mapper.session.SessionScheduleMapper; import com.speakerspace.model.session.*; import com.speakerspace.repository.SessionRepository; import lombok.RequiredArgsConstructor; @@ -11,12 +14,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.time.Clock; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; -import com.speakerspace.utils.date.EventDateCalculator; @Slf4j @Service @@ -25,53 +24,66 @@ public class SessionService { private final SessionRepository sessionRepository; private final SessionMapper sessionMapper; - private final SpeakerService speakerService; - private final SpeakerMapper speakerMapper; - private final Clock clock; + private final SessionImportMapper sessionImportMapper; + private final SessionScheduleMapper sessionScheduleMapper; + private final SessionCreateMapper sessionCreateMapper; @Autowired private EventService eventService; - - public boolean deleteSession(String id) { - Session existingSession = sessionRepository.findSessionById(id); - if (existingSession == null) { - return false; - } - return sessionRepository.deleteSession(id); - } - - public List getDistinctTracksByEventId(String eventId) { - return sessionRepository.findDistinctTracksByEventId(eventId); - } + @Autowired + private SessionSpeakerManagementService sessionSpeakerManagementService; public ImportResultDTO importSessionsReview(String eventId, List importDataList) { List successfulImports = new ArrayList<>(); List failedImports = new ArrayList<>(); + List skippedImports = new ArrayList<>(); List errors = new ArrayList<>(); + Set allExistingConferenceHallIds = sessionRepository.findAllExistingConferenceHallIds(); + for (SessionDTO importData : importDataList) { + String sessionConferenceId = importData.id(); + String sessionName = importData.title(); try { - SessionDTO sessionDTO = convertImportDataToSessionDTO(importData, eventId); + if (allExistingConferenceHallIds.contains(sessionConferenceId)) { + Session existingSession = sessionRepository.findByIdConferenceHall(sessionConferenceId); + String existingEventId = existingSession != null ? existingSession.getEventId() : "unknown"; + + log.debug("Session {} already exists in event {}, skipping import", + sessionConferenceId, existingEventId); + skippedImports.add(sessionConferenceId); + errors.add("Session \"" + sessionName + "\" already exists in event other event"); + continue; + } - List speakerIds = processSpeakersForSession(sessionDTO.speakers(), eventId); + String appId = generateSessionId(); + SessionDTO sessionDTO = sessionImportMapper.convertImportDataToSessionDTO(importData, eventId, appId); + + List processedSpeakers = sessionImportMapper.processSpeakersForImport( + sessionDTO.speakers(), eventId, sessionConferenceId); Session session = sessionMapper.convertToEntity(sessionDTO); - session.setSpeakerIds(speakerIds); + session.setIdConferenceHall(sessionConferenceId); + session.setSpeakers(processedSpeakers != null ? processedSpeakers : new ArrayList<>()); sessionRepository.saveSession(session); - successfulImports.add(importData.id()); - log.info("Successfully imported session {}, ", importData.id()); + + allExistingConferenceHallIds.add(sessionConferenceId); + + successfulImports.add(sessionConferenceId); + log.debug("Successfully imported session {} with app ID {}", sessionConferenceId, appId); } catch (Exception e) { - log.error("Failed to import session {}", importData.id(), e); - failedImports.add(importData.id()); - errors.add("Failed to import session " + importData.id() + ": " + e.getMessage()); + log.error("Failed to import session {}: {}", sessionConferenceId, e.getMessage(), e); + failedImports.add(sessionConferenceId); + errors.add("Failed to import session " + sessionConferenceId + ": " + e.getMessage()); } } return ImportResultDTO.builder() .successfulImports(successfulImports) .failedImports(failedImports) + .skippedImports(skippedImports) .totalCount(importDataList.size()) .successCount(successfulImports.size()) .errors(errors) @@ -81,43 +93,68 @@ public ImportResultDTO importSessionsReview(String eventId, List imp public ImportResultDTO importSessionsSchedule(String eventId, List importDataList) { List successfulImports = new ArrayList<>(); List failedImports = new ArrayList<>(); + List skippedImports = new ArrayList<>(); List errors = new ArrayList<>(); + log.info("Starting schedule import of {} sessions for event {}", importDataList.size(), eventId); + EventDTO event = eventService.getEventById(eventId); if (event == null) { throw new IllegalArgumentException("Event not found: " + eventId); } List convertedSessions = importDataList.stream() - .map(this::convertUtcToLocalDateTime) + .map(sessionScheduleMapper::convertUtcToLocalDateTime) .collect(Collectors.toList()); - eventService.updateEventDatesFromSessions(eventId, convertedSessions); + try { + eventService.updateEventDatesFromSessions(eventId, convertedSessions); + } catch (Exception e) { + log.warn("Failed to update event dates for {}: {}", eventId, e.getMessage()); + } for (SessionScheduleImportDataDTO scheduleData : convertedSessions) { - String sessionId = null; + String conferenceHallId = null; try { - sessionId = scheduleData.proposal() != null && scheduleData.proposal().id() != null - ? scheduleData.proposal().id() - : scheduleData.id(); + conferenceHallId = sessionScheduleMapper.extractConferenceHallId(scheduleData); - Session existingSession = sessionRepository.findSessionById(sessionId); + Session existingSession = sessionRepository.findByIdConferenceHall(conferenceHallId); if (existingSession != null) { - enrichExistingSessionWithScheduleData(existingSession, scheduleData); + if (!eventId.equals(existingSession.getEventId())) { + log.debug("Session {} exists in different event {}, skipping schedule import", + conferenceHallId, existingSession.getEventId()); + skippedImports.add(conferenceHallId); + errors.add("Session " + conferenceHallId + " exists in other event "); + continue; + } + + if (hasScheduleData(existingSession)) { + log.debug("Session {} already has schedule data, skipping", conferenceHallId); + skippedImports.add(conferenceHallId); + continue; + } + + sessionScheduleMapper.enrichExistingSessionWithScheduleData(existingSession, scheduleData); sessionRepository.saveSession(existingSession); - log.info("Successfully updated session {} ", existingSession.getId()); + + log.debug("Successfully updated session with ConferenceHall ID {} (app ID: {})", + conferenceHallId, existingSession.getId()); } else { - Session newSession = createSessionFromScheduleData(scheduleData, eventId); + Session newSession = sessionScheduleMapper.createSessionFromScheduleData(scheduleData, eventId); sessionRepository.saveSession(newSession); - log.info("Successfully created new session {} ", newSession.getId()); + + log.debug("Successfully created new session with ConferenceHall ID {} (app ID: {})", + conferenceHallId, newSession.getId()); } - successfulImports.add(sessionId); + successfulImports.add(conferenceHallId); } catch (Exception e) { - String finalSessionId = sessionId != null ? sessionId : + String finalSessionId = conferenceHallId != null ? conferenceHallId : (scheduleData.proposal() != null ? scheduleData.proposal().id() : scheduleData.id()); + + log.error("Failed to import schedule for session {}: {}", finalSessionId, e.getMessage(), e); failedImports.add(finalSessionId); errors.add("Failed to import schedule for session " + finalSessionId + ": " + e.getMessage()); } @@ -126,264 +163,183 @@ public ImportResultDTO importSessionsSchedule(String eventId, List emptySessionId = sessionSpeakerManagementService + .findEmptySessionForSpeakers(createRequest.speakers(), eventId); + + if (emptySessionId.isPresent()) { + return sessionSpeakerManagementService.updateEmptySession(emptySessionId.get(), createRequest); + } else { + return createNewSession(eventId, createRequest); + } } - public List getSessionsReviewAsImportData(String eventId) { - List sessions = sessionRepository.findByEventId(eventId); + private SessionDTO createNewSession(String eventId, SessionCreateRequestDTO createRequest) { + String sessionId = generateSessionId(); + Session session = sessionCreateMapper.convertCreateRequestToSession(sessionId, eventId, createRequest); + sessionRepository.saveSession(session); - return sessions.stream() - .map(sessionMapper::toSessionImportData) - .filter(Objects::nonNull) - .collect(Collectors.toCollection(ArrayList::new)); + log.info("Successfully created new session {} '{}' for event {}", + sessionId, createRequest.title(), eventId); + return sessionMapper.convertToDTO(session); } - public SessionReviewImportData getSessionById(String eventId, String sessionId) { - Session session = sessionRepository.findByIdAndEventId(sessionId, eventId); - return session != null ? sessionMapper.toSessionImportData(session) : null; + public boolean deleteSession(String id) { + Session existingSession = sessionRepository.findSessionById(id) + .orElseThrow(() -> new EntityNotFoundException("Session not found: " + id)); + + List speakerIds = new ArrayList<>(); + if (existingSession.getSpeakers() != null) { + for (Speaker speaker : existingSession.getSpeakers()) { + if (speaker.getId() != null) { + speakerIds.add(speaker.getId()); + } + } + } + + return sessionRepository.deleteSession(id); } - public SessionDTO getSessionByIdAndEventId(String sessionId, String eventId) { - Session session = sessionRepository.findByIdAndEventId(sessionId, eventId); - return session != null ? sessionMapper.convertToDTO(session) : null; + public List getDistinctTracksByEventId(String eventId) { + return sessionRepository.findDistinctTracksByEventId(eventId); } public List getUniqueSpeekersByEventId(String eventId) { - return speakerService.findByEventId(eventId); + return sessionRepository.findUniqueSpeekersByEventId(eventId); } public Speaker getSpeakerById(String eventId, String speakerId) { - Speaker speaker = speakerService.findById(speakerId); - if (speaker != null && eventId.equals(speaker.getEventId())) { - return speaker; - } - return null; + List speakers = getUniqueSpeekersByEventId(eventId); + return speakers.stream() + .filter(speaker -> speakerId.equals(speaker.getId())) + .findFirst() + .orElse(null); } - public List getSessionsWithScheduleByEventId(String eventId) { + public List getSpeakersWithSessionsByEventId(String eventId) { List sessions = sessionRepository.findByEventId(eventId); + Map uniqueSpeakers = new HashMap<>(); + Map> speakerSessions = new HashMap<>(); - return sessions.stream() - .map(sessionMapper::convertToDTO) - .filter(Objects::nonNull) - .filter(session -> session.start() != null && session.end() != null) - .collect(Collectors.toCollection(ArrayList::new)); - } + sessions.forEach(session -> { + if (session.getSpeakers() != null) { + session.getSpeakers().forEach(speaker -> { + String speakerKey = speaker.getEmail() != null ? + speaker.getEmail().toLowerCase() : speaker.getId(); - public List getSpeakersWithSessionsByEventId(String eventId) { - List speakers = speakerService.findByEventId(eventId); - List sessions = sessionRepository.findByEventId(eventId); + uniqueSpeakers.put(speakerKey, speaker); - List result = speakers.stream() - .map(speaker -> { - List speakerSessions = sessions.stream() - .filter(session -> session.getSpeakerIds() != null && - session.getSpeakerIds().contains(speaker.getId())) - .map(sessionMapper::toSessionImportData) - .collect(Collectors.toCollection(ArrayList::new)); + speakerSessions.computeIfAbsent(speakerKey, k -> new ArrayList<>()) + .add(sessionMapper.toSessionImportData(session)); + }); + } + }); - return new SpeakerWithSessionsDTO(speaker, speakerSessions); - }) + List result = uniqueSpeakers.entrySet().stream() + .map(entry -> new SpeakerWithSessionsDTO( + entry.getValue(), + speakerSessions.get(entry.getKey()) + )) .collect(Collectors.toCollection(ArrayList::new)); result.sort(Comparator.comparing(dto -> dto.speaker().getName().toLowerCase())); - return result; } - public SessionDTO updateSessionSchedule(String sessionId, String eventId, Session scheduleUpdate) { - if (!sessionRepository.existsByIdAndEventId(sessionId, eventId)) { - throw new IllegalArgumentException("Session not found or does not belong to the specified event"); - } - - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object - - Session updatedSession = sessionRepository.updateScheduleFields( - sessionId, - EventDateCalculator.convertLocalDateTimeToDate(scheduleUpdate.getStart(), eventZone), - EventDateCalculator.convertLocalDateTimeToDate(scheduleUpdate.getEnd(), eventZone), - scheduleUpdate.getTrack() - ); - - if (updatedSession == null) { - throw new RuntimeException("Failed to update session schedule"); - } - - return sessionMapper.convertToDTO(updatedSession); + public List getSessionsByEventAndSpeakerEmail(String eventId, String speakerEmail) { + List sessions = sessionRepository.findByEventIdAndSpeakerEmail(eventId, speakerEmail); + return sessions.stream() + .map(sessionMapper::toSessionImportData) + .filter(Objects::nonNull) + .collect(Collectors.toList()); } - private Session createSessionFromScheduleData(SessionScheduleImportDataDTO scheduleData, String eventId) { - Session session = new Session(); - - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object - - String sessionId = scheduleData.proposal() != null && scheduleData.proposal().id() != null - ? scheduleData.proposal().id() - : scheduleData.id(); - - session.setId(sessionId); - session.setTitle(scheduleData.title()); - session.setStart(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.start(), eventZone)); - session.setEnd(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.end(), eventZone)); - session.setTrack(scheduleData.track()); - session.setEventId(eventId); + public SessionImportData getSessionByIdForSpeaker(String eventId, String sessionId, String speakerEmail) { + List speakerSessions = sessionRepository.findByEventIdAndSpeakerEmail(eventId, speakerEmail); - if (scheduleData.languages() != null && !scheduleData.languages().trim().isEmpty()) { - session.setLanguages(List.of(scheduleData.languages())); - } - - if (scheduleData.proposal() != null) { - ProposalScheduleDTO proposal = scheduleData.proposal(); + Session targetSession = speakerSessions.stream() + .filter(session -> sessionId.equals(session.getId())) + .findFirst() + .orElse(null); - session.setAbstractText(proposal.abstractText()); - session.setLevel(proposal.level()); - - if (proposal.formats() != null) { - session.setFormats(convertStringFormatsToObjects(proposal.formats())); - } - if (proposal.categories() != null) { - session.setCategories(convertStringCategoriesToObjects(proposal.categories())); - } - if (proposal.speakers() != null) { - List speakers = convertScheduleSpeakersToSpeakers(proposal.speakers()); - List speakerIds = speakerService.processSpeakers(speakers, eventId); - session.setSpeakerIds(speakerIds); - } - } + return targetSession != null ? sessionMapper.toSessionImportData(targetSession) : null; + } - return session; + public Speaker getSpeakerByEmailAndEventId(String email, String eventId) { + List speakers = getUniqueSpeekersByEventId(eventId); + return speakers.stream() + .filter(speaker -> email.equalsIgnoreCase(speaker.getEmail())) + .findFirst() + .orElse(null); } - private void enrichExistingSessionWithScheduleData(Session existingSession, SessionScheduleImportDataDTO scheduleData) { - ZoneId eventZone = ZoneId.of("Europe/Paris"); // TODO : get zone from Event object - Date now = EventDateCalculator.convertLocalDateTimeToDate(LocalDateTime.now(clock), eventZone); + public List getSessionsReviewAsImportData(String eventId) { + List sessions = sessionRepository.findByEventId(eventId); - existingSession.setStart(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.start(), eventZone)); - existingSession.setEnd(EventDateCalculator.convertLocalDateTimeToDate(scheduleData.end(), eventZone)); - existingSession.setTrack(scheduleData.track()); + return sessions.stream() + .map(sessionMapper::toSessionImportData) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(ArrayList::new)); + } - if (scheduleData.title() != null && !scheduleData.title().trim().isEmpty()) { - existingSession.setTitle(scheduleData.title()); - } + public SessionImportData getSessionById(String eventId, String sessionId) { + Session session = sessionRepository.findByIdAndEventId(sessionId, eventId); + return session != null ? sessionMapper.toSessionImportData(session) : null; + } - if (scheduleData.languages() != null && !scheduleData.languages().trim().isEmpty()) { - existingSession.setLanguages(List.of(scheduleData.languages())); - } + public List getSessionsWithScheduleByEventId(String eventId) { + List sessions = sessionRepository.findByEventId(eventId); - existingSession.setUpdatedAt(now); + return sessions.stream() + .map(sessionMapper::convertToDTO) + .filter(Objects::nonNull) + .filter(session -> session.start() != null && session.end() != null) + .collect(Collectors.toCollection(ArrayList::new)); } - private List processSpeakersForSession(List speakerDTOs, String eventId) { - if (speakerDTOs == null || speakerDTOs.isEmpty()) { - return new ArrayList<>(); + public SessionDTO updateSessionSchedule(String sessionId, String eventId, Session scheduleUpdate) { + if (!sessionRepository.existsByIdAndEventId(sessionId, eventId)) { + throw new IllegalArgumentException("Session not found or does not belong to the specified event"); } - List speakers = speakerDTOs.stream() - .map(speakerMapper::convertToEntity) - .toList(); - - return speakerService.processSpeakers(speakers, eventId); - } + Date startDate = scheduleUpdate.getStart(); + Date endDate = scheduleUpdate.getEnd(); + String track = scheduleUpdate.getTrack(); - private SessionDTO convertImportDataToSessionDTO(SessionDTO importData, String eventId) { - return SessionDTO.builder() - .id(importData.id()) - .title(importData.title()) - .abstractText(importData.abstractText()) - .deliberationStatus(importData.deliberationStatus()) - .confirmationStatus(importData.confirmationStatus()) - .level(importData.level()) - .references(importData.references()) - .eventId(eventId) - .start(importData.start()) - .end(importData.end()) - .track(importData.track()) - .formats(defaultIfNull(importData.formats(), new ArrayList<>())) - .categories(defaultIfNull(importData.categories(), new ArrayList<>())) - .tags(defaultIfNull(importData.tags(), new ArrayList<>())) - .languages(defaultIfNull(importData.languages(), new ArrayList<>())) - .speakers(defaultIfNull(importData.speakers(), new ArrayList<>())) - .reviews(importData.reviews()) - .build(); - } + log.debug("Updating session {} with start: {}, end: {}, track: {}", + sessionId, startDate, endDate, track); - private List convertStringFormatsToObjects(List formatStrings) { - return formatStrings.stream() - .map(formatString -> { - Format format = new Format(); - format.setId(generateIdFromString(formatString)); - format.setName(formatString); - format.setDescription(formatString); - return format; - }) - .toList(); - } + Session updatedSession = sessionRepository.updateScheduleFields( + sessionId, + startDate, + endDate, + track + ); - private List convertStringCategoriesToObjects(List categoryStrings) { - return categoryStrings.stream() - .map(categoryString -> { - Category category = new Category(); - category.setId(generateIdFromString(categoryString)); - category.setName(categoryString); - category.setDescription(categoryString); - return category; - }) - .toList(); - } + if (updatedSession == null) { + throw new RuntimeException("Failed to update session schedule"); + } - private List convertScheduleSpeakersToSpeakers(List scheduleSpeakers) { - return scheduleSpeakers.stream() - .map(scheduleSpeaker -> { - Speaker speaker = new Speaker(); - speaker.setId(scheduleSpeaker.id()); - speaker.setName(scheduleSpeaker.name()); - speaker.setBio(scheduleSpeaker.bio()); - speaker.setCompany(scheduleSpeaker.company()); - speaker.setPicture(scheduleSpeaker.picture()); - speaker.setSocialLinks(scheduleSpeaker.socialLinks() != null ? - scheduleSpeaker.socialLinks() : new ArrayList<>()); - return speaker; - }) - .toList(); + return sessionMapper.convertToDTO(updatedSession); } - private T defaultIfNull(T value, T defaultValue) { - return value != null ? value : defaultValue; + private String generateSessionId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); } - private String generateIdFromString(String content) { - return content.toLowerCase() - .replaceAll("[^a-z0-9]", "_") - .replaceAll("_+", "_") - .replaceAll("^_|_$", ""); + private boolean hasScheduleData(Session session) { + return session.getStart() != null && session.getEnd() != null; } } diff --git a/back/src/main/java/com/speakerspace/service/SessionSpeakerManagementService.java b/back/src/main/java/com/speakerspace/service/SessionSpeakerManagementService.java new file mode 100644 index 00000000..0ae1f0f6 --- /dev/null +++ b/back/src/main/java/com/speakerspace/service/SessionSpeakerManagementService.java @@ -0,0 +1,118 @@ +package com.speakerspace.service; + +import com.speakerspace.dto.session.SessionCreateRequestDTO; +import com.speakerspace.dto.session.SessionDTO; +import com.speakerspace.dto.session.SpeakerDTO; +import com.speakerspace.exception.EntityNotFoundException; +import com.speakerspace.mapper.session.CategoryMapper; +import com.speakerspace.mapper.session.FormatMapper; +import com.speakerspace.mapper.session.SessionMapper; +import com.speakerspace.model.session.Session; +import com.speakerspace.repository.SessionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionSpeakerManagementService { + + private final SessionRepository sessionRepository; + private final SessionMapper sessionMapper; + private final FormatMapper formatMapper; + private final CategoryMapper categoryMapper; + + public SessionDTO updateEmptySession(String sessionId, SessionCreateRequestDTO sessionData) { + Session existingSession = sessionRepository.findSessionById(sessionId) + .orElseThrow(() -> new EntityNotFoundException("Session not found: " + sessionId)); + + if (existingSession.getTitle() != null && !existingSession.getTitle().trim().isEmpty()) { + throw new IllegalStateException("Cannot update a session that already has content"); + } + + updateSessionWithData(existingSession, sessionData); + + sessionRepository.saveSession(existingSession); + + return sessionMapper.convertToDTO(existingSession); + } + + public List getEmptySessionsForEvent(String eventId) { + return sessionRepository.findByEventId(eventId).stream() + .filter(this::isEmptySession) + .map(sessionMapper::convertToDTO) + .collect(Collectors.toList()); + } + + public Optional findEmptySessionForSpeakers(List speakers, String eventId) { + if (speakers == null || speakers.isEmpty()) { + return Optional.empty(); + } + + Set speakerEmails = speakers.stream() + .map(SpeakerDTO::email) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + return sessionRepository.findByEventId(eventId).stream() + .filter(this::isEmptySession) + .filter(session -> hasMatchingSpeaker(session, speakerEmails)) + .map(Session::getId) + .findFirst(); + } + + private void updateSessionWithData(Session session, SessionCreateRequestDTO sessionData) { + session.setTitle(sessionData.title().trim()); + session.setAbstractText(trimOrNull(sessionData.abstractText())); + session.setReferences(trimOrNull(sessionData.references())); + session.setLevel(sessionData.level()); + session.setTrack(sessionData.track()); + session.setDeliberationStatus(sessionData.deliberationStatus() != null ? + sessionData.deliberationStatus() : "ACCEPTED"); + session.setConfirmationStatus(sessionData.confirmationStatus() != null ? + sessionData.confirmationStatus() : "CONFIRMED"); + + if (sessionData.start() != null) { + session.setStart(sessionData.start()); + } + if (sessionData.end() != null) { + session.setEnd(sessionData.end()); + } + + session.setLanguages(sessionData.languages() != null ? sessionData.languages() : new ArrayList<>()); + + if (sessionData.formats() != null) { + session.setFormats(sessionData.formats().stream() + .map(formatMapper::convertToEntity) + .collect(Collectors.toList())); + } + + if (sessionData.categories() != null) { + session.setCategories(sessionData.categories().stream() + .map(categoryMapper::convertToEntity) + .collect(Collectors.toList())); + } + + session.setUpdatedAt(new Date()); + } + + private boolean isEmptySession(Session session) { + return session.getTitle() == null || session.getTitle().trim().isEmpty(); + } + + private boolean hasMatchingSpeaker(Session session, Set speakerEmails) { + return session.getSpeakers() != null && + session.getSpeakers().stream() + .anyMatch(speaker -> speakerEmails.contains(speaker.getEmail())); + } + + private String trimOrNull(String value) { + return value != null && !value.trim().isEmpty() ? value.trim() : null; + } + + +} diff --git a/back/src/main/java/com/speakerspace/service/SpeakerService.java b/back/src/main/java/com/speakerspace/service/SpeakerService.java index 8883fe63..b5f8b700 100644 --- a/back/src/main/java/com/speakerspace/service/SpeakerService.java +++ b/back/src/main/java/com/speakerspace/service/SpeakerService.java @@ -1,36 +1,79 @@ package com.speakerspace.service; +import com.speakerspace.dto.EventDTO; +import com.speakerspace.dto.session.*; +import com.speakerspace.mapper.session.SessionMapper; +import com.speakerspace.mapper.session.SpeakerMapper; +import com.speakerspace.model.session.Session; +import com.speakerspace.model.session.SessionImportData; import com.speakerspace.model.session.Speaker; +import com.speakerspace.repository.SessionRepository; import com.speakerspace.repository.SpeakerRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class SpeakerService { + private final SessionRepository sessionRepository; + private final SpeakerMapper speakerMapper; private final SpeakerRepository speakerRepository; + private final SessionSpeakerManagementService sessionSpeakerManagementService; - public Speaker saveSpeaker(Speaker speaker) { - return speakerRepository.saveSpeaker(speaker); - } + @Autowired + private EventService eventService; + @Autowired + private SessionMapper sessionMapper; + + public SpeakerDTO createSpeaker(String eventId, SpeakerCreateRequestDTO createRequest) { + validateBusinessRules(eventId, createRequest); + + Speaker speaker = speakerMapper.buildSpeakerFromRequest(eventId, createRequest); - public Speaker findById(String id) { - return speakerRepository.findSpeakerById(id); + Session emptySession = sessionMapper.createEmptySessionForSpeaker(eventId, speaker); + + sessionRepository.saveSession(emptySession); + + return speakerMapper.convertToDTO(speaker); } - public List findByIds(List ids) { - return speakerRepository.findByIds(ids); + public List getEmptySessionsForEvent(String eventId) { + return sessionSpeakerManagementService.getEmptySessionsForEvent(eventId); } public List findByEventId(String eventId) { - return speakerRepository.findByEventId(eventId); + return sessionRepository.findUniqueSpeekersByEventId(eventId); + } + + public Speaker findByIdAndEventId(String speakerId, String eventId) { + List speakers = findByEventId(eventId); + return speakers.stream() + .filter(speaker -> speakerId.equals(speaker.getId())) + .findFirst() + .orElse(null); + } + + public Speaker getSpeakerByEmailAndEventId(String email, String eventId) { + List speakers = findByEventId(eventId); + return speakers.stream() + .filter(speaker -> email.equalsIgnoreCase(speaker.getEmail())) + .findFirst() + .orElse(null); + } + + public List getSessionsByEventAndSpeakerEmail(String eventId, String speakerEmail) { + List sessions = sessionRepository.findByEventIdAndSpeakerEmail(eventId, speakerEmail); + return sessions.stream() + .map(sessionMapper::toSessionImportData) + .filter(Objects::nonNull) + .collect(Collectors.toList()); } public boolean deleteSpeaker(String id) { @@ -38,57 +81,25 @@ public boolean deleteSpeaker(String id) { if (existingSpeaker == null) { return false; } + return speakerRepository.deleteSpeaker(id); } - public String saveOrUpdateSpeaker(Speaker speaker, String eventId) { - speaker.setEventId(eventId); - if (speaker.getId() != null && speakerRepository.speakerExistsById(speaker.getId())) { - Speaker existingSpeaker = speakerRepository.findSpeakerById(speaker.getId()); - Speaker mergedSpeaker = mergeSpeakerData(existingSpeaker, speaker); - return speakerRepository.saveSpeaker(mergedSpeaker).getId(); - } else { - return speakerRepository.saveSpeaker(speaker).getId(); - } - } - public List processSpeakers(List speakers, String eventId) { - if (speakers == null || speakers.isEmpty()) { - return new ArrayList<>(); + private void validateBusinessRules(String eventId, SpeakerCreateRequestDTO createRequest) { + EventDTO event = eventService.getEventById(eventId); + if (event == null) { + throw new IllegalArgumentException("Event not found: " + eventId); } - return speakers.stream() - .map(speaker -> saveOrUpdateSpeaker(speaker, eventId)) - .collect(Collectors.toList()); - } + Speaker existingSpeaker = getSpeakerByEmailAndEventId( + createRequest.email().toLowerCase().trim(), eventId); - private Speaker mergeSpeakerData(Speaker existing, Speaker incoming) { - Speaker merged = new Speaker(); - merged.setId(existing.getId()); - merged.setEventId(existing.getEventId()); - - merged.setName(isNotEmpty(incoming.getName()) ? incoming.getName() : existing.getName()); - merged.setBio(isNotEmpty(incoming.getBio()) ? incoming.getBio() : existing.getBio()); - merged.setCompany(isNotEmpty(incoming.getCompany()) ? incoming.getCompany() : existing.getCompany()); - merged.setPicture(isNotEmpty(incoming.getPicture()) ? incoming.getPicture() : existing.getPicture()); - merged.setLocation(isNotEmpty(incoming.getLocation()) ? incoming.getLocation() : existing.getLocation()); - merged.setEmail(isNotEmpty(incoming.getEmail()) ? incoming.getEmail() : existing.getEmail()); - merged.setReferences(incoming.getReferences() != null ? incoming.getReferences() : existing.getReferences()); - - Set mergedSocialLinks = new HashSet<>(); - if (existing.getSocialLinks() != null) { - mergedSocialLinks.addAll(existing.getSocialLinks()); - } - if (incoming.getSocialLinks() != null) { - mergedSocialLinks.addAll(incoming.getSocialLinks()); + if (existingSpeaker != null) { + throw new IllegalArgumentException( + "A speaker with email '" + createRequest.email() + + "' already exists in this event"); } - merged.setSocialLinks(new ArrayList<>(mergedSocialLinks)); - - return merged; - } - - private boolean isNotEmpty(String value) { - return value != null && !value.trim().isEmpty(); } } diff --git a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts index 8de6d6f3..a9b57e69 100644 --- a/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-review-import/session-review-import.component.ts @@ -99,7 +99,7 @@ export class SessionReviewImportComponent { return reviewData.map(session => ({ id: session.id, title: session.title, - abstractText: session.abstract, + abstract: session.abstract, deliberationStatus: session.deliberationStatus || '', confirmationStatus: session.confirmationStatus || '', level: session.level || '', diff --git a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html index 49fa482d..b634988a 100644 --- a/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-detail-page/session-detail-page.component.html @@ -36,8 +36,8 @@ }
- @if (sessionData.abstractText) { -

{{ sessionData.abstractText }}

+ @if (sessionData.abstract) { +

{{ sessionData.abstract }}

} @else {

No description available

} diff --git a/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts b/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts index d2c3cbb2..062d4568 100644 --- a/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts +++ b/front/src/app/feature/admin-management/services/sessions/session-filter.service.ts @@ -101,7 +101,7 @@ export class SessionFilterService { return sessions.filter(session => session.title?.toLowerCase().includes(searchLower) || - session.abstractText?.toLowerCase().includes(searchLower) || + session.abstract?.toLowerCase().includes(searchLower) || session.speakers?.some(speaker => speaker.name?.toLowerCase().includes(searchLower)) ); } diff --git a/front/src/app/feature/admin-management/type/session/session.ts b/front/src/app/feature/admin-management/type/session/session.ts index 7d7da882..71abafc0 100644 --- a/front/src/app/feature/admin-management/type/session/session.ts +++ b/front/src/app/feature/admin-management/type/session/session.ts @@ -2,8 +2,9 @@ import {Observable} from 'rxjs'; export type SessionImportData = { id?: string; + idConferenceHall?: string; title: string; - abstractText: string; + abstract: string; deliberationStatus: string; confirmationStatus: string; level: string; @@ -53,6 +54,7 @@ export type Category = { export type Speaker = { id: string; + idConferenceHall?: string; name: string; bio: string; company: string; From 610095bf52243f8145bd5bf33ba4a726f729dc55 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:35:26 +0200 Subject: [PATCH 14/23] session-speaker standelone --- .../session/session-speakers/session-speakers.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts index f5dca0ad..5c421d21 100644 --- a/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-speakers/session-speakers.component.ts @@ -5,6 +5,7 @@ import {Speaker} from '../../../type/session/session'; selector: 'app-session-speakers', imports: [], templateUrl: './session-speakers.component.html', + standalone: true, styleUrl: './session-speakers.component.scss' }) export class SessionSpeakersComponent { From c96d5f972c3e25f5b0ad82a30cb8cda021eaa519 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:56:43 +0200 Subject: [PATCH 15/23] clean code session with speaker in firestore --- .../controller/AuthController.java | 111 ++---------------- .../controller/EventController.java | 53 ++------- .../controller/SessionController.java | 32 +---- .../controller/SpeakerSessionController.java | 18 +-- .../exception/GlobalExceptionHandler.java | 30 ++--- .../com/speakerspace/mapper/EventMapper.java | 68 ++++++++++- .../security/FirebaseTokenFilter.java | 20 ++-- .../speakerspace/service/EventService.java | 37 +++--- .../speakerspace/service/SessionService.java | 25 ++++ .../speakerspace/service/SpeakerService.java | 11 ++ .../com/speakerspace/service/TeamService.java | 10 +- .../com/speakerspace/service/UserService.java | 102 ++++++++++++++++ .../src/app/shared/button/button.component.ts | 1 + 13 files changed, 269 insertions(+), 249 deletions(-) diff --git a/back/src/main/java/com/speakerspace/controller/AuthController.java b/back/src/main/java/com/speakerspace/controller/AuthController.java index c8287377..64d8096c 100644 --- a/back/src/main/java/com/speakerspace/controller/AuthController.java +++ b/back/src/main/java/com/speakerspace/controller/AuthController.java @@ -1,7 +1,5 @@ package com.speakerspace.controller; -import com.google.firebase.auth.FirebaseAuth; -import com.google.firebase.auth.FirebaseAuthException; import com.google.firebase.auth.FirebaseToken; import com.speakerspace.config.CookieService; import com.speakerspace.config.FirebaseTokenRequest; @@ -11,22 +9,18 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.nio.file.AccessDeniedException; - +@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { - private static final Logger logger = LoggerFactory.getLogger(AuthController.class); private final UserService userService; private final CookieService cookieService; - private final FirebaseAuth firebaseAuth; @PostMapping("/login") public ResponseEntity login(@RequestBody FirebaseTokenRequest request, HttpServletResponse response) { @@ -34,7 +28,7 @@ public ResponseEntity login(@RequestBody FirebaseTokenRequest request, throw new IllegalArgumentException("No token provided"); } - FirebaseToken decodedToken = verifyFirebaseToken(request.idToken()); + FirebaseToken decodedToken = userService.verifyFirebaseToken(request.idToken()); String uid = decodedToken.getUid(); cookieService.setAuthCookie(response, request.idToken()); @@ -42,9 +36,9 @@ public ResponseEntity login(@RequestBody FirebaseTokenRequest request, UserDTO existingUser = userService.getUserByUid(uid); if (existingUser == null) { - existingUser = createNewUser(decodedToken); + existingUser = userService.createNewUser(decodedToken); } else { - existingUser = updateExistingUserIfNeeded(existingUser, decodedToken); + existingUser = userService.updateExistingUserIfNeeded(existingUser, decodedToken); } return ResponseEntity.ok(existingUser); @@ -58,7 +52,7 @@ public ResponseEntity logout(HttpServletResponse response) { @PostMapping public ResponseEntity createUser(@RequestBody UserDTO userDTO) { - logger.info("Creating/updating user: {}", userDTO.uid()); + log.info("Creating/updating user: {}", userDTO.uid()); UserDTO savedUser = userService.saveUser(userDTO); return ResponseEntity.ok(savedUser); } @@ -74,7 +68,7 @@ public ResponseEntity getUserByUid(@PathVariable String uid) { @GetMapping("/user/{uid}") public ResponseEntity getUserData(@PathVariable String uid, HttpServletRequest request) { - authenticateAndAuthorize(request, uid); + userService.authenticateAndAuthorize(request, uid); UserDTO userDTO = userService.getUserByUid(uid); if (userDTO == null) { @@ -86,7 +80,7 @@ public ResponseEntity getUserData(@PathVariable String uid, HttpServlet @PutMapping("/profile") public ResponseEntity updateUserProfile(@RequestBody UserDTO userDTO, HttpServletRequest request) { - String uid = authenticateAndAuthorize(request, userDTO.uid()); + String uid = userService.authenticateAndAuthorize(request, userDTO.uid()); UserDTO existingUser = userService.getUserByUid(uid); if (existingUser == null) { @@ -96,93 +90,4 @@ public ResponseEntity updateUserProfile(@RequestBody UserDTO userDTO, H UserDTO updatedUser = userService.partialUpdateUser(userDTO, existingUser); return ResponseEntity.ok(updatedUser); } - - private FirebaseToken verifyFirebaseToken(String idToken) { - try { - return firebaseAuth.verifyIdToken(idToken); - } catch (FirebaseAuthException e) { - logger.error("Firebase token verification failed: {}", e.getMessage()); - throw new FirebaseAuthenticationException("Invalid token"); - } - } - - private UserDTO createNewUser(FirebaseToken decodedToken) { - UserDTO userDTO = UserDTO.builder() - .uid(decodedToken.getUid()) - .email(decodedToken.getEmail()) - .displayName(decodedToken.getName()) - .photoURL(decodedToken.getPicture()) - .build(); - - UserDTO createdUser = userService.saveUser(userDTO); - if (createdUser == null) { - throw new RuntimeException("Failed to create user"); - } - return createdUser; - } - - private UserDTO updateExistingUserIfNeeded(UserDTO existingUser, FirebaseToken decodedToken) { - boolean needsUpdate = false; - UserDTO.UserDTOBuilder builder = UserDTO.builder() - .uid(existingUser.uid()) - .email(existingUser.email()) - .displayName(existingUser.displayName()) - .photoURL(existingUser.photoURL()) - .company(existingUser.company()) - .city(existingUser.city()) - .phoneNumber(existingUser.phoneNumber()) - .githubLink(existingUser.githubLink()) - .twitterLink(existingUser.twitterLink()) - .blueSkyLink(existingUser.blueSkyLink()) - .linkedInLink(existingUser.linkedInLink()) - .biography(existingUser.biography()) - .otherLink(existingUser.otherLink()); - - if (existingUser.email() == null && decodedToken.getEmail() != null) { - builder.email(decodedToken.getEmail()); - needsUpdate = true; - } - - if ((existingUser.displayName() == null || existingUser.displayName().isEmpty()) - && decodedToken.getName() != null) { - builder.displayName(decodedToken.getName()); - needsUpdate = true; - } - - if ((existingUser.photoURL() == null || existingUser.photoURL().isEmpty()) - && decodedToken.getPicture() != null) { - builder.photoURL(decodedToken.getPicture()); - needsUpdate = true; - } - - if (needsUpdate) { - UserDTO updatedUserDTO = builder.build(); - return userService.saveUser(updatedUserDTO); - } - - return existingUser; - } - - private String authenticateAndAuthorize(HttpServletRequest request, String targetUid) { - String token = cookieService.getAuthTokenFromCookies(request); - if (token == null) { - throw new UnauthorizedException("Authentication required"); - } - - try { - FirebaseToken decodedToken = firebaseAuth.verifyIdToken(token); - String tokenUid = decodedToken.getUid(); - - if (!tokenUid.equals(targetUid)) { - throw new AccessDeniedException("Not authorized to access this profile"); - } - - return tokenUid; - } catch (FirebaseAuthException | AccessDeniedException e) { - if (e.getMessage().contains("expired")) { - throw new TokenExpiredException("Token expired, please refresh"); - } - throw new FirebaseAuthenticationException("Token verification failed", e); - } - } } diff --git a/back/src/main/java/com/speakerspace/controller/EventController.java b/back/src/main/java/com/speakerspace/controller/EventController.java index 50ec9a35..1ea06daa 100644 --- a/back/src/main/java/com/speakerspace/controller/EventController.java +++ b/back/src/main/java/com/speakerspace/controller/EventController.java @@ -3,6 +3,7 @@ import com.speakerspace.dto.EventDTO; import com.speakerspace.exception.EntityNotFoundException; import com.speakerspace.exception.UnauthorizedException; +import com.speakerspace.mapper.EventMapper; import com.speakerspace.security.AuthenticationHelper; import com.speakerspace.service.EventService; import lombok.RequiredArgsConstructor; @@ -21,34 +22,12 @@ public class EventController { private final EventService eventService; private final AuthenticationHelper authHelper; + private final EventMapper eventMapper; @PostMapping("/create") public ResponseEntity createEvent(@RequestBody EventDTO eventDTO, Authentication authentication) { - if (authentication == null) { - throw new UnauthorizedException("Authentication required"); - } - - EventDTO eventWithUserId = EventDTO.builder() - .idEvent(eventDTO.idEvent()) - .eventName(eventDTO.eventName()) - .description(eventDTO.description()) - .endDate(eventDTO.endDate()) - .url(eventDTO.url()) - .startDate(eventDTO.startDate()) - .isOnline(eventDTO.isOnline()) - .location(eventDTO.location()) - .isPrivate(eventDTO.isPrivate()) - .webLinkUrl(eventDTO.webLinkUrl()) - .isFinish(eventDTO.isFinish()) - .userCreateId(authHelper.getUserId(authentication)) - .conferenceHallUrl(eventDTO.conferenceHallUrl()) - .teamId(eventDTO.teamId()) - .timeZone(eventDTO.timeZone()) - .logoBase64(eventDTO.logoBase64()) - .type(eventDTO.type()) - .build(); - - EventDTO createdEvent = eventService.createEvent(eventWithUserId); + EventDTO mappedEvent = eventMapper.eventWithUserId(eventDTO, authentication); + EventDTO createdEvent = eventService.createEvent(mappedEvent); return ResponseEntity.ok(createdEvent); } @@ -103,30 +82,12 @@ public ResponseEntity updateEvent( throw new AccessDeniedException("User not authorized to update this event"); } - EventDTO eventWithPreservedUserId = EventDTO.builder() - .idEvent(eventDTO.idEvent()) - .eventName(eventDTO.eventName()) - .description(eventDTO.description()) - .endDate(eventDTO.endDate()) - .url(eventDTO.url()) - .startDate(eventDTO.startDate()) - .isOnline(eventDTO.isOnline()) - .location(eventDTO.location()) - .isPrivate(eventDTO.isPrivate()) - .webLinkUrl(eventDTO.webLinkUrl()) - .isFinish(eventDTO.isFinish()) - .userCreateId(existingEvent.userCreateId()) - .conferenceHallUrl(eventDTO.conferenceHallUrl()) - .teamId(eventDTO.teamId()) - .timeZone(eventDTO.timeZone()) - .logoBase64(eventDTO.logoBase64()) - .type(eventDTO.type()) - .build(); - - EventDTO updatedEvent = eventService.updateEvent(eventWithPreservedUserId); + EventDTO mergedEvent = eventMapper.mergeForUpdate(eventDTO, existingEvent, authentication); + EventDTO updatedEvent = eventService.updateEvent(mergedEvent); return ResponseEntity.ok(updatedEvent); } + @DeleteMapping("/{eventId}") public ResponseEntity> deleteEvent(@PathVariable String eventId) throws AccessDeniedException { boolean deleted = eventService.deleteEvent(eventId); diff --git a/back/src/main/java/com/speakerspace/controller/SessionController.java b/back/src/main/java/com/speakerspace/controller/SessionController.java index 6a4e547b..e1ad6a10 100644 --- a/back/src/main/java/com/speakerspace/controller/SessionController.java +++ b/back/src/main/java/com/speakerspace/controller/SessionController.java @@ -33,7 +33,7 @@ public ResponseEntity importSessionsReview( Authentication authentication) throws AccessDeniedException { return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { - validateEventIdMatch(eventId, importRequest.eventId()); + sessionService.validateEventIdMatch(eventId, importRequest.eventId()); return sessionService.importSessionsReview(eventId, importRequest.sessions()); }); } @@ -45,8 +45,8 @@ public ResponseEntity importSessionsSchedule( Authentication authentication) throws AccessDeniedException { return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { - validateEventIdMatch(eventId, importRequest.eventId()); - validateSessionsData(importRequest.sessions()); + sessionService.validateEventIdMatch(eventId, importRequest.eventId()); + sessionService.validateSessionsData(importRequest.sessions()); return sessionService.importSessionsSchedule(eventId, importRequest.sessions()); }); } @@ -59,7 +59,7 @@ public ResponseEntity createSession( Authentication authentication) throws AccessDeniedException { return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> { - validateCreateRequest(createRequest); + sessionService.validateCreateRequest(createRequest); return sessionService.createSession(eventId, createRequest); }); } @@ -187,30 +187,6 @@ public ResponseEntity deleteSession(@PathVariable String id) { return ResponseEntity.noContent().build(); } - private void validateEventIdMatch(String pathEventId, String bodyEventId) { - if (!pathEventId.equals(bodyEventId)) { - throw new IllegalArgumentException("Event ID mismatch"); - } - } - - private void validateSessionsData(List sessions) { - if (sessions == null || sessions.isEmpty()) { - throw new IllegalArgumentException("No sessions data provided"); - } - } - - private void validateCreateRequest(SessionCreateRequestDTO request) { - if (request.title() == null || request.title().trim().isEmpty()) { - throw new IllegalArgumentException("Session title is required"); - } - if (request.title().length() > 200) { - throw new IllegalArgumentException("Session title must not exceed 200 characters"); - } - if (request.abstractText() != null && request.abstractText().length() > 2000) { - throw new IllegalArgumentException("Abstract must not exceed 2000 characters"); - } - } - @GetMapping("/event/{eventId}/empty-sessions") public ResponseEntity> getEmptySessionsForEvent( @PathVariable String eventId, diff --git a/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java index 863d58dc..bd1b5282 100644 --- a/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java +++ b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java @@ -1,6 +1,5 @@ package com.speakerspace.controller; -import com.google.firebase.auth.FirebaseToken; import com.speakerspace.exception.EntityNotFoundException; import com.speakerspace.exception.EventAuthorizationHelper; import com.speakerspace.model.session.SessionImportData; @@ -19,7 +18,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.Map; @RestController @RequestMapping("/speaker-sessions") @@ -37,7 +35,7 @@ public ResponseEntity> getMySessions( Authentication authentication) { return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { - String userEmail = extractEmailFromAuthentication(authentication); + String userEmail = speakerService.extractEmailFromAuthentication(authentication); List sessions = speakerService.getSessionsByEventAndSpeakerEmail(eventId, userEmail); @@ -57,7 +55,7 @@ public ResponseEntity getMySessionById( Authentication authentication) { return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { - String userEmail = extractEmailFromAuthentication(authentication); + String userEmail = speakerService.extractEmailFromAuthentication(authentication); SessionImportData session = sessionService.getSessionByIdForSpeaker(eventId, sessionId, userEmail); @@ -76,7 +74,7 @@ public ResponseEntity getMyProfile( Authentication authentication) { return authorizationHelper.executeWithUserAuthentication(request, authentication, () -> { - String userEmail = extractEmailFromAuthentication(authentication); + String userEmail = speakerService.extractEmailFromAuthentication(authentication); Speaker speaker = speakerService.getSpeakerByEmailAndEventId(userEmail, eventId); @@ -88,15 +86,5 @@ public ResponseEntity getMyProfile( }); } - private String extractEmailFromAuthentication(org.springframework.security.core.Authentication authentication) { - if (authentication.getPrincipal() instanceof FirebaseToken token) { - return token.getEmail(); - } - if (authentication.getDetails() instanceof Map details) { - return (String) details.get("email"); - } - - throw new IllegalStateException("Unable to extract email from authentication"); - } } diff --git a/back/src/main/java/com/speakerspace/exception/GlobalExceptionHandler.java b/back/src/main/java/com/speakerspace/exception/GlobalExceptionHandler.java index ceb12b41..7a912873 100644 --- a/back/src/main/java/com/speakerspace/exception/GlobalExceptionHandler.java +++ b/back/src/main/java/com/speakerspace/exception/GlobalExceptionHandler.java @@ -1,7 +1,6 @@ package com.speakerspace.exception; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -9,17 +8,14 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import java.nio.file.AccessDeniedException; -import java.util.HashMap; -import java.util.Map; @ControllerAdvice +@Slf4j public class GlobalExceptionHandler { - private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); - @ExceptionHandler(NullPointerException.class) public ResponseEntity handleNullPointerException(NullPointerException ex) { - logger.error("Null pointer exception: {}", ex.getMessage(), ex); + log.error("Null pointer exception: {}", ex.getMessage(), ex); AppError errorResponse = new AppError( "Data validation error", "Required data is missing or invalid" @@ -29,14 +25,14 @@ public ResponseEntity handleNullPointerException(NullPointerException @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { - logger.error("Illegal argument/validation error: {}", ex.getMessage(), ex); + log.error("Illegal argument/validation error: {}", ex.getMessage(), ex); return ResponseEntity.badRequest() .body(new AppError("Invalid input",ex.getMessage())); } @ExceptionHandler(UnsupportedOperationException.class) public ResponseEntity handleUnsupportedOperation(UnsupportedOperationException ex) { - logger.error("Unsupported operation error: {}", ex.getMessage(), ex); + log.error("Unsupported operation error: {}", ex.getMessage(), ex); AppError errorResponse = new AppError( "Operation not supported", "An internal error occurred while processing your request" @@ -46,7 +42,7 @@ public ResponseEntity handleUnsupportedOperation(UnsupportedOperationE @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDenied(AccessDeniedException ex) { - logger.warn("Access denied: {}", ex.getMessage()); + log.warn("Access denied: {}", ex.getMessage()); AppError errorResponse = new AppError( "Access denied", "You do not have permission to perform this action." @@ -56,28 +52,28 @@ public ResponseEntity handleAccessDenied(AccessDeniedException ex) { @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity handleEntityNotFound(EntityNotFoundException ex) { - logger.warn("Entity not found: {}", ex.getMessage()); + log.warn("Entity not found: {}", ex.getMessage()); return ResponseEntity.badRequest() .body(new AppError("Resource not found",ex.getMessage())); } @ExceptionHandler(ValidationException.class) public ResponseEntity handleValidationException(ValidationException ex) { - logger.error("Validation error: {}", ex.getMessage()); + log.error("Validation error: {}", ex.getMessage()); return ResponseEntity.badRequest() .body(new AppError("Validation failed",ex.getMessage(),ex.getErrors())); } @ExceptionHandler(FirebaseAuthenticationException.class) public ResponseEntity handleFirebaseAuth(FirebaseAuthenticationException ex) { - logger.error("Firebase authentication error: {}", ex.getMessage()); + log.error("Firebase authentication error: {}", ex.getMessage()); return ResponseEntity.badRequest() .body(new AppError("Authentication failed",ex.getMessage())); } @ExceptionHandler(TokenExpiredException.class) public ResponseEntity handleTokenExpired(TokenExpiredException ex) { - logger.warn("Token expired: {}", ex.getMessage()); + log.warn("Token expired: {}", ex.getMessage()); AppError errorResponse = new AppError( "Token expired", "Please refresh your authentication" @@ -87,7 +83,7 @@ public ResponseEntity handleTokenExpired(TokenExpiredException ex) { @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleJsonParseError(HttpMessageNotReadableException ex) { - logger.error("JSON parsing error: {}", ex.getMessage()); + log.error("JSON parsing error: {}", ex.getMessage()); AppError errorResponse = new AppError( "Invalid JSON format", "Please check your JSON structure" @@ -97,7 +93,7 @@ public ResponseEntity handleJsonParseError(HttpMessageNotReadableExcep @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException(RuntimeException ex) { - logger.error("Runtime error: {}", ex.getMessage(), ex); + log.error("Runtime error: {}", ex.getMessage(), ex); AppError errorResponse = new AppError( "Server error", "An error occurred while processing your request" @@ -107,7 +103,7 @@ public ResponseEntity handleRuntimeException(RuntimeException ex) { @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { - logger.error("Unexpected error: {}", ex.getMessage(), ex); + log.error("Unexpected error: {}", ex.getMessage(), ex); AppError errorResponse = new AppError( "Server error", "Please try again later" diff --git a/back/src/main/java/com/speakerspace/mapper/EventMapper.java b/back/src/main/java/com/speakerspace/mapper/EventMapper.java index d6315d4d..1808f4c1 100644 --- a/back/src/main/java/com/speakerspace/mapper/EventMapper.java +++ b/back/src/main/java/com/speakerspace/mapper/EventMapper.java @@ -1,18 +1,23 @@ package com.speakerspace.mapper; import com.speakerspace.dto.EventDTO; +import com.speakerspace.exception.UnauthorizedException; import com.speakerspace.model.Event; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.speakerspace.security.AuthenticationHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import java.time.Instant; import java.util.Optional; +@Slf4j @Component +@RequiredArgsConstructor public class EventMapper { - private static final Logger logger = LoggerFactory.getLogger(EventMapper.class); + private final AuthenticationHelper authHelper; public EventDTO convertToDTO(Event event) { if (event == null) return null; @@ -74,13 +79,68 @@ public Event convertToEntity(EventDTO eventDTO) { return event; } + public EventDTO eventWithUserId(EventDTO eventDTO, Authentication authentication) { + if (eventDTO == null) return null; + if (authentication == null) { + throw new UnauthorizedException("Authentication required"); + } + + return EventDTO.builder() + .idEvent(eventDTO.idEvent()) + .eventName(eventDTO.eventName()) + .description(eventDTO.description()) + .endDate(eventDTO.endDate()) + .url(eventDTO.url()) + .startDate(eventDTO.startDate()) + .isOnline(eventDTO.isOnline()) + .location(eventDTO.location()) + .isPrivate(eventDTO.isPrivate()) + .webLinkUrl(eventDTO.webLinkUrl()) + .isFinish(eventDTO.isFinish()) + .userCreateId(authHelper.getUserId(authentication)) + .conferenceHallUrl(eventDTO.conferenceHallUrl()) + .teamId(eventDTO.teamId()) + .timeZone(eventDTO.timeZone()) + .logoBase64(eventDTO.logoBase64()) + .type(eventDTO.type()) + .build(); + } + + public EventDTO mergeForUpdate(EventDTO updated, EventDTO existing, Authentication authentication) { + if (updated == null || existing == null) + throw new IllegalArgumentException("Updated and existing events must not be null"); + if (authentication == null) + throw new UnauthorizedException("Authentication required"); + + return EventDTO.builder() + .idEvent(existing.idEvent()) + .eventName(updated.eventName()) + .description(updated.description()) + .endDate(updated.endDate()) + .url(updated.url()) + .startDate(updated.startDate()) + .isOnline(updated.isOnline()) + .location(updated.location()) + .isPrivate(updated.isPrivate()) + .webLinkUrl(updated.webLinkUrl()) + .isFinish(updated.isFinish()) + .userCreateId(existing.userCreateId()) + .conferenceHallUrl(updated.conferenceHallUrl()) + .teamId(updated.teamId()) + .timeZone(updated.timeZone()) + .logoBase64(updated.logoBase64()) + .type(updated.type()) + .build(); + } + + private com.google.cloud.Timestamp parseStringToTimestamp(String dateString) { try { Instant instant = Instant.parse(dateString); return com.google.cloud.Timestamp.ofTimeSecondsAndNanos( instant.getEpochSecond(), instant.getNano()); } catch (Exception e) { - logger.error("Failed to parse date: {}", dateString, e); + log.error("Failed to parse date: {}", dateString, e); throw new IllegalArgumentException("Invalid date format: " + dateString, e); } } diff --git a/back/src/main/java/com/speakerspace/security/FirebaseTokenFilter.java b/back/src/main/java/com/speakerspace/security/FirebaseTokenFilter.java index e224038a..e99545f9 100644 --- a/back/src/main/java/com/speakerspace/security/FirebaseTokenFilter.java +++ b/back/src/main/java/com/speakerspace/security/FirebaseTokenFilter.java @@ -8,8 +8,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -25,11 +24,10 @@ import java.util.List; import java.util.Map; +@Slf4j @Component public class FirebaseTokenFilter extends OncePerRequestFilter { - private static final Logger logger = LoggerFactory.getLogger(FirebaseTokenFilter.class); - @Autowired private FirebaseAuth firebaseAuth; @@ -64,16 +62,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { token = authorizationHeader.substring(7); - logger.debug("Token found in Authorization header"); + log.debug("Token found in Authorization header"); } else { token = cookieService.getAuthTokenFromCookies(request); if (token != null) { - logger.debug("Token found in cookies"); + log.debug("Token found in cookies"); } } if (token == null || token.trim().isEmpty()) { - logger.warn("No authentication token found for path: {}", path); + log.warn("No authentication token found for path: {}", path); sendUnauthorizedResponse(response, "Authentication required"); return; } @@ -83,7 +81,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String email = decodedToken.getEmail(); String uid = decodedToken.getUid(); - logger.debug("Token verified successfully for user: {} ({})", email, uid); + log.debug("Token verified successfully for user: {} ({})", email, uid); List authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); @@ -102,16 +100,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.getContext().setAuthentication(authentication); - logger.debug("Authentication set for user: {} ({})", uid, email); + log.debug("Authentication set for user: {} ({})", uid, email); filterChain.doFilter(request, response); } catch (FirebaseAuthException e) { - logger.error("Firebase token verification failed: {}", e.getMessage()); + log.error("Firebase token verification failed: {}", e.getMessage()); SecurityContextHolder.clearContext(); sendUnauthorizedResponse(response, "Invalid token: " + e.getErrorCode()); } catch (Exception e) { - logger.error("Unexpected error during token verification: {}", e.getMessage(), e); + log.error("Unexpected error during token verification: {}", e.getMessage(), e); SecurityContextHolder.clearContext(); sendUnauthorizedResponse(response, "Authentication error"); } diff --git a/back/src/main/java/com/speakerspace/service/EventService.java b/back/src/main/java/com/speakerspace/service/EventService.java index 81c3268b..ebf58281 100644 --- a/back/src/main/java/com/speakerspace/service/EventService.java +++ b/back/src/main/java/com/speakerspace/service/EventService.java @@ -13,8 +13,7 @@ import com.speakerspace.repository.TeamRepository; import com.speakerspace.utils.date.EventDateCalculator; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.nio.file.AccessDeniedException; @@ -23,11 +22,11 @@ import java.util.stream.Collectors; @Service +@Slf4j @RequiredArgsConstructor public class EventService { private static final String BASE_URL = "https://speaker-space.io/event/"; - private static final Logger logger = LoggerFactory.getLogger(EventService.class); private final EventMapper eventMapper; private final EventRepository eventRepository; @@ -164,11 +163,11 @@ private boolean deleteEventWithDependencies(String eventId) { boolean eventDeleted = eventRepository.deleteEvent(eventId); if (eventDeleted) { - logger.info("Event deleted successfully: {} (with {} sessions and {} speakers)", + log.info("Event deleted successfully: {} (with {} sessions and {} speakers)", eventId, deletedSessionsCount, deletedSpeakersCount); return true; } else { - logger.error("Failed to delete event: {}", eventId); + log.error("Failed to delete event: {}", eventId); return false; } } @@ -264,7 +263,7 @@ public void updateEventDatesFromSessions(String eventId, List getEventsBySpeakerEmail() { UserDTO currentUser = userService.getUserByUid(currentUserId); if (currentUser == null || currentUser.email() == null) { - logger.debug("No current user or email found for speaker events"); + log.debug("No current user or email found for speaker events"); return Collections.emptyList(); } List userSpeakers = speakerRepository.findByEmail(currentUser.email()); if (userSpeakers.isEmpty()) { - logger.debug("No speaker records found for email: {}", currentUser.email()); + log.debug("No speaker records found for email: {}", currentUser.email()); return Collections.emptyList(); } @@ -333,7 +332,7 @@ public List getEventsBySpeakerEmail() { .collect(Collectors.toSet()); if (eventIds.isEmpty()) { - logger.debug("No valid event IDs found in speaker records"); + log.debug("No valid event IDs found in speaker records"); return Collections.emptyList(); } @@ -349,11 +348,11 @@ public List getEventsBySpeakerEmail() { return e2.idEvent().compareTo(e1.idEvent()); }); - logger.debug("Found {} speaker events for user {}", speakerEvents.size(), currentUser.email()); + log.debug("Found {} speaker events for user {}", speakerEvents.size(), currentUser.email()); return speakerEvents; } catch (Exception e) { - logger.error("Error retrieving events by speaker email", e); + log.error("Error retrieving events by speaker email", e); return Collections.emptyList(); } } @@ -373,7 +372,7 @@ public boolean isUserSpeakerOfEvent(String eventId) { return !speakers.isEmpty(); } catch (Exception e) { - logger.error("Error checking if user is speaker of event: {}", eventId, e); + log.error("Error checking if user is speaker of event: {}", eventId, e); return false; } } @@ -384,16 +383,16 @@ public List getAllUserRelatedEventsComplete() { try { List createdEvents = getEventsForCurrentUser(); allEvents.addAll(createdEvents); - logger.debug("Found {} created events", createdEvents.size()); + log.debug("Found {} created events", createdEvents.size()); String currentUserId = userService.getCurrentUserId(); List teamEvents = getEventsFromUserTeams(currentUserId); allEvents.addAll(teamEvents); - logger.debug("Found {} team events", teamEvents.size()); + log.debug("Found {} team events", teamEvents.size()); List speakerEvents = getEventsBySpeakerEmail(); allEvents.addAll(speakerEvents); - logger.debug("Found {} speaker events", speakerEvents.size()); + log.debug("Found {} speaker events", speakerEvents.size()); List result = new ArrayList<>(allEvents); result.sort((e1, e2) -> { @@ -405,7 +404,7 @@ public List getAllUserRelatedEventsComplete() { return result; } catch (Exception e) { - logger.error("Error retrieving all user related events", e); + log.error("Error retrieving all user related events", e); return Collections.emptyList(); } } @@ -428,7 +427,7 @@ private List getEventsFromUserTeams(String userId) { return new ArrayList<>(teamEvents); } catch (Exception e) { - logger.error("Error retrieving events from user teams for user: {}", userId, e); + log.error("Error retrieving events from user teams for user: {}", userId, e); return Collections.emptyList(); } } diff --git a/back/src/main/java/com/speakerspace/service/SessionService.java b/back/src/main/java/com/speakerspace/service/SessionService.java index bbd6b7cb..9f5242b0 100644 --- a/back/src/main/java/com/speakerspace/service/SessionService.java +++ b/back/src/main/java/com/speakerspace/service/SessionService.java @@ -335,6 +335,31 @@ public SessionDTO updateSessionSchedule(String sessionId, String eventId, Sessio return sessionMapper.convertToDTO(updatedSession); } + + public void validateEventIdMatch(String pathEventId, String bodyEventId) { + if (!pathEventId.equals(bodyEventId)) { + throw new IllegalArgumentException("Event ID mismatch"); + } + } + + public void validateSessionsData(List sessions) { + if (sessions == null || sessions.isEmpty()) { + throw new IllegalArgumentException("No sessions data provided"); + } + } + + public void validateCreateRequest(SessionCreateRequestDTO request) { + if (request.title() == null || request.title().trim().isEmpty()) { + throw new IllegalArgumentException("Session title is required"); + } + if (request.title().length() > 200) { + throw new IllegalArgumentException("Session title must not exceed 200 characters"); + } + if (request.abstractText() != null && request.abstractText().length() > 2000) { + throw new IllegalArgumentException("Abstract must not exceed 2000 characters"); + } + } + private String generateSessionId() { return UUID.randomUUID().toString().replace("-", "").substring(0, 16); } diff --git a/back/src/main/java/com/speakerspace/service/SpeakerService.java b/back/src/main/java/com/speakerspace/service/SpeakerService.java index b5f8b700..a4bb23b0 100644 --- a/back/src/main/java/com/speakerspace/service/SpeakerService.java +++ b/back/src/main/java/com/speakerspace/service/SpeakerService.java @@ -1,5 +1,6 @@ package com.speakerspace.service; +import com.google.firebase.auth.FirebaseToken; import com.speakerspace.dto.EventDTO; import com.speakerspace.dto.session.*; import com.speakerspace.mapper.session.SessionMapper; @@ -85,7 +86,17 @@ public boolean deleteSpeaker(String id) { return speakerRepository.deleteSpeaker(id); } + public String extractEmailFromAuthentication(org.springframework.security.core.Authentication authentication) { + if (authentication.getPrincipal() instanceof FirebaseToken token) { + return token.getEmail(); + } + + if (authentication.getDetails() instanceof Map details) { + return (String) details.get("email"); + } + throw new IllegalStateException("Unable to extract email from authentication"); + } private void validateBusinessRules(String eventId, SpeakerCreateRequestDTO createRequest) { EventDTO event = eventService.getEventById(eventId); diff --git a/back/src/main/java/com/speakerspace/service/TeamService.java b/back/src/main/java/com/speakerspace/service/TeamService.java index 29ca0526..522c8e81 100644 --- a/back/src/main/java/com/speakerspace/service/TeamService.java +++ b/back/src/main/java/com/speakerspace/service/TeamService.java @@ -11,8 +11,7 @@ import com.speakerspace.repository.SpeakerRepository; import com.speakerspace.repository.TeamRepository; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.nio.file.AccessDeniedException; @@ -21,12 +20,11 @@ import java.util.Optional; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class TeamService { - private static final Logger logger = LoggerFactory.getLogger(TeamService.class); - private final TeamMapper teamMapper; private final EventRepository eventRepository; private final TeamRepository teamRepository; @@ -161,13 +159,13 @@ private boolean deleteTeamWithFirestoreTransaction(String teamId) { teamRepository.deleteTeam(teamId); - logger.info("Team deleted successfully: {} (with {} events, {} sessions, {} speakers)", + log.info("Team deleted successfully: {} (with {} events, {} sessions, {} speakers)", teamId, deletedEventsCount, totalDeletedSessions, totalDeletedSpeakers); return true; } catch (Exception e) { - logger.error("Error in Firestore transaction for team deletion: {}", e.getMessage(), e); + log.error("Error in Firestore transaction for team deletion: {}", e.getMessage(), e); throw e; } } diff --git a/back/src/main/java/com/speakerspace/service/UserService.java b/back/src/main/java/com/speakerspace/service/UserService.java index 4712fe89..a84035cd 100644 --- a/back/src/main/java/com/speakerspace/service/UserService.java +++ b/back/src/main/java/com/speakerspace/service/UserService.java @@ -1,30 +1,43 @@ package com.speakerspace.service; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import com.speakerspace.config.CookieService; import com.speakerspace.dto.TeamMemberDTO; import com.speakerspace.dto.UserDTO; +import com.speakerspace.exception.FirebaseAuthenticationException; +import com.speakerspace.exception.TokenExpiredException; +import com.speakerspace.exception.UnauthorizedException; import com.speakerspace.exception.ValidationException; import com.speakerspace.mapper.UserMapper; import com.speakerspace.model.Team; import com.speakerspace.model.User; import com.speakerspace.repository.TeamRepository; import com.speakerspace.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.net.URL; +import java.nio.file.AccessDeniedException; import java.util.*; import java.util.function.BiConsumer; import java.util.function.Function; @Service +@Slf4j @RequiredArgsConstructor public class UserService { private final UserMapper userMapper; private final UserRepository userRepository; private final TeamRepository teamRepository; + private final FirebaseAuth firebaseAuth; + private final CookieService cookieService; private static final int MIN_LENGTH = 2; public UserDTO saveUser(UserDTO userDTO) { @@ -310,4 +323,93 @@ private User mergeUsers(User partialUser, User existingUser) { return updatedUser; } + + public FirebaseToken verifyFirebaseToken(String idToken) { + try { + return firebaseAuth.verifyIdToken(idToken); + } catch (FirebaseAuthException e) { + log.error("Firebase token verification failed: {}", e.getMessage()); + throw new FirebaseAuthenticationException("Invalid token"); + } + } + + public UserDTO createNewUser(FirebaseToken decodedToken) { + UserDTO userDTO = UserDTO.builder() + .uid(decodedToken.getUid()) + .email(decodedToken.getEmail()) + .displayName(decodedToken.getName()) + .photoURL(decodedToken.getPicture()) + .build(); + + UserDTO createdUser = this.saveUser(userDTO); + if (createdUser == null) { + throw new RuntimeException("Failed to create user"); + } + return createdUser; + } + + public UserDTO updateExistingUserIfNeeded(UserDTO existingUser, FirebaseToken decodedToken) { + boolean needsUpdate = false; + UserDTO.UserDTOBuilder builder = UserDTO.builder() + .uid(existingUser.uid()) + .email(existingUser.email()) + .displayName(existingUser.displayName()) + .photoURL(existingUser.photoURL()) + .company(existingUser.company()) + .city(existingUser.city()) + .phoneNumber(existingUser.phoneNumber()) + .githubLink(existingUser.githubLink()) + .twitterLink(existingUser.twitterLink()) + .blueSkyLink(existingUser.blueSkyLink()) + .linkedInLink(existingUser.linkedInLink()) + .biography(existingUser.biography()) + .otherLink(existingUser.otherLink()); + + if (existingUser.email() == null && decodedToken.getEmail() != null) { + builder.email(decodedToken.getEmail()); + needsUpdate = true; + } + + if ((existingUser.displayName() == null || existingUser.displayName().isEmpty()) + && decodedToken.getName() != null) { + builder.displayName(decodedToken.getName()); + needsUpdate = true; + } + + if ((existingUser.photoURL() == null || existingUser.photoURL().isEmpty()) + && decodedToken.getPicture() != null) { + builder.photoURL(decodedToken.getPicture()); + needsUpdate = true; + } + + if (needsUpdate) { + UserDTO updatedUserDTO = builder.build(); + return this.saveUser(updatedUserDTO); + } + + return existingUser; + } + + public String authenticateAndAuthorize(HttpServletRequest request, String targetUid) { + String token = cookieService.getAuthTokenFromCookies(request); + if (token == null) { + throw new UnauthorizedException("Authentication required"); + } + + try { + FirebaseToken decodedToken = firebaseAuth.verifyIdToken(token); + String tokenUid = decodedToken.getUid(); + + if (!tokenUid.equals(targetUid)) { + throw new AccessDeniedException("Not authorized to access this profile"); + } + + return tokenUid; + } catch (FirebaseAuthException | AccessDeniedException e) { + if (e.getMessage().contains("expired")) { + throw new TokenExpiredException("Token expired, please refresh"); + } + throw new FirebaseAuthenticationException("Token verification failed", e); + } + } } diff --git a/front/src/app/shared/button/button.component.ts b/front/src/app/shared/button/button.component.ts index ffee1dcd..e7005895 100644 --- a/front/src/app/shared/button/button.component.ts +++ b/front/src/app/shared/button/button.component.ts @@ -4,6 +4,7 @@ import {Component, computed, input, output} from '@angular/core'; selector: 'app-button', imports: [], templateUrl: './button.component.html', + standalone: true, styleUrl: './button.component.scss' }) export class ButtonComponent { From 53be302c335089c951997b9dcfc345f826620e8e Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:44:47 +0200 Subject: [PATCH 16/23] create modal speaker and session --- .../controller/SpeakerSessionController.java | 2 - .../modal/modal-popup-create.component.html | 88 +++++ .../modal-popup-create.component.spec.ts | 23 ++ .../modal/modal-popup-create.component.ts | 42 +++ .../services/form-modal.service.spec.ts | 16 + .../components/services/form-modal.service.ts | 60 +++ ...on-categories-formats-field.component.html | 45 +++ ...categories-formats-field.component.spec.ts | 23 ++ ...sion-categories-formats-field.component.ts | 50 +++ .../session-datetime-fields.component.html | 32 ++ .../session-datetime-fields.component.scss | 64 ++++ .../session-datetime-fields.component.spec.ts | 23 ++ .../session-datetime-fields.component.ts | 49 +++ .../session-duration-field.component.html | 29 ++ .../session-duration-field.component.spec.ts | 23 ++ .../session-duration-field.component.ts | 60 +++ .../session-form-fields.component.html | 84 +++++ .../session-form-fields.component.spec.ts | 23 ++ .../session-form-fields.component.ts | 79 ++++ .../session-languages-field.component.html | 15 + .../session-languages-field.component.spec.ts | 23 ++ .../session-languages-field.component.ts | 31 ++ .../session-speakers-field.component.html | 88 +++++ .../session-speakers-field.component.spec.ts | 23 ++ .../session-speakers-field.component.ts | 152 ++++++++ .../session-create-popup.component.html | 59 +++ .../session-create-popup.component.scss | 32 ++ .../session-create-popup.component.spec.ts | 23 ++ .../session-create-popup.component.ts | 342 ++++++++++++++++++ .../social-links-field.component.html | 58 +++ .../social-links-field.component.spec.ts | 23 ++ .../social-links-field.component.ts | 82 +++++ .../speaker-form-fields.component.html | 83 +++++ .../speaker-form-fields.component.spec.ts | 23 ++ .../speaker-form-fields.component.ts | 25 ++ .../speaker-create-popup.component.html | 36 ++ .../speaker-create-popup.component.spec.ts | 23 ++ .../speaker-create-popup.component.ts | 165 +++++++++ .../session-list-page.component.html | 14 + .../session-list-page.component.ts | 46 ++- .../speaker-list-page.component.html | 9 + .../speaker-list-page.component.ts | 38 +- .../services/sessions/session.service.ts | 20 + .../services/speaker/speaker.service.ts | 36 +- .../type/session/session-create.ts | 18 + .../type/speaker/speaker-create.ts | 25 ++ .../src/app/shared/input/field.component.html | 6 +- front/src/app/shared/input/field.component.ts | 25 +- 48 files changed, 2321 insertions(+), 37 deletions(-) create mode 100644 front/src/app/feature/admin-management/components/modal/modal-popup-create.component.html create mode 100644 front/src/app/feature/admin-management/components/modal/modal-popup-create.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/modal/modal-popup-create.component.ts create mode 100644 front/src/app/feature/admin-management/components/services/form-modal.service.spec.ts create mode 100644 front/src/app/feature/admin-management/components/services/form-modal.service.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.scss create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.html create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.ts create mode 100644 front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html create mode 100644 front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.scss create mode 100644 front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.html create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.html create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.html create mode 100644 front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.spec.ts create mode 100644 front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.ts create mode 100644 front/src/app/feature/admin-management/type/session/session-create.ts create mode 100644 front/src/app/feature/admin-management/type/speaker/speaker-create.ts diff --git a/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java index bd1b5282..938af160 100644 --- a/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java +++ b/back/src/main/java/com/speakerspace/controller/SpeakerSessionController.java @@ -85,6 +85,4 @@ public ResponseEntity getMyProfile( return speaker; }); } - - } diff --git a/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.html b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.html new file mode 100644 index 00000000..b108ea5e --- /dev/null +++ b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.html @@ -0,0 +1,88 @@ + diff --git a/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.spec.ts b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.spec.ts new file mode 100644 index 00000000..2d36002d --- /dev/null +++ b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalPopupCreateComponent } from './modal-popup-create.component'; + +describe('ModalComponent', () => { + let component: ModalPopupCreateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModalPopupCreateComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalPopupCreateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.ts b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.ts new file mode 100644 index 00000000..0119fdfe --- /dev/null +++ b/front/src/app/feature/admin-management/components/modal/modal-popup-create.component.ts @@ -0,0 +1,42 @@ +import { Component, computed, input, output, signal } from '@angular/core'; +import { ButtonComponent } from '../../../../shared/button/button.component'; + +@Component({ + selector: 'app-modal-popup-create', + imports: [ButtonComponent], + templateUrl: './modal-popup-create.component.html', + styleUrl: './modal-popup-create.component.scss' +}) +export class ModalPopupCreateComponent { + title = input.required(); + submitText = input.required(); + submittingText = input.required(); + isSubmitting = input(false); + + closed = output(); + submitted = output(); + + readonly titleId = signal(`modal-title-${crypto.randomUUID()}`); + + readonly isBlocked = computed(() => this.isSubmitting()); + + readonly submitButtonText = computed(() => + this.isSubmitting() ? this.submittingText() : this.submitText() + ); + + onClose(): void { + if (this.isBlocked()) return; + this.closed.emit(); + } + + onSubmit(): void { + if (this.isBlocked()) return; + this.submitted.emit(); + } + + onOverlayClick(event: Event): void { + if (event.target === event.currentTarget && !this.isBlocked()) { + this.onClose(); + } + } +} diff --git a/front/src/app/feature/admin-management/components/services/form-modal.service.spec.ts b/front/src/app/feature/admin-management/components/services/form-modal.service.spec.ts new file mode 100644 index 00000000..9cad8173 --- /dev/null +++ b/front/src/app/feature/admin-management/components/services/form-modal.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { FormModalService } from './form-modal.service'; + +describe('FormModalService', () => { + let service: FormModalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FormModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/services/form-modal.service.ts b/front/src/app/feature/admin-management/components/services/form-modal.service.ts new file mode 100644 index 00000000..2ed7f3c3 --- /dev/null +++ b/front/src/app/feature/admin-management/components/services/form-modal.service.ts @@ -0,0 +1,60 @@ +import { DestroyRef, inject, Injectable, WritableSignal } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { finalize } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Injectable({ + providedIn: 'root' +}) +export abstract class FormModalService< + TForm extends { [K in keyof TForm]: AbstractControl }, + TCreateRequest, + TResponse +> { + protected readonly fb = inject(FormBuilder); + protected readonly _destroyRef = inject(DestroyRef); + + abstract form: FormGroup; + + abstract isSubmitting: WritableSignal; + abstract errorMessage: WritableSignal; + + protected abstract initializeForm(): void; + protected abstract buildCreateRequest(): TCreateRequest; + protected abstract submitRequest(request: TCreateRequest): Observable; + protected abstract onSuccess(response: TResponse): void; + protected abstract extractErrorMessage(error: HttpErrorResponse): string; + + onSubmit(): void { + if (this.isSubmitting() || this.form.invalid) { + this.markAllFieldsAsTouched(); + return; + } + + this.isSubmitting.set(true); + this.errorMessage.set(null); + + const request = this.buildCreateRequest(); + + this.submitRequest(request) + .pipe( + takeUntilDestroyed(this._destroyRef), + finalize(() => this.isSubmitting.set(false)) + ) + .subscribe({ + next: (response) => this.onSuccess(response), + error: (error) => { + console.error('Form submission error:', error); + this.errorMessage.set(this.extractErrorMessage(error)); + } + }); + } + + protected markAllFieldsAsTouched(): void { + Object.keys(this.form.controls).forEach(key => { + this.form.get(key)?.markAsTouched(); + }); + } +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.html b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.html new file mode 100644 index 00000000..0d320707 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.html @@ -0,0 +1,45 @@ +
+
+ Formats + + @if (hasFormats()) { +
+ @for (format of availableFormats(); track format.id) { + + } +
+ } @else { +

No formats available

+ } +
+ +
+ Categories + + @if (hasCategories()) { +
+ @for (category of availableCategories(); track category.id) { + + } +
+ } @else { +

No categories available

+ } +
+
diff --git a/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.spec.ts new file mode 100644 index 00000000..2e664536 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionCategoriesFormatsFieldComponent } from './session-categories-formats-field.component'; + +describe('SessionCategoriesFormatsFieldComponent', () => { + let component: SessionCategoriesFormatsFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionCategoriesFormatsFieldComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionCategoriesFormatsFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.ts new file mode 100644 index 00000000..ac8825a3 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-categories-formats-field/session-categories-formats-field.component.ts @@ -0,0 +1,50 @@ +import {Component, computed, EventEmitter, input, Input, output, Output} from '@angular/core'; +import {Category, Format} from '../../../../type/session/session'; + +@Component({ + selector: 'app-session-categories-formats-field', + imports: [], + templateUrl: './session-categories-formats-field.component.html', + styleUrl: './session-categories-formats-field.component.scss' +}) + +export class SessionCategoriesFormatsFieldComponent { + availableFormats = input([]); + availableCategories = input([]); + selectedFormats = input([]); + selectedCategories = input([]); + + formatsChange = output(); + categoriesChange = output(); + + hasFormats = computed(() => this.availableFormats().length > 0); + hasCategories = computed(() => this.availableCategories().length > 0); + + onFormatChange(formatId: string, event: Event): void { + const isChecked = (event.target as HTMLInputElement).checked; + + const updatedFormats = isChecked + ? [...this.selectedFormats(), formatId] + : this.selectedFormats().filter(id => id !== formatId); + + this.formatsChange.emit(updatedFormats); + } + + onCategoryChange(categoryId: string, event: Event): void { + const isChecked = (event.target as HTMLInputElement).checked; + + const updatedCategories = isChecked + ? [...this.selectedCategories(), categoryId] + : this.selectedCategories().filter(id => id !== categoryId); + + this.categoriesChange.emit(updatedCategories); + } + + isFormatSelected(formatId: string): boolean { + return this.selectedFormats().includes(formatId); + } + + isCategorySelected(categoryId: string): boolean { + return this.selectedCategories().includes(categoryId); + } +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.html b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.html new file mode 100644 index 00000000..8f9d9ad5 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.html @@ -0,0 +1,32 @@ +
+
+ + + + + @if (eventDateRange()) { +

+ Event dates: {{ eventDateRange() }} +

+ } +
+ + + +
diff --git a/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.scss b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.scss new file mode 100644 index 00000000..bd8b90c9 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.scss @@ -0,0 +1,64 @@ +input[type="date"] { + color: #111827 !important; + color-scheme: light; +} + +input[type="date"]::-webkit-datetime-edit-fields-wrapper { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-text { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-month-field { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-day-field { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-year-field { + color: #111827 !important; +} + +input[type="date"]:focus { + color: #111827 !important; +} + +input[type="date"]:invalid { + color: #111827 !important; +} + +.required:after { + content: ' *'; + color: var(--color-text-invalidated-form); +} + +input, select { + height: 42px !important; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +textarea { + min-height: auto; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.block { + display: block; +} + +fieldset { + border: none; + padding: 0; + margin: 0; +} + +.character-count { + font-size: 0.75rem; + line-height: 1rem; + text-align: right; + margin-top: 0.25rem; +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.spec.ts new file mode 100644 index 00000000..af24c785 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionDatetimeFieldsComponent } from './session-datetime-fields.component'; + +describe('SessionDatetimeFieldsComponent', () => { + let component: SessionDatetimeFieldsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionDatetimeFieldsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionDatetimeFieldsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.ts new file mode 100644 index 00000000..a6874bb9 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-datetime-fields/session-datetime-fields.component.ts @@ -0,0 +1,49 @@ +import {booleanAttribute, Component, computed, input, Input} from '@angular/core'; +import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {FieldComponent} from '../../../../../../shared/input/field.component'; + +@Component({ + selector: 'app-session-datetime-fields', + imports: [ + FormsModule, + ReactiveFormsModule, + FieldComponent + ], + templateUrl: './session-datetime-fields.component.html', + styleUrl: './session-datetime-fields.component.scss' +}) +export class SessionDatetimeFieldsComponent { + form = input.required(); + eventStartDate = input(); + eventEndDate = input(); + required = input(true, { transform: booleanAttribute }); + + eventDateRange = computed(() => { + const start = this.eventStartDate(); + const end = this.eventEndDate(); + + if (!start || !end) return ''; + + const startStr = start.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + const endStr = end.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + + return `${startStr} - ${endStr}`; + }); + + eventStartDateForInput = computed(() => { + const start = this.eventStartDate(); + return start ? start.toISOString().split('T')[0] : ''; + }); + + eventEndDateForInput = computed(() => { + const end = this.eventEndDate(); + return end ? end.toISOString().split('T')[0] : ''; + }); +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.html b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.html new file mode 100644 index 00000000..e0e43449 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.html @@ -0,0 +1,29 @@ +
+ +
+ + + @if (showDropdown()) { +
+ @for (duration of durations; track duration.value) { + + } +
+ } +
+
diff --git a/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.spec.ts new file mode 100644 index 00000000..bc1e76bd --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionDurationFieldComponent } from './session-duration-field.component'; + +describe('SessionDurationFieldComponent', () => { + let component: SessionDurationFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionDurationFieldComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionDurationFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.ts new file mode 100644 index 00000000..58234480 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-duration-field/session-duration-field.component.ts @@ -0,0 +1,60 @@ +import { Component, computed, input, output, signal } from '@angular/core'; + +@Component({ + selector: 'app-session-duration-field', + imports: [], + templateUrl: './session-duration-field.component.html', + styleUrl: './session-duration-field.component.scss', + host: { '(document:click)': 'onDocumentClick($event)' } +}) +export class SessionDurationFieldComponent { + selectedDuration = input(60); + + durationChange = output(); + + showDropdown = signal(false); + + readonly durations = [20, 30, 40, 45, 50, 60, 75, 90, 105, 110, 120, 130].map(val => { + const hours = Math.floor(val / 60); + const minutes = val % 60; + let label = ''; + + if (hours > 0) { + label += `${hours} hour${hours > 1 ? 's' : ''}`; + } + if (minutes > 0) { + if (hours > 0) label += ' '; + label += `${minutes} minute${minutes > 1 ? 's' : ''}`; + } + + return { label, value: val }; + }); + + currentDurationLabel = computed(() => { + const duration = this.durations.find(d => d.value === this.selectedDuration()); + return duration ? duration.label : `${this.selectedDuration()} minutes`; + }); + + getDurationLabel(minutes: number): string { + const duration = this.durations.find(d => d.value === minutes); + return duration ? duration.label : `${minutes} minutes`; + } + + onDurationSelect(duration: number): void { + this.durationChange.emit(duration); + this.showDropdown.set(false); + } + + onDocumentClick(event: Event): void { + const target = event.target as HTMLElement; + const container = target.closest('[data-duration-dropdown]'); + + if (!container) { + this.showDropdown.set(false); + } + } + + toggleDropdown(): void { + this.showDropdown.update(show => !show); + } +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html new file mode 100644 index 00000000..314e136f --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html @@ -0,0 +1,84 @@ + + + + +
+ {{ form().get('abstractText')?.value?.length || 0 }}/2000 characters +
+
+ + +
+ {{ form().get('references')?.value?.length || 0 }}/1000 characters +
+
+ +
+ + + + + + + + +
+ + + + + + + + + + + diff --git a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.spec.ts new file mode 100644 index 00000000..000c8696 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionFormFieldsComponent } from './session-form-fields.component'; + +describe('SessionFormFieldsComponent', () => { + let component: SessionFormFieldsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionFormFieldsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionFormFieldsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.ts new file mode 100644 index 00000000..dcddeadd --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.ts @@ -0,0 +1,79 @@ +import {Component, computed, EventEmitter, input, Input, output, Output} from '@angular/core'; +import {FormGroup} from '@angular/forms'; +import {Category, Format, Speaker} from '../../../../type/session/session'; +import {SessionSpeakersFieldComponent} from '../session-speakers-field/session-speakers-field.component'; +import {SessionLanguagesFieldComponent} from '../session-languages-field/session-languages-field.component'; +import {SessionDatetimeFieldsComponent} from '../session-datetime-fields/session-datetime-fields.component'; +import {FieldComponent} from '../../../../../../shared/input/field.component'; +import { + SessionCategoriesFormatsFieldComponent +} from '../session-categories-formats-field/session-categories-formats-field.component'; +import {SessionDurationFieldComponent} from '../session-duration-field/session-duration-field.component'; + +@Component({ + selector: 'app-session-form-fields', + imports: [ + SessionSpeakersFieldComponent, + SessionCategoriesFormatsFieldComponent, + SessionLanguagesFieldComponent, + SessionDurationFieldComponent, + SessionDatetimeFieldsComponent, + FieldComponent + ], + templateUrl: './session-form-fields.component.html', + styleUrl: './session-form-fields.component.scss' +}) +export class SessionFormFieldsComponent { + form = input.required(); + availableFormats = input([]); + availableCategories = input([]); + availableTracks = input([]); + eventStartDate = input(); + eventEndDate = input(); + selectedSpeakers = input([]); + availableSpeakers = input([]); + isLoadingSpeakers = input(false); + selectedDuration = input(60); + selectedFormats = input([]); + selectedCategories = input([]); + selectedLanguages = input([]); + + durationChange = output(); + speakersChange = output(); + formatsChange = output(); + categoriesChange = output(); + languagesChange = output(); + + readonly levelOptions = [ + { value: 'beginner', label: 'Beginner' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'advanced', label: 'Advanced' } + ]; + + trackOptions = computed(() => + this.availableTracks().map(track => ({ + value: track, + label: track + })) + ); + + onDurationChange(duration: number): void { + this.durationChange.emit(duration); + } + + onSpeakersChange(speakers: Speaker[]): void { + this.speakersChange.emit(speakers); + } + + onFormatsChange(formats: string[]): void { + this.formatsChange.emit(formats); + } + + onCategoriesChange(categories: string[]): void { + this.categoriesChange.emit(categories); + } + + onLanguagesChange(languages: string[]): void { + this.languagesChange.emit(languages); + } +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.html b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.html new file mode 100644 index 00000000..ebb431e2 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.html @@ -0,0 +1,15 @@ +
+ +
+ @for (lang of commonLanguages; track lang.code) { + + } +
+
diff --git a/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.spec.ts new file mode 100644 index 00000000..01a87d93 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionLanguagesFieldComponent } from './session-languages-field.component'; + +describe('SessionLanguagesFieldComponent', () => { + let component: SessionLanguagesFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionLanguagesFieldComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionLanguagesFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.ts new file mode 100644 index 00000000..8faba61b --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-languages-field/session-languages-field.component.ts @@ -0,0 +1,31 @@ +import {Component, signal} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-session-languages-field', + imports: [ + FormsModule + ], + templateUrl: './session-languages-field.component.html', + styleUrl: './session-create-popup.component.scss' +}) +export class SessionLanguagesFieldComponent { + selectedLanguages = signal([]); + + commonLanguages = [ + { code: 'en', name: 'English' }, + { code: 'fr', name: 'Français' } + ]; + + onLanguageChange(languageCode: string, event: Event): void { + const target = event.target as HTMLInputElement; + + if (target.checked) { + this.selectedLanguages.update(languages => [...languages, languageCode]); + } else { + this.selectedLanguages.update(languages => + languages.filter(code => code !== languageCode) + ); + } + } +} diff --git a/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.html b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.html new file mode 100644 index 00000000..c7004dee --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.html @@ -0,0 +1,88 @@ +
+ + + @if (isLoading()) { +
+
+ Loading speakers... +
+ } @else { +
+
+ +
+ @for (speaker of selectedSpeakers(); track speaker.id) { + + {{ speaker.name }} + + + } +
+ + +
+ + @if (showDropdown() && filteredSpeakers().length > 0) { +
    + @for (speaker of filteredSpeakers(); track speaker.id; let i = $index) { +
  • + +
  • + } +
+ } + + @if (showDropdown() && filteredSpeakers().length === 0 && searchTerm().length > 0) { +
+

+ No speakers found matching "{{ searchTerm() }}" +

+
+ } +
+ } +
diff --git a/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.spec.ts b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.spec.ts new file mode 100644 index 00000000..eafd4c92 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionSpeakersFieldComponent } from './session-speakers-field.component'; + +describe('SessionSpeakersFieldComponent', () => { + let component: SessionSpeakersFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionSpeakersFieldComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionSpeakersFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.ts b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.ts new file mode 100644 index 00000000..553d90eb --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/fields/session-speakers-field/session-speakers-field.component.ts @@ -0,0 +1,152 @@ +import { Component, computed, effect, ElementRef, input, output, signal, viewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Speaker } from '../../../../type/session/session'; + +@Component({ + selector: 'app-session-speakers-field', + imports: [FormsModule], + templateUrl: './session-speakers-field.component.html', + styleUrl: './session-speakers-field.component.scss', + host: { + '(document:click)': 'onDocumentClick($event)' + } +}) +export class SessionSpeakersFieldComponent { + selectedSpeakers = input([]); + availableSpeakers = input([]); + isLoading = input(false); + + speakersChange = output(); + + searchInput = viewChild>('searchInput'); + + searchTerm = signal(''); + showDropdown = signal(false); + highlightedIndex = signal(-1); + private blurTimeout?: number; + + filteredSpeakers = computed(() => { + const term = this.searchTerm().toLowerCase().trim(); + const available = this.availableSpeakers(); + const selected = this.selectedSpeakers(); + + const notSelected = available.filter(speaker => + !selected.some(s => s.id === speaker.id) + ); + + if (!term) { + return notSelected; + } + + return notSelected.filter(speaker => + speaker.name.toLowerCase().includes(term) || + speaker.company?.toLowerCase().includes(term) + ); + }); + + hasSpeakers = computed(() => this.availableSpeakers().length > 0); + + searchPlaceholder = computed(() => + this.selectedSpeakers().length === 0 + ? 'Search and select speakers...' + : 'Add more speakers...' + ); + + constructor() { + effect(() => { + const count = this.filteredSpeakers().length; + if (this.highlightedIndex() >= count) { + this.highlightedIndex.set(-1); + } + }); + } + + onSearch(event: Event): void { + const target = event.target as HTMLInputElement; + this.searchTerm.set(target.value); + this.showDropdown.set(true); + this.highlightedIndex.set(-1); + } + + focusSearch(): void { + this.showDropdown.set(true); + + setTimeout(() => { + const input = this.searchInput(); + input?.nativeElement.focus(); + }, 0); + } + + selectSpeaker(speaker: Speaker): void { + if (this.blurTimeout) { + clearTimeout(this.blurTimeout); + this.blurTimeout = undefined; + } + + if (!this.isSpeakerSelected(speaker)) { + const updatedSpeakers = [...this.selectedSpeakers(), speaker]; + this.speakersChange.emit(updatedSpeakers); + } + + this.searchTerm.set(''); + this.showDropdown.set(false); + this.highlightedIndex.set(-1); + } + + removeSpeaker(speaker: Speaker): void { + const updatedSpeakers = this.selectedSpeakers().filter(s => s.id !== speaker.id); + this.speakersChange.emit(updatedSpeakers); + } + + isSpeakerSelected(speaker: Speaker): boolean { + return this.selectedSpeakers().some(s => s.id === speaker.id); + } + + onInputBlur(): void { + this.blurTimeout = window.setTimeout(() => { + this.showDropdown.set(false); + this.highlightedIndex.set(-1); + }, 150); + } + + onKeydown(event: KeyboardEvent): void { + if (!this.showDropdown() || this.filteredSpeakers().length === 0) return; + + const maxIndex = this.filteredSpeakers().length - 1; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.highlightedIndex.update(index => Math.min(index + 1, maxIndex)); + break; + + case 'ArrowUp': + event.preventDefault(); + this.highlightedIndex.update(index => Math.max(index - 1, -1)); + break; + + case 'Enter': + event.preventDefault(); + const index = this.highlightedIndex(); + if (index >= 0 && index < this.filteredSpeakers().length) { + this.selectSpeaker(this.filteredSpeakers()[index]); + } + break; + + case 'Escape': + this.showDropdown.set(false); + this.highlightedIndex.set(-1); + break; + } + } + + onDocumentClick(event: Event): void { + const target = event.target as HTMLElement; + const container = target.closest('[data-speaker-multiselect]'); + + if (!container && this.showDropdown()) { + this.showDropdown.set(false); + this.highlightedIndex.set(-1); + } + } +} diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html new file mode 100644 index 00000000..546a9b0e --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html @@ -0,0 +1,59 @@ + + + + + + + @if (errorMessage()) { + } + + @if (matchingEmptySession()) { +
+
+ +

+ Empty session detected for selected speaker(s). Form has been pre-filled. +

+
+
+ } + +
diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.scss b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.scss new file mode 100644 index 00000000..1c833b64 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.scss @@ -0,0 +1,32 @@ +input[type="date"] { + color: #111827 !important; + color-scheme: light; +} + +input[type="date"]::-webkit-datetime-edit-fields-wrapper { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-text { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-month-field { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-day-field { + color: #111827 !important; +} + +input[type="date"]::-webkit-datetime-edit-year-field { + color: #111827 !important; +} + +input[type="date"]:focus { + color: #111827 !important; +} + +input[type="date"]:invalid { + color: #111827 !important; +} diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.spec.ts b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.spec.ts new file mode 100644 index 00000000..2eb5e1c4 --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SessionCreatePopupComponent } from './session-create-popup.component'; + +describe('SessionCreatePageComponent', () => { + let component: SessionCreatePopupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SessionCreatePopupComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionCreatePopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts new file mode 100644 index 00000000..0a43358c --- /dev/null +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts @@ -0,0 +1,342 @@ +import { Component, computed, DestroyRef, effect, inject, input, OnInit, output, signal } from '@angular/core'; +import { + AbstractControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { finalize } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { SessionService } from '../../../services/sessions/session.service'; +import { SpeakerService } from '../../../services/speaker/speaker.service'; +import { Category, Format, SessionImportData, Speaker } from '../../../type/session/session'; +import { SessionCreateRequest } from '../../../type/session/session-create'; +import { ModalPopupCreateComponent } from '../../modal/modal-popup-create.component'; +import { FormModalService } from '../../services/form-modal.service'; +import { SessionFormFieldsComponent } from '../fields/session-form-fields/session-form-fields.component'; + +@Component({ + selector: 'app-session-create-popup', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + FormsModule, + ModalPopupCreateComponent, + SessionFormFieldsComponent + ], + templateUrl: './session-create-popup.component.html', + styleUrl: './session-create-popup.component.scss' +}) +export class SessionCreatePopupComponent + extends FormModalService + implements OnInit { + eventId = input.required(); + availableFormats = input([]); + availableCategories = input([]); + availableTracks = input([]); + eventStartDate = input(undefined); + eventEndDate = input(undefined); + + sessionCreated = output(); + popupClosed = output(); + + private readonly sessionService = inject(SessionService); + private readonly speakerService = inject(SpeakerService); + protected override readonly _destroyRef = inject(DestroyRef); + + sessionForm!: FormGroup; + isSubmitting = signal(false); + errorMessage = signal(null); + isLoadingSpeakers = signal(false); + isLoadingEmptySessions = signal(false); + + availableSpeakers = signal([]); + availableEmptySessions = signal([]); + selectedEmptySession = signal(null); + + selectedFormats = signal([]); + selectedCategories = signal([]); + selectedSpeakers = signal([]); + selectedLanguages = signal([]); + selectedDuration = signal(60); + + isFormValid = computed(() => + this.sessionForm?.valid && !this.isSubmitting() + ); + + sortedAvailableSpeakers = computed(() => + [...this.availableSpeakers()].sort((a, b) => a.name.localeCompare(b.name)) + ); + + matchingEmptySession = computed(() => { + const speakers = this.selectedSpeakers(); + const emptySessions = this.availableEmptySessions(); + + if (!speakers.length || !emptySessions.length) { + return null; + } + + const speakerEmails = speakers.map(s => s.email); + return emptySessions.find(session => + session.speakers.some(speaker => speakerEmails.includes(speaker.email)) + ) || null; + }); + + get form(): FormGroup { + return this.sessionForm; + } + + constructor() { + super(); + + effect(() => { + const emptySession = this.matchingEmptySession(); + if (emptySession) { + this.prefillFormFromEmptySession(emptySession); + } + }); + } + + ngOnInit(): void { + this.initializeForm(); + this.loadAvailableSpeakers(); + this.loadEmptySessions(); + this.setDefaultStartDate(); + } + + protected initializeForm(): void { + this.sessionForm = this.fb.group({ + title: ['', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(200) + ]], + abstractText: ['', [Validators.maxLength(2000)]], + references: ['', [Validators.maxLength(1000)]], + level: [''], + track: ['', [Validators.maxLength(50)]], + startDate: ['', [ + Validators.required, + this.eventDateRangeValidator.bind(this) + ]], + startTime: [''] + }); + } + + protected buildCreateRequest(): SessionCreateRequest { + const formValue = this.sessionForm.value; + const { startDateTime, endDateTime } = this.calculateSessionTimes( + formValue.startDate, + formValue.startTime, + this.selectedDuration().toString() + ); + + return { + title: formValue.title.trim(), + abstractText: formValue.abstractText?.trim() || '', + references: formValue.references?.trim() || '', + level: formValue.level || '', + track: formValue.track?.trim() || '', + languages: [...this.selectedLanguages()], + formats: this.getSelectedFormats(), + categories: this.getSelectedCategories(), + speakers: [...this.selectedSpeakers()], + eventId: this.eventId(), + deliberationStatus: 'ACCEPTED', + confirmationStatus: 'CONFIRMED', + start: startDateTime, + end: endDateTime + }; + } + + protected submitRequest(request: SessionCreateRequest): Observable { + return this.sessionService.createSession(this.eventId(), request); + } + + protected onSuccess(response: SessionImportData): void { + this.sessionCreated.emit(); + this.onClose(); + } + + protected extractErrorMessage(error: HttpErrorResponse): string { + if (error.status === 400 && error.error?.errors) { + const validationErrors = error.error.errors; + if (Array.isArray(validationErrors) && validationErrors.length > 0) { + return validationErrors[0].defaultMessage || validationErrors[0]; + } + } + + if (error.error?.message) { + return error.error.message; + } + + const statusMessages: Record = { + 400: 'Invalid session data. Please check your inputs.', + 403: 'You do not have permission to create sessions for this event.', + 404: 'Event not found.', + 409: 'A session with this title already exists.', + 500: 'Server error. Please try again later.' + }; + + return statusMessages[error.status] || 'Failed to create session. Please try again.'; + } + + onDurationChange(duration: number): void { + this.selectedDuration.set(duration); + } + + onSpeakersChange(speakers: Speaker[]): void { + this.selectedSpeakers.set(speakers); + } + + onFormatsChange(formats: string[]): void { + this.selectedFormats.set(formats); + } + + onCategoriesChange(categories: string[]): void { + this.selectedCategories.set(categories); + } + + onLanguagesChange(languages: string[]): void { + this.selectedLanguages.set(languages); + } + + onClose(): void { + if (this.isSubmitting()) return; + this.popupClosed.emit(); + } + + private loadAvailableSpeakers(): void { + this.isLoadingSpeakers.set(true); + + this.speakerService.getSpeakersByEventId(this.eventId()) + .pipe( + takeUntilDestroyed(this._destroyRef), + finalize(() => this.isLoadingSpeakers.set(false)) + ) + .subscribe({ + next: (speakers) => { + this.availableSpeakers.set(speakers); + }, + error: (error) => { + console.error('Error loading speakers:', error); + this.availableSpeakers.set([]); + } + }); + } + + private loadEmptySessions(): void { + this.isLoadingEmptySessions.set(true); + + this.sessionService.getEmptySessionsForEvent(this.eventId()) + .pipe( + takeUntilDestroyed(this._destroyRef), + finalize(() => this.isLoadingEmptySessions.set(false)) + ) + .subscribe({ + next: (sessions) => { + this.availableEmptySessions.set(sessions); + }, + error: (error) => { + console.error('Error loading empty sessions:', error); + this.availableEmptySessions.set([]); + } + }); + } + + private setDefaultStartDate(): void { + const startDate = this.eventStartDate(); + if (startDate) { + const defaultDate = this.formatDateForInput(startDate); + this.sessionForm.patchValue({ startDate: defaultDate }); + } + } + + private formatDateForInput(date: Date): string { + if (!date || isNaN(date.getTime())) return ''; + return date.toISOString().split('T')[0]; + } + + private calculateSessionTimes( + startDate: string, + startTime: string, + duration: string + ): { startDateTime: Date | null; endDateTime: Date | null } { + if (!startDate || !startTime || !duration) { + return { startDateTime: null, endDateTime: null }; + } + + try { + const startDateTime = new Date(`${startDate}T${startTime}:00`); + const durationMinutes = parseInt(duration, 10); + const endDateTime = new Date(startDateTime.getTime() + (durationMinutes * 60 * 1000)); + return { startDateTime, endDateTime }; + } catch (error) { + console.error('Error calculating session times:', error); + return { startDateTime: null, endDateTime: null }; + } + } + + private getSelectedFormats(): Format[] { + const formats = this.availableFormats(); + const selected = this.selectedFormats(); + return formats.filter(format => selected.includes(format.id)); + } + + private getSelectedCategories(): Category[] { + const categories = this.availableCategories(); + const selected = this.selectedCategories(); + return categories.filter(category => selected.includes(category.id)); + } + + private eventDateRangeValidator(control: AbstractControl): ValidationErrors | null { + if (!control.value) { + return null; + } + + const startDate = this.eventStartDate(); + const endDate = this.eventEndDate(); + + if (!startDate || !endDate) { + return null; + } + + const selectedDate = new Date(control.value); + const eventStart = new Date(startDate); + const eventEnd = new Date(endDate); + + eventStart.setHours(0, 0, 0, 0); + eventEnd.setHours(23, 59, 59, 999); + selectedDate.setHours(12, 0, 0, 0); + + if (selectedDate < eventStart || selectedDate > eventEnd) { + return { + eventDateOutOfRange: { + selectedDate: selectedDate.toISOString().split('T')[0], + eventStart: eventStart.toISOString().split('T')[0], + eventEnd: eventEnd.toISOString().split('T')[0] + } + }; + } + + return null; + } + + private prefillFormFromEmptySession(session: SessionImportData): void { + const speaker = session.speakers[0]; + const currentTitle = this.sessionForm.get('title')?.value; + + if (speaker && !currentTitle) { + this.sessionForm.patchValue({ + title: `Session by ${speaker.name}` + }); + } + } +} diff --git a/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.html b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.html new file mode 100644 index 00000000..064533c0 --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.html @@ -0,0 +1,58 @@ +
+ Social Links + +
+ + + + Add + +
+ + @if (socialLinks().length > 0) { +
    + @for (link of socialLinks(); track link; let i = $index) { +
  • + + {{ link }} + + + + + + +
  • + } +
+ } + + @if (maxLinksReached()) { + + } + + @if (errorMessage()) { + + } +
diff --git a/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.spec.ts b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.spec.ts new file mode 100644 index 00000000..942c521f --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SocialLinksFieldComponent } from './social-links-field.component'; + +describe('SocialLinksFieldComponent', () => { + let component: SocialLinksFieldComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SocialLinksFieldComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SocialLinksFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.ts b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.ts new file mode 100644 index 00000000..5bf4ff1d --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/social-links-field/social-links-field.component.ts @@ -0,0 +1,82 @@ +import {Component, computed, EventEmitter, input, Input, output, Output, signal} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {ButtonComponent} from '../../../../../../shared/button/button.component'; + +@Component({ + selector: 'app-social-links-field', + imports: [ + FormsModule, + ButtonComponent + ], + templateUrl: './social-links-field.component.html', + styleUrl: './social-links-field.component.scss' +}) + +export class SocialLinksFieldComponent { + socialLinks = input([]); + socialLinksChange = output(); + + newSocialLink = signal(''); + errorMessage = signal(null); + + maxLinksReached = computed(() => this.socialLinks().length >= 5); + isAddDisabled = computed(() => + !this.newSocialLink().trim() || this.maxLinksReached() + ); + + remainingSlots = computed(() => 5 - this.socialLinks().length); + + addSocialLink(): void { + const link = this.newSocialLink().trim(); + if (!link) return; + + if (this.socialLinks().includes(link)) { + this.showTemporaryError('This social link already exists'); + return; + } + + if (!this.isValidUrl(link)) { + this.showTemporaryError('Please enter a valid URL'); + return; + } + + if (this.maxLinksReached()) { + this.showTemporaryError('Maximum 5 social links allowed'); + return; + } + + const updatedLinks = [...this.socialLinks(), link]; + this.socialLinksChange.emit(updatedLinks); + + this.newSocialLink.set(''); + this.errorMessage.set(null); + } + + removeSocialLink(index: number): void { + const links = this.socialLinks(); + + if (index >= 0 && index < links.length) { + const updatedLinks = links.filter((_, i) => i !== index); + this.socialLinksChange.emit(updatedLinks); + } + } + + private isValidUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return ['http:', 'https:'].includes(urlObj.protocol); + } catch { + return false; + } + } + + private showTemporaryError(message: string): void { + this.errorMessage.set(message); + + setTimeout(() => { + if (this.errorMessage() === message) { + this.errorMessage.set(null); + } + }, 3000); + } +} diff --git a/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.html b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.html new file mode 100644 index 00000000..a71fa959 --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.html @@ -0,0 +1,83 @@ +
+ + + + + +
+ +
+ +
+ {{ form().get('bio')?.value?.length || 0 }}/2000 characters +
+
+ +
+ + + + + +
+ + + + + +
+ {{ form().get('references')?.value?.length || 0 }}/2000 characters +
+
+
+ + + diff --git a/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.spec.ts b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.spec.ts new file mode 100644 index 00000000..3cd9c26a --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SpeakerFormFieldsComponent } from './speaker-form-fields.component'; + +describe('SpeakerFormFieldsComponent', () => { + let component: SpeakerFormFieldsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SpeakerFormFieldsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SpeakerFormFieldsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.ts b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.ts new file mode 100644 index 00000000..83239aeb --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/fields/speaker-form-fields/speaker-form-fields.component.ts @@ -0,0 +1,25 @@ +import {Component, EventEmitter, input, Input, output, Output} from '@angular/core'; +import {FormGroup} from '@angular/forms'; +import {SocialLinksFieldComponent} from '../social-links-field/social-links-field.component'; +import {FieldComponent} from '../../../../../../shared/input/field.component'; + +@Component({ + selector: 'app-speaker-form-fields', + imports: [ + SocialLinksFieldComponent, + FieldComponent, + ], + templateUrl: './speaker-form-fields.component.html', + styleUrl: './speaker-form-fields.component.scss' +}) + +export class SpeakerFormFieldsComponent { + form = input.required(); + socialLinks = input([]); + + socialLinksChange = output(); + + onSocialLinksChange(links: string[]): void { + this.socialLinksChange.emit(links); + } +} diff --git a/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.html b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.html new file mode 100644 index 00000000..4c295ad9 --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.html @@ -0,0 +1,36 @@ + + +
+ + + + @if (errorMessage()) { + + } +
+
diff --git a/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.spec.ts b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.spec.ts new file mode 100644 index 00000000..6fea389b --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SpeakerCreatePopupComponent } from './speaker-create-popup.component'; + +describe('SpeakerCreatePopupComponent', () => { + let component: SpeakerCreatePopupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SpeakerCreatePopupComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SpeakerCreatePopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.ts b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.ts new file mode 100644 index 00000000..d532de98 --- /dev/null +++ b/front/src/app/feature/admin-management/components/speaker/speaker-create-popup/speaker-create-popup.component.ts @@ -0,0 +1,165 @@ +import { + Component, computed, + inject, input, + OnInit, output, + signal, +} from '@angular/core'; +import { + AbstractControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; +import {SpeakerService} from '../../../services/speaker/speaker.service'; +import {SpeakerCreateRequest, SpeakerImportData} from '../../../type/speaker/speaker-create'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ModalPopupCreateComponent} from '../../modal/modal-popup-create.component'; +import {Observable} from 'rxjs'; +import {FormModalService} from '../../services/form-modal.service'; +import {SpeakerFormFieldsComponent} from '../fields/speaker-form-fields/speaker-form-fields.component'; + +@Component({ + selector: 'app-speaker-create-popup', + imports: [ + ReactiveFormsModule, + FormsModule, + SpeakerFormFieldsComponent, + ModalPopupCreateComponent, + ], + templateUrl: './speaker-create-popup.component.html', + styleUrl: './speaker-create-popup.component.scss' +}) + +export class SpeakerCreatePopupComponent + extends FormModalService + implements OnInit { + + eventId = input.required(); + speakerCreated = output(); + popupClosed = output(); + + private readonly speakerService = inject(SpeakerService); + + speakerForm!: FormGroup; + isSubmitting = signal(false); + errorMessage = signal(null); + socialLinks = signal([]); + + isFormValid = computed(() => + this.speakerForm?.valid && !this.isSubmitting() + ); + + get form(): FormGroup { + return this.speakerForm; + } + + ngOnInit(): void { + this.initializeForm(); + } + + protected initializeForm(): void { + this.speakerForm = this.fb.group({ + name: ['', [ + Validators.required, + Validators.minLength(2), + Validators.maxLength(100), + this.noWhitespaceValidator + ]], + email: ['', [ + Validators.required, + Validators.email, + Validators.maxLength(100) + ]], + bio: ['', [Validators.maxLength(2000)]], + company: ['', [Validators.maxLength(100)]], + location: ['', [Validators.maxLength(100)]], + picture: ['', [Validators.maxLength(500), this.urlValidator]], + references: ['', [Validators.maxLength(2000)]] + }); + } + + protected buildCreateRequest(): SpeakerCreateRequest { + const formValue = this.speakerForm.value; + const links = this.socialLinks(); + + return { + name: formValue.name.trim(), + email: formValue.email.trim().toLowerCase(), + bio: this.trimOrUndefined(formValue.bio), + company: this.trimOrUndefined(formValue.company), + location: this.trimOrUndefined(formValue.location), + picture: this.trimOrUndefined(formValue.picture), + references: this.trimOrUndefined(formValue.references), + eventId: this.eventId(), + socialLinks: links.length > 0 ? [...links] : undefined + }; + } + + protected submitRequest(request: SpeakerCreateRequest): Observable { + return this.speakerService.createSpeaker(this.eventId(), request); + } + + protected onSuccess(response: SpeakerImportData): void { + this.speakerCreated.emit(); + this.onClose(); + } + + protected extractErrorMessage(error: HttpErrorResponse): string { + if (error.status === 400 && error.error?.errors) { + const validationErrors = error.error.errors; + if (Array.isArray(validationErrors) && validationErrors.length > 0) { + return validationErrors[0].defaultMessage || validationErrors[0]; + } + } + + if (error.error?.message) { + return error.error.message; + } + + const statusMessages: Record = { + 400: 'Invalid data provided. Please check your inputs.', + 409: 'A speaker with this email already exists in this event.', + 403: 'You do not have permission to create speakers for this event.', + 404: 'Event not found.', + 500: 'Server error. Please try again later.' + }; + + return statusMessages[error.status] || 'Failed to create speaker. Please try again.'; + } + + onSocialLinksChange(links: string[]): void { + this.socialLinks.set(links); + } + + onClose(): void { + if (this.isSubmitting()) return; + this.popupClosed.emit(); + } + + private noWhitespaceValidator(control: AbstractControl): ValidationErrors | null { + if (control.value && control.value.trim().length === 0) { + return { whitespace: true }; + } + return null; + } + + private urlValidator(control: AbstractControl): ValidationErrors | null { + if (!control.value || control.value.trim() === '') { + return null; + } + + try { + new URL(control.value); + return null; + } catch { + return { invalidUrl: true }; + } + } + + private trimOrUndefined(value: string | null | undefined): string | undefined { + if (!value || value.trim() === '') return undefined; + return value.trim(); + } +} diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html index 39de7e5b..22770680 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html @@ -46,6 +46,7 @@ Create session @@ -175,6 +176,19 @@ @if (totalPages() > 1) { } + + @if (showCreatePopup()) { + + + }
}
diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index 28974e5e..6f289b60 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -1,4 +1,4 @@ -import { Component, input, OnInit, inject, signal } from '@angular/core'; +import { Component, input, OnInit, inject, signal, computed } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { finalize } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -12,8 +12,10 @@ import { EventService } from '../../../services/event/event.service'; import { EventDataService } from '../../../services/event/event-data.service'; import { ButtonComponent } from '../../../../../shared/button/button.component'; import { PaginationListComponent } from '../../../components/pagination-list/pagination-list.component'; -import {SessionFilterService} from '../../../services/sessions/session-filter.service'; -import {SessionFormatterService} from '../../../services/sessions/session-formatter.service'; +import { SessionFilterService } from '../../../services/sessions/session-filter.service'; +import { SessionFormatterService } from '../../../services/sessions/session-formatter.service'; +import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; +import { SessionService } from '../../../services/sessions/session.service'; @Component({ selector: 'app-session-list-page', @@ -25,7 +27,8 @@ import {SessionFormatterService} from '../../../services/sessions/session-format SessionFilterPopupComponent, AsyncPipe, ButtonComponent, - PaginationListComponent + PaginationListComponent, + SessionCreatePopupComponent ], providers: [BaseListService, SessionFilterService], templateUrl: './session-list-page.component.html', @@ -33,28 +36,36 @@ import {SessionFormatterService} from '../../../services/sessions/session-format }) export class SessionListPageComponent implements OnInit { readonly icon = input('search'); - readonly listService = inject(BaseListService); - readonly filterService = inject(SessionFilterService); - readonly formatterService = inject(SessionFormatterService); + private readonly listService = inject(BaseListService); + private readonly filterService = inject(SessionFilterService); + private readonly formatterService = inject(SessionFormatterService); + private readonly sessionService = inject(SessionService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly eventService = inject(EventService); readonly showFilterPopup = signal(false); + readonly showCreatePopup = signal(false); + readonly availableTracks = signal([]); + readonly eventStartDate = signal(undefined); + readonly eventEndDate = signal(undefined); readonly state$ = this.listService.state$; readonly totalSessions = this.listService.paginationService.totalItemsSignal; - readonly isLoadingSessions = () => this.listService.getCurrentState().isLoadingItems; + readonly isLoadingSessions = computed(() => this.listService.getCurrentState().isLoadingItems); readonly paginatedSessions = this.listService.paginationService.paginatedItemsSignal; readonly totalPages = this.listService.paginationService.totalPagesSignal; - readonly selectAll = () => this.listService.getCurrentState().selectAll; - readonly selectedItems = () => this.listService.getCurrentState().selectedItems; - readonly searchTerm = () => this.listService.getCurrentState().searchTerm; + readonly selectAll = computed(() => this.listService.getCurrentState().selectAll); + readonly selectedItems = computed(() => this.listService.getCurrentState().selectedItems); + readonly searchTerm = computed(() => this.listService.getCurrentState().searchTerm); + readonly eventId = computed(() => this.listService.getCurrentState().eventId); + readonly availableFormats = this.filterService.availableFormats; readonly availableCategories = this.filterService.availableCategories; readonly currentFilters = this.filterService.currentFilters; readonly hasActiveFilters = this.filterService.hasActiveFilters; readonly activeFiltersCount = this.filterService.activeFiltersCount; + formatSpeakers = this.formatterService.formatSpeakers.bind(this.formatterService); readonly Math = Math; @@ -168,4 +179,17 @@ export class SessionListPageComponent implements OnInit { this.openItemDetail(sessionId); } } + + onCreateSession(): void { + this.showCreatePopup.set(true); + } + + onCloseCreatePopup(): void { + this.showCreatePopup.set(false); + } + + onSessionCreated(): void { + this.showCreatePopup.set(false); + this.loadItems(); + } } diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html index e2de6bde..11757c2e 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.html @@ -51,6 +51,7 @@

Speakers Management

Create speaker @@ -195,6 +196,14 @@

@if (totalPages() > 1) { } + + @if (showCreatePopup()) { + + + }

} diff --git a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts index 8e8c56a1..b7828942 100644 --- a/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/speaker/speaker-list-page/speaker-list-page.component.ts @@ -1,4 +1,4 @@ -import { Component, input, OnInit, OnDestroy, inject, signal } from '@angular/core'; +import {Component, input, OnInit, OnDestroy, inject, signal, computed} from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { finalize, Observable } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -17,6 +17,12 @@ import { PaginationListComponent } from '../../../components/pagination-list/pag import { ListState } from '../../../components/type/liste-state'; import { SpeakerFilterService } from '../../../services/speaker/speaker-filter.service'; import { SpeakerFormatterService } from '../../../services/speaker/speaker-formatter.service'; +import { + SessionCreatePopupComponent +} from '../../../components/session/session-create-popup/session-create-popup.component'; +import { + SpeakerCreatePopupComponent +} from '../../../components/speaker/speaker-create-popup/speaker-create-popup.component'; @Component({ selector: 'app-speaker-list-page', @@ -27,7 +33,9 @@ import { SpeakerFormatterService } from '../../../services/speaker/speaker-forma SpeakerFilterPopupComponent, AsyncPipe, ButtonComponent, - PaginationListComponent + PaginationListComponent, + SessionCreatePopupComponent, + SpeakerCreatePopupComponent ], providers: [BaseListService, SpeakerFilterService], templateUrl: './speaker-list-page.component.html', @@ -37,6 +45,10 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { readonly icon = input('person'); readonly showFilterPopup = signal(false); + readonly showCreatePopup = signal(false); + readonly availableTracks = signal([]); + readonly eventStartDate = signal(undefined); + readonly eventEndDate = signal(undefined); private speakersWithSessions: SpeakerWithSessionsDTO[] = []; @@ -50,12 +62,13 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { readonly state$: Observable = this.listService.state$; readonly totalSpeakers = this.listService.paginationService.totalItemsSignal; - readonly isLoadingSpeakers = () => this.listService.getCurrentState().isLoadingItems; + readonly isLoadingSpeakers = computed(() => this.listService.getCurrentState().isLoadingItems); readonly paginatedSpeakers = this.listService.paginationService.paginatedItemsSignal; readonly totalPages = this.listService.paginationService.totalPagesSignal; - readonly selectAll = () => this.listService.getCurrentState().selectAll; - readonly selectedItems = () => this.listService.getCurrentState().selectedItems; - readonly searchTerm = () => this.listService.getCurrentState().searchTerm; + readonly selectAll = computed(() => this.listService.getCurrentState().selectAll); + readonly selectedItems = computed(() => this.listService.getCurrentState().selectedItems); + readonly searchTerm = computed(() => this.listService.getCurrentState().searchTerm); + readonly eventId = computed(() => this.listService.getCurrentState().eventId); readonly availableFormats = this.filterService.availableFormats; readonly availableCategories = this.filterService.availableCategories; readonly currentFilters = this.filterService.currentFilters; @@ -175,4 +188,17 @@ export class SpeakerListPageComponent implements OnInit, OnDestroy { const currentState = this.listService.getCurrentState(); this.router.navigate(['event', currentState.eventId, 'speaker', speakerId]); } + + onCreateSpeaker(): void { + this.showCreatePopup.set(true); + } + + onCloseCreatePopup(): void { + this.showCreatePopup.set(false); + } + + onSpeakerCreated(): void { + this.showCreatePopup.set(false); + this.loadItems(); + } } diff --git a/front/src/app/feature/admin-management/services/sessions/session.service.ts b/front/src/app/feature/admin-management/services/sessions/session.service.ts index 78f41d85..878f4c77 100644 --- a/front/src/app/feature/admin-management/services/sessions/session.service.ts +++ b/front/src/app/feature/admin-management/services/sessions/session.service.ts @@ -6,6 +6,7 @@ import {environment} from '../../../../../environments/environment.development'; import {map} from 'rxjs/operators'; import {convertToDate} from '../../utils/date.utils'; import {SessionScheduleUpdate} from '../../type/session/schedule-json-data'; +import {SessionCreateRequest} from '../../type/session/session-create'; @Injectable({ providedIn: 'root' @@ -58,4 +59,23 @@ export class SessionService { map(sessions => sessions.map(session => this.convertSessionDates(session))) ); } + + createSession(eventId: string, sessionData: SessionCreateRequest): Observable { + return this.http.post( + `${environment.apiUrl}/session/event/${eventId}`, + sessionData, + { withCredentials: true } + ).pipe( + map(sessionData => this.convertSessionDates(sessionData)) + ); + } + + getEmptySessionsForEvent(eventId: string): Observable { + return this.http.get( + `${environment.apiUrl}/session/event/${eventId}/empty-sessions`, + { withCredentials: true } + ).pipe( + map(sessions => sessions.map(session => this.convertSessionDates(session))) + ); + } } diff --git a/front/src/app/feature/admin-management/services/speaker/speaker.service.ts b/front/src/app/feature/admin-management/services/speaker/speaker.service.ts index dc5b635a..f0cf68bc 100644 --- a/front/src/app/feature/admin-management/services/speaker/speaker.service.ts +++ b/front/src/app/feature/admin-management/services/speaker/speaker.service.ts @@ -1,9 +1,12 @@ import { Injectable } from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {Observable} from 'rxjs'; +import {HttpClient, HttpErrorResponse} from '@angular/common/http'; +import {Observable, throwError} from 'rxjs'; import {Speaker} from '../../type/session/session'; import {environment} from '../../../../../environments/environment.development'; import {SpeakerWithSessionsDTO} from '../../type/speaker/speaker-with-sessions'; +import {SpeakerCreateRequest, SpeakerImportData} from '../../type/speaker/speaker-create'; +import {catchError, map} from 'rxjs/operators'; +import {convertToDate} from '../../utils/date.utils'; @Injectable({ providedIn: 'root' @@ -24,4 +27,33 @@ export class SpeakerService { getSpeakersWithSessionsByEventId(eventId: string): Observable { return this.http.get(`${environment.apiUrl}/session/event/${eventId}/speakers-with-sessions`); } + + getSpeakersByEventId(eventId: string): Observable { + return this.http.get( + `${environment.apiUrl}/session/event/${eventId}/speakers`, + { withCredentials: true } + ); + } + + createSpeaker(eventId: string, speakerData: SpeakerCreateRequest): Observable { + return this.http.post( + `${environment.apiUrl}/speaker/event/${eventId}/new-speaker`, + speakerData, + { withCredentials: true } + ).pipe( + map(speakerData => this.convertSpeakerDates(speakerData)), + catchError((error: HttpErrorResponse) => { + console.error('Speaker creation failed:', error); + return throwError(() => error); + }) + ); + } + + private convertSpeakerDates(speakerData: SpeakerImportData): SpeakerImportData { + return { + ...speakerData, + createdAt: speakerData.createdAt ? convertToDate(speakerData.createdAt)?.toISOString() : undefined, + updatedAt: speakerData.updatedAt ? convertToDate(speakerData.updatedAt)?.toISOString() : undefined + }; + } } diff --git a/front/src/app/feature/admin-management/type/session/session-create.ts b/front/src/app/feature/admin-management/type/session/session-create.ts new file mode 100644 index 00000000..0bcf8a7b --- /dev/null +++ b/front/src/app/feature/admin-management/type/session/session-create.ts @@ -0,0 +1,18 @@ +import {Category, Format, Speaker} from './session'; + +export type SessionCreateRequest = { + title: string; + abstractText?: string; + references?: string; + level?: string; + track?: string; + languages?: string[]; + formats?: Format[]; + categories?: Category[]; + speakers?: Speaker[]; + eventId: string; + deliberationStatus?: string; + confirmationStatus?: string; + start?: Date | null; + end?: Date | null; +} diff --git a/front/src/app/feature/admin-management/type/speaker/speaker-create.ts b/front/src/app/feature/admin-management/type/speaker/speaker-create.ts new file mode 100644 index 00000000..77f9bb98 --- /dev/null +++ b/front/src/app/feature/admin-management/type/speaker/speaker-create.ts @@ -0,0 +1,25 @@ +export type SpeakerCreateRequest = { + name: string; + bio?: string; + company?: string; + references?: string; + email: string; + eventId: string; + picture?: string; + location?: string; + socialLinks?: string[]; +} + +export type SpeakerImportData = { + name: string; + bio?: string; + company?: string; + references?: string; + email?: string; + eventId: string; + picture?: string; + location?: string; + socialLinks?: string[]; + createdAt?: string; + updatedAt?: string; +} diff --git a/front/src/app/shared/input/field.component.html b/front/src/app/shared/input/field.component.html index 25bc8245..72d91a5d 100644 --- a/front/src/app/shared/input/field.component.html +++ b/front/src/app/shared/input/field.component.html @@ -25,7 +25,7 @@ - -
- - -
-
-

- {{ session.title || 'Titre non défini' }} -

-

- by {{ formatSpeakers(session.speakers) }} -

+ @if(session.title!){ + + +
+ +
-
- - + + +
+
+

+ {{ session.title || 'Titre non défini' }} +

+

+ by {{ formatSpeakers(session.speakers) }} +

+
+
+ + + } } } @@ -177,18 +179,18 @@ } - @if (showCreatePopup()) { - - - } + + + + + + + + + + + +
} diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index f9485f27..6aa55851 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -13,7 +13,7 @@ import { ButtonComponent } from '../../../../../shared/button/button.component'; import { PaginationListComponent } from '../../../components/pagination-list/pagination-list.component'; import { SessionFilterService } from '../../../services/sessions/session-filter.service'; import { SessionFormatterService } from '../../../services/sessions/session-formatter.service'; -import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; +// import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; import { SessionService } from '../../../services/sessions/session.service'; import {BaseListService} from '../../../components/services/list/base-list.service'; @@ -28,7 +28,7 @@ import {BaseListService} from '../../../components/services/list/base-list.servi AsyncPipe, ButtonComponent, PaginationListComponent, - SessionCreatePopupComponent + // SessionCreatePopupComponent ], providers: [BaseListService, SessionFilterService], templateUrl: './session-list-page.component.html', diff --git a/front/src/app/shared/button/button.component.html b/front/src/app/shared/button/button.component.html index da1581d7..63613128 100644 --- a/front/src/app/shared/button/button.component.html +++ b/front/src/app/shared/button/button.component.html @@ -3,25 +3,21 @@ [class]="buttonClasses()" [disabled]="disabled()" [attr.aria-label]="ariaLabel()" - [attr.form]="form()" (click)="handleButtonClick()"> @if (materialIcon()) {
-
} diff --git a/front/src/app/shared/button/button.component.ts b/front/src/app/shared/button/button.component.ts index 31f9736e..e7005895 100644 --- a/front/src/app/shared/button/button.component.ts +++ b/front/src/app/shared/button/button.component.ts @@ -14,12 +14,11 @@ export class ButtonComponent { readonly buttonHandler = input<(() => void) | null>(null); readonly isActivePage = input(false); readonly disabled = input(false); + readonly ariaLabel = input(null); readonly hasNotification = input(false); readonly notificationCount = input(1); readonly customClass = input(''); readonly materialIconClass = input('text-base'); - readonly ariaLabel = input(null); - readonly form = input(null); readonly itemClick = output(); @@ -45,14 +44,21 @@ export class ButtonComponent { : 'You have notifications'; }); - handleButtonClick(): void { + + + navigate(): void { if (this.disabled()) { return; } - const isFormSubmit = this.type() === 'submit' && this.form(); - if (isFormSubmit) { - console.log('🔘 Submit button clicked (linked to form)', this.form()); + const routeValue = this.route(); + if (routeValue) { + this.itemClick.emit(routeValue); + } + } + + handleButtonClick(): void { + if (this.disabled()) { return; } From eb118a14439fd637fe5875874993cb6d6b346366 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:47:16 +0200 Subject: [PATCH 20/23] create session, and abstractText default view --- .../dto/session/SessionCreateRequestDTO.java | 2 + .../speakerspace/dto/session/SessionDTO.java | 2 + .../session/SessionReviewImportData.java | 39 -- .../session-form-fields.component.html | 4 +- .../session-create-popup.component.html | 84 ++- .../session-create-popup.component.ts | 626 +++++++++--------- .../session-list-page.component.html | 40 +- .../session-list-page.component.ts | 4 +- 8 files changed, 382 insertions(+), 419 deletions(-) delete mode 100644 back/src/main/java/com/speakerspace/model/session/SessionReviewImportData.java diff --git a/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java index a5d39853..ce129072 100644 --- a/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java @@ -1,5 +1,6 @@ package com.speakerspace.dto.session; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -14,6 +15,7 @@ public record SessionCreateRequestDTO( String title, @Size(max = 2000, message = "Abstract must not exceed 2000 characters") + @JsonProperty("abstract") String abstractText, @Size(max = 1000, message = "References must not exceed 1000 characters") diff --git a/back/src/main/java/com/speakerspace/dto/session/SessionDTO.java b/back/src/main/java/com/speakerspace/dto/session/SessionDTO.java index 9e812418..19f2e1c5 100644 --- a/back/src/main/java/com/speakerspace/dto/session/SessionDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/SessionDTO.java @@ -1,5 +1,6 @@ package com.speakerspace.dto.session; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import java.time.LocalDateTime; @@ -10,6 +11,7 @@ public record SessionDTO ( String id, String title, + @JsonProperty("abstract") String abstractText, String deliberationStatus, String confirmationStatus, diff --git a/back/src/main/java/com/speakerspace/model/session/SessionReviewImportData.java b/back/src/main/java/com/speakerspace/model/session/SessionReviewImportData.java deleted file mode 100644 index 26920e3a..00000000 --- a/back/src/main/java/com/speakerspace/model/session/SessionReviewImportData.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.speakerspace.model.session; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.cloud.spring.data.firestore.Document; -import jakarta.validation.constraints.NotBlank; -import lombok.*; - -import java.util.ArrayList; -import java.util.List; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(onlyExplicitlyIncluded = true) -@Builder -@Document -public class SessionReviewImportData { - - @NotBlank(message = "ID is required") - @EqualsAndHashCode.Include - private String id; - private String title; - - @JsonProperty("abstract") - private String abstractText; - - private String deliberationStatus; - private String confirmationStatus; - private String level; - private String references; - private String eventId; - private List formats = new ArrayList<>(); - private List categories = new ArrayList<>(); - private List tags = new ArrayList<>(); - private List languages = new ArrayList<>(); - private List speakers = new ArrayList<>(); - private Reviews reviews; -} \ No newline at end of file diff --git a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html index 24092f4b..7701add2 100644 --- a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html +++ b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html @@ -45,10 +45,10 @@ + [placeholder]="availableTracks.length > 0 ? 'Select track...' : 'Enter track name'"> - - - - - - - - + - - - - - +
- - - - - - - + @if (errorMessage()) { + + } - - - - - - - - - - - - - - - - - - - - - - + + +
+
diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts index c00102fe..e5cb6247 100644 --- a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.ts @@ -1,313 +1,313 @@ -// import { Component, OnInit, inject, input, output, signal, computed, effect, DestroyRef } from '@angular/core'; -// import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, ReactiveFormsModule } from '@angular/forms'; -// import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -// import { finalize } from 'rxjs/operators'; -// import {Category, Format, SessionImportData, Speaker} from '../../../type/session/session'; -// import {SessionService} from '../../../services/sessions/session.service'; -// import {SpeakerService} from '../../../services/speaker/speaker.service'; -// import {SessionCreateRequest} from '../../../type/session/session-create'; -// import {ModalPopupCreateComponent} from '../../modal/modal-popup-create.component'; -// import {SessionFormFieldsComponent} from '../fields/session-form-fields/session-form-fields.component'; -// import {HttpErrorHandlerService} from '../../services/create/http-error-handler.service'; -// import {SessionDateCalculatorService} from '../../services/create/session-date-calculator.service'; -// import {FormSubmissionService} from '../../services/create/form-submission.service'; -// import {SessionRequestBuilderService} from '../../services/create/session-request-builder.service'; -// -// @Component({ -// selector: 'app-session-create-popup', -// templateUrl: './session-create-popup.component.html', -// imports: [ModalPopupCreateComponent, ReactiveFormsModule, SessionFormFieldsComponent], -// standalone: true, -// providers: [FormSubmissionService, SessionRequestBuilderService] -// }) -// export class SessionCreatePopupComponent implements OnInit { -// eventId = input.required(); -// availableFormats = input([]); -// availableCategories = input([]); -// availableTracks = input([]); -// eventStartDate = input(undefined); -// eventEndDate = input(undefined); -// -// sessionCreated = output(); -// popupClosed = output(); -// -// private readonly fb = inject(FormBuilder); -// private readonly destroyRef = inject(DestroyRef); -// private readonly sessionService = inject(SessionService); -// private readonly speakerService = inject(SpeakerService); -// private readonly formSubmission = inject(FormSubmissionService); -// private readonly errorHandler = inject(HttpErrorHandlerService); -// private readonly dateCalculator = inject(SessionDateCalculatorService); -// private readonly requestBuilder = inject(SessionRequestBuilderService); -// -// sessionForm!: FormGroup; -// isSubmitting = signal(false); -// errorMessage = signal(null); -// isLoadingSpeakers = signal(false); -// isLoadingEmptySessions = signal(false); -// availableSpeakers = signal([]); -// availableEmptySessions = signal([]); -// selectedFormats = signal([]); -// selectedCategories = signal([]); -// selectedSpeakers = signal([]); -// selectedLanguages = signal([]); -// selectedDuration = signal(60); -// -// isFormValid = computed(() => { -// const formValid = this.sessionForm?.valid ?? false; -// const notSubmitting = !this.isSubmitting(); -// -// Object.keys(this.sessionForm?.controls || {}).forEach(key => { -// const control = this.sessionForm.get(key); -// console.log(`Field "${key}":`, { -// value: control?.value, -// valid: control?.valid, -// errors: control?.errors, -// touched: control?.touched, -// dirty: control?.dirty -// }); -// }); -// -// return formValid && notSubmitting; -// }); -// -// sortedAvailableSpeakers = computed(() => -// [...this.availableSpeakers()].sort((a, b) => a.name.localeCompare(b.name)) -// ); -// -// matchingEmptySession = computed(() => { -// const speakers = this.selectedSpeakers(); -// const emptySessions = this.availableEmptySessions(); -// -// if (!speakers.length || !emptySessions.length) { -// return null; -// } -// -// const speakerEmails = speakers.map(s => s.email); -// return emptySessions.find(session => -// session.speakers.some(speaker => speakerEmails.includes(speaker.email)) -// ) || null; -// }); -// -// constructor() { -// effect(() => { -// const emptySession = this.matchingEmptySession(); -// if (emptySession) { -// this.prefillFormFromEmptySession(emptySession); -// } -// }); -// } -// -// ngOnInit(): void { -// this.initializeForm(); -// this.loadAvailableSpeakers(); -// this.loadEmptySessions(); -// this.setDefaultStartDate(); -// } -// -// private initializeForm(): void { -// this.sessionForm = this.fb.group({ -// title: ['', [ -// Validators.required, -// Validators.minLength(3), -// Validators.maxLength(200) -// ]], -// abstractText: ['', [Validators.maxLength(2000)]], -// references: ['', [Validators.maxLength(1000)]], -// level: [''], -// track: ['', [Validators.maxLength(50)]], -// startDate: ['', [ -// Validators.required, -// this.eventDateRangeValidator.bind(this) -// ]], -// startTime: [''] -// }); -// -// this.sessionForm.valueChanges -// .pipe(takeUntilDestroyed(this.destroyRef)) -// .subscribe(() => { -// console.log(' Form changed, triggering validation check'); -// }); -// -// this.sessionForm.statusChanges -// .pipe(takeUntilDestroyed(this.destroyRef)) -// .subscribe(status => { -// console.log('Form status changed:', status); -// }); -// } -// -// onSubmit(): void { -// -// if (!this.sessionForm.valid) { -// this.sessionForm.markAllAsTouched(); -// console.log('Form errors:', this.sessionForm.errors); -// -// Object.keys(this.sessionForm.controls).forEach(key => { -// const control = this.sessionForm.get(key); -// if (control?.invalid) { -// console.error(`Field "${key}" errors:`, control.errors); -// } -// }); -// -// console.groupEnd(); -// return; -// } -// -// this.formSubmission.submit({ -// form: this.sessionForm, -// isSubmitting: this.isSubmitting, -// errorMessage: this.errorMessage, -// buildRequest: () => { -// const request = this.buildCreateRequest(); -// return request; -// }, -// submitRequest: (request) => { -// return this.sessionService.createSession(this.eventId(), request); -// }, -// onSuccess: (response) => { -// this.onSuccess(response); -// }, -// extractError: (error) => { -// return this.errorHandler.extractSessionErrorMessage(error); -// } -// }); -// } -// -// private buildCreateRequest(): SessionCreateRequest { -// const formValue = this.sessionForm.value; -// const selectedFormats = this.requestBuilder.getSelectedFormats( -// this.availableFormats(), -// this.selectedFormats() -// ); -// const selectedCategories = this.requestBuilder.getSelectedCategories( -// this.availableCategories(), -// this.selectedCategories() -// ); -// -// return this.requestBuilder.buildCreateRequest( -// formValue, -// this.eventId(), -// this.selectedDuration(), -// this.selectedLanguages(), -// selectedFormats, -// selectedCategories, -// this.selectedSpeakers() -// ); -// } -// -// private onSuccess(response: SessionImportData): void { -// this.sessionCreated.emit(); -// this.onClose(); -// } -// -// onDurationChange(duration: number): void { -// this.selectedDuration.set(duration); -// } -// -// onSpeakersChange(speakers: Speaker[]): void { -// this.selectedSpeakers.set(speakers); -// } -// -// onFormatsChange(formats: string[]): void { -// this.selectedFormats.set(formats); -// } -// -// onCategoriesChange(categories: string[]): void { -// this.selectedCategories.set(categories); -// } -// -// onLanguagesChange(languages: string[]): void { -// this.selectedLanguages.set(languages); -// } -// -// onClose(): void { -// if (this.isSubmitting()) return; -// this.popupClosed.emit(); -// } -// -// private loadAvailableSpeakers(): void { -// this.isLoadingSpeakers.set(true); -// -// this.speakerService.getSpeakersByEventId(this.eventId()) -// .pipe( -// takeUntilDestroyed(this.destroyRef), -// finalize(() => this.isLoadingSpeakers.set(false)) -// ) -// .subscribe({ -// next: (speakers) => this.availableSpeakers.set(speakers), -// error: (error) => { -// console.error('Error loading speakers:', error); -// this.availableSpeakers.set([]); -// } -// }); -// } -// -// private loadEmptySessions(): void { -// this.isLoadingEmptySessions.set(true); -// -// this.sessionService.getEmptySessionsForEvent(this.eventId()) -// .pipe( -// takeUntilDestroyed(this.destroyRef), -// finalize(() => this.isLoadingEmptySessions.set(false)) -// ) -// .subscribe({ -// next: (sessions) => this.availableEmptySessions.set(sessions), -// error: (error) => { -// console.error('Error loading empty sessions:', error); -// this.availableEmptySessions.set([]); -// } -// }); -// } -// -// private setDefaultStartDate(): void { -// const startDate = this.eventStartDate(); -// if (startDate) { -// const defaultDate = this.dateCalculator.formatDateForInput(startDate); -// this.sessionForm.patchValue({ startDate: defaultDate }); -// } -// } -// -// private eventDateRangeValidator(control: AbstractControl): ValidationErrors | null { -// if (!control.value) { -// return null; -// } -// -// const startDate = this.eventStartDate(); -// const endDate = this.eventEndDate(); -// -// if (!startDate || !endDate) { -// return null; -// } -// -// const selectedDate = new Date(control.value); -// const eventStart = new Date(startDate); -// const eventEnd = new Date(endDate); -// -// eventStart.setHours(0, 0, 0, 0); -// eventEnd.setHours(23, 59, 59, 999); -// selectedDate.setHours(12, 0, 0, 0); -// -// if (selectedDate < eventStart || selectedDate > eventEnd) { -// return { -// eventDateOutOfRange: { -// selectedDate: selectedDate.toISOString().split('T')[0], -// eventStart: eventStart.toISOString().split('T')[0], -// eventEnd: eventEnd.toISOString().split('T')[0] -// } -// }; -// } -// -// return null; -// } -// -// private prefillFormFromEmptySession(session: SessionImportData): void { -// const speaker = session.speakers[0]; -// const currentTitle = this.sessionForm.get('title')?.value; -// -// if (speaker && !currentTitle) { -// this.sessionForm.patchValue({ -// title: `Session by ${speaker.name}` -// }); -// } -// } -// } +import { Component, OnInit, inject, input, output, signal, computed, effect, DestroyRef } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, ReactiveFormsModule } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { finalize } from 'rxjs/operators'; +import {Category, Format, SessionImportData, Speaker} from '../../../type/session/session'; +import {SessionService} from '../../../services/sessions/session.service'; +import {SpeakerService} from '../../../services/speaker/speaker.service'; +import {SessionCreateRequest} from '../../../type/session/session-create'; +import {ModalPopupCreateComponent} from '../../modal/modal-popup-create.component'; +import {SessionFormFieldsComponent} from '../fields/session-form-fields/session-form-fields.component'; +import {HttpErrorHandlerService} from '../../services/create/http-error-handler.service'; +import {SessionDateCalculatorService} from '../../services/create/session-date-calculator.service'; +import {FormSubmissionService} from '../../services/create/form-submission.service'; +import {SessionRequestBuilderService} from '../../services/create/session-request-builder.service'; + +@Component({ + selector: 'app-session-create-popup', + templateUrl: './session-create-popup.component.html', + imports: [ModalPopupCreateComponent, ReactiveFormsModule, SessionFormFieldsComponent], + standalone: true, + providers: [FormSubmissionService, SessionRequestBuilderService] +}) +export class SessionCreatePopupComponent implements OnInit { + eventId = input.required(); + availableFormats = input([]); + availableCategories = input([]); + availableTracks = input([]); + eventStartDate = input(undefined); + eventEndDate = input(undefined); + + sessionCreated = output(); + popupClosed = output(); + + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + private readonly sessionService = inject(SessionService); + private readonly speakerService = inject(SpeakerService); + private readonly formSubmission = inject(FormSubmissionService); + private readonly errorHandler = inject(HttpErrorHandlerService); + private readonly dateCalculator = inject(SessionDateCalculatorService); + private readonly requestBuilder = inject(SessionRequestBuilderService); + + sessionForm!: FormGroup; + isSubmitting = signal(false); + errorMessage = signal(null); + isLoadingSpeakers = signal(false); + isLoadingEmptySessions = signal(false); + availableSpeakers = signal([]); + availableEmptySessions = signal([]); + selectedFormats = signal([]); + selectedCategories = signal([]); + selectedSpeakers = signal([]); + selectedLanguages = signal([]); + selectedDuration = signal(60); + + isFormValid = computed(() => { + const formValid = this.sessionForm?.valid ?? false; + const notSubmitting = !this.isSubmitting(); + + Object.keys(this.sessionForm?.controls || {}).forEach(key => { + const control = this.sessionForm.get(key); + console.log(`Field "${key}":`, { + value: control?.value, + valid: control?.valid, + errors: control?.errors, + touched: control?.touched, + dirty: control?.dirty + }); + }); + + return formValid && notSubmitting; + }); + + sortedAvailableSpeakers = computed(() => + [...this.availableSpeakers()].sort((a, b) => a.name.localeCompare(b.name)) + ); + + matchingEmptySession = computed(() => { + const speakers = this.selectedSpeakers(); + const emptySessions = this.availableEmptySessions(); + + if (!speakers.length || !emptySessions.length) { + return null; + } + + const speakerEmails = speakers.map(s => s.email); + return emptySessions.find(session => + session.speakers.some(speaker => speakerEmails.includes(speaker.email)) + ) || null; + }); + + constructor() { + effect(() => { + const emptySession = this.matchingEmptySession(); + if (emptySession) { + this.prefillFormFromEmptySession(emptySession); + } + }); + } + + ngOnInit(): void { + this.initializeForm(); + this.loadAvailableSpeakers(); + this.loadEmptySessions(); + this.setDefaultStartDate(); + } + + private initializeForm(): void { + this.sessionForm = this.fb.group({ + title: ['', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(200) + ]], + abstractText: ['', [Validators.maxLength(2000)]], + references: ['', [Validators.maxLength(1000)]], + level: [''], + track: ['', [Validators.maxLength(50)]], + startDate: ['', [ + Validators.required, + this.eventDateRangeValidator.bind(this) + ]], + startTime: [''] + }); + + this.sessionForm.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + console.log(' Form changed, triggering validation check'); + }); + + this.sessionForm.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(status => { + console.log('Form status changed:', status); + }); + } + + onSubmit(): void { + + if (!this.sessionForm.valid) { + this.sessionForm.markAllAsTouched(); + console.log('Form errors:', this.sessionForm.errors); + + Object.keys(this.sessionForm.controls).forEach(key => { + const control = this.sessionForm.get(key); + if (control?.invalid) { + console.error(`Field "${key}" errors:`, control.errors); + } + }); + + console.groupEnd(); + return; + } + + this.formSubmission.submit({ + form: this.sessionForm, + isSubmitting: this.isSubmitting, + errorMessage: this.errorMessage, + buildRequest: () => { + const request = this.buildCreateRequest(); + return request; + }, + submitRequest: (request) => { + return this.sessionService.createSession(this.eventId(), request); + }, + onSuccess: (response) => { + this.onSuccess(response); + }, + extractError: (error) => { + return this.errorHandler.extractSessionErrorMessage(error); + } + }); + } + + private buildCreateRequest(): SessionCreateRequest { + const formValue = this.sessionForm.value; + const selectedFormats = this.requestBuilder.getSelectedFormats( + this.availableFormats(), + this.selectedFormats() + ); + const selectedCategories = this.requestBuilder.getSelectedCategories( + this.availableCategories(), + this.selectedCategories() + ); + + return this.requestBuilder.buildCreateRequest( + formValue, + this.eventId(), + this.selectedDuration(), + this.selectedLanguages(), + selectedFormats, + selectedCategories, + this.selectedSpeakers() + ); + } + + private onSuccess(response: SessionImportData): void { + this.sessionCreated.emit(); + this.onClose(); + } + + onDurationChange(duration: number): void { + this.selectedDuration.set(duration); + } + + onSpeakersChange(speakers: Speaker[]): void { + this.selectedSpeakers.set(speakers); + } + + onFormatsChange(formats: string[]): void { + this.selectedFormats.set(formats); + } + + onCategoriesChange(categories: string[]): void { + this.selectedCategories.set(categories); + } + + onLanguagesChange(languages: string[]): void { + this.selectedLanguages.set(languages); + } + + onClose(): void { + if (this.isSubmitting()) return; + this.popupClosed.emit(); + } + + private loadAvailableSpeakers(): void { + this.isLoadingSpeakers.set(true); + + this.speakerService.getSpeakersByEventId(this.eventId()) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isLoadingSpeakers.set(false)) + ) + .subscribe({ + next: (speakers) => this.availableSpeakers.set(speakers), + error: (error) => { + console.error('Error loading speakers:', error); + this.availableSpeakers.set([]); + } + }); + } + + private loadEmptySessions(): void { + this.isLoadingEmptySessions.set(true); + + this.sessionService.getEmptySessionsForEvent(this.eventId()) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isLoadingEmptySessions.set(false)) + ) + .subscribe({ + next: (sessions) => this.availableEmptySessions.set(sessions), + error: (error) => { + console.error('Error loading empty sessions:', error); + this.availableEmptySessions.set([]); + } + }); + } + + private setDefaultStartDate(): void { + const startDate = this.eventStartDate(); + if (startDate) { + const defaultDate = this.dateCalculator.formatDateForInput(startDate); + this.sessionForm.patchValue({ startDate: defaultDate }); + } + } + + private eventDateRangeValidator(control: AbstractControl): ValidationErrors | null { + if (!control.value) { + return null; + } + + const startDate = this.eventStartDate(); + const endDate = this.eventEndDate(); + + if (!startDate || !endDate) { + return null; + } + + const selectedDate = new Date(control.value); + const eventStart = new Date(startDate); + const eventEnd = new Date(endDate); + + eventStart.setHours(0, 0, 0, 0); + eventEnd.setHours(23, 59, 59, 999); + selectedDate.setHours(12, 0, 0, 0); + + if (selectedDate < eventStart || selectedDate > eventEnd) { + return { + eventDateOutOfRange: { + selectedDate: selectedDate.toISOString().split('T')[0], + eventStart: eventStart.toISOString().split('T')[0], + eventEnd: eventEnd.toISOString().split('T')[0] + } + }; + } + + return null; + } + + private prefillFormFromEmptySession(session: SessionImportData): void { + const speaker = session.speakers[0]; + const currentTitle = this.sessionForm.get('title')?.value; + + if (speaker && !currentTitle) { + this.sessionForm.patchValue({ + title: `Session by ${speaker.name}` + }); + } + } +} diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html index 7406fdbe..0ad851cd 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.html @@ -43,14 +43,14 @@ - - - - - - - - + + Create session + } - - - - - - - - - - - - + @if (showCreatePopup()) { + + + } } diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index 6aa55851..f9485f27 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -13,7 +13,7 @@ import { ButtonComponent } from '../../../../../shared/button/button.component'; import { PaginationListComponent } from '../../../components/pagination-list/pagination-list.component'; import { SessionFilterService } from '../../../services/sessions/session-filter.service'; import { SessionFormatterService } from '../../../services/sessions/session-formatter.service'; -// import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; +import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; import { SessionService } from '../../../services/sessions/session.service'; import {BaseListService} from '../../../components/services/list/base-list.service'; @@ -28,7 +28,7 @@ import {BaseListService} from '../../../components/services/list/base-list.servi AsyncPipe, ButtonComponent, PaginationListComponent, - // SessionCreatePopupComponent + SessionCreatePopupComponent ], providers: [BaseListService, SessionFilterService], templateUrl: './session-list-page.component.html', From 142cc07fdd79966d2dbc35c1b1587dd7d8489f7a Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:36:10 +0200 Subject: [PATCH 21/23] proposition track in create session --- .../controller/SessionController.java | 6 +++-- .../speakerspace/service/SessionService.java | 5 ++++ .../src/app/core/sidebar/sidebar.component.ts | 10 ------- .../session-form-fields.component.html | 4 +-- .../session-create-popup.component.html | 2 +- .../session-detail-page.component.ts | 26 ++++++++++++------- .../session-list-page.component.ts | 20 ++++++-------- 7 files changed, 37 insertions(+), 36 deletions(-) diff --git a/back/src/main/java/com/speakerspace/controller/SessionController.java b/back/src/main/java/com/speakerspace/controller/SessionController.java index e1ad6a10..4988f85a 100644 --- a/back/src/main/java/com/speakerspace/controller/SessionController.java +++ b/back/src/main/java/com/speakerspace/controller/SessionController.java @@ -143,8 +143,10 @@ public ResponseEntity> getAvailableTracksForEvent( @PathVariable String eventId, Authentication authentication) throws AccessDeniedException { - return authorizationHelper.executeWithEventAuthorization(eventId, authentication, () -> - sessionService.getDistinctTracksByEventId(eventId)); + authorizationHelper.validateEventAuthorization(eventId, authentication); + List tracks = sessionService.getAvailableTracksForEvent(eventId); + + return ResponseEntity.ok(tracks); } @GetMapping("/event/{eventId}/calendar") diff --git a/back/src/main/java/com/speakerspace/service/SessionService.java b/back/src/main/java/com/speakerspace/service/SessionService.java index 9f5242b0..a2d9f2ff 100644 --- a/back/src/main/java/com/speakerspace/service/SessionService.java +++ b/back/src/main/java/com/speakerspace/service/SessionService.java @@ -360,6 +360,11 @@ public void validateCreateRequest(SessionCreateRequestDTO request) { } } + public List getAvailableTracksForEvent(String eventId) { + List tracks = sessionRepository.findDistinctTracksByEventId(eventId); + return tracks; + } + private String generateSessionId() { return UUID.randomUUID().toString().replace("-", "").substring(0, 16); } diff --git a/front/src/app/core/sidebar/sidebar.component.ts b/front/src/app/core/sidebar/sidebar.component.ts index 51d06a48..c48d314f 100644 --- a/front/src/app/core/sidebar/sidebar.component.ts +++ b/front/src/app/core/sidebar/sidebar.component.ts @@ -51,16 +51,6 @@ export class SidebarComponent implements OnInit { return teamsData !== null && teamsData.length > 0; }); - constructor() { - effect(() => { - console.log('Teams state:', { - teams: this.teams(), - isLoading: this.isLoadingTeams(), - hasTeams: this.hasTeams() - }); - }); - } - ngOnInit(): void { this.teamService.loadUserTeams(); } diff --git a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html index 7701add2..be5834b4 100644 --- a/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html +++ b/front/src/app/feature/admin-management/components/session/fields/session-form-fields/session-form-fields.component.html @@ -45,10 +45,10 @@ + [placeholder]="availableTracks().length > 0 ? 'Select track...' : 'Enter track name'"> + class="space-y-6 m-4"> @if (errorMessage()) {
(null); readonly category = signal(null); readonly availableTracks = signal([]); + readonly eventStartDate = signal(undefined); + readonly eventEndDate = signal(undefined); readonly hasSessionData = computed(() => !!this.session()); readonly canEditSchedule = computed(() => @@ -79,15 +81,21 @@ export class SessionDetailPageComponent implements OnInit { this.sessionId.set(sessionId); try { - const [session, tracks] = await Promise.all([ + const [session, tracks, event] = await Promise.all([ this.sessionService.getSessionById(eventId, sessionId).toPromise(), - this.sessionService.getAvailableTracksForEvent(eventId).toPromise() + this.sessionService.getAvailableTracksForEvent(eventId).toPromise(), + this.eventService.getEventById(eventId).toPromise() ]); this.session.set(session!); this.availableTracks.set(tracks || []); this.format.set(session!.formats?.[0] || null); this.category.set(session!.categories?.[0] || null); + + if (event) { + this.eventStartDate.set(event.startDate ? new Date(event.startDate) : undefined); + this.eventEndDate.set(event.endDate ? new Date(event.endDate) : undefined); + } } catch (error) { this.detailService.updateState({ error: 'Failed to load session data' }); throw error; diff --git a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts index f9485f27..d0b45227 100644 --- a/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts +++ b/front/src/app/feature/admin-management/pages/session/session-list-page/session-list-page.component.ts @@ -1,6 +1,6 @@ import { Component, input, OnInit, inject, signal, computed } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import {finalize, forkJoin} from 'rxjs'; +import { finalize, forkJoin } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AsyncPipe } from '@angular/common'; import { NavbarEventPageComponent } from '../../../components/event/navbar-event-page/navbar-event-page.component'; @@ -15,7 +15,7 @@ import { SessionFilterService } from '../../../services/sessions/session-filter. import { SessionFormatterService } from '../../../services/sessions/session-formatter.service'; import { SessionCreatePopupComponent } from '../../../components/session/session-create-popup/session-create-popup.component'; import { SessionService } from '../../../services/sessions/session.service'; -import {BaseListService} from '../../../components/services/list/base-list.service'; +import { BaseListService } from '../../../components/services/list/base-list.service'; @Component({ selector: 'app-session-list-page', @@ -88,25 +88,22 @@ export class SessionListPageComponent implements OnInit { return new Promise((resolve, reject) => { forkJoin({ event: this.eventService.getEventById(currentState.eventId), - sessions: this.eventService.getSessionsByEventId(currentState.eventId) + sessions: this.eventService.getSessionsByEventId(currentState.eventId), + tracks: this.sessionService.getAvailableTracksForEvent(currentState.eventId) }) .pipe( finalize(() => this.listService.updateState({ isLoadingItems: false })), takeUntilDestroyed(this.listService['destroyRef']) ) .subscribe({ - next: ({ event, sessions }) => { - console.log('📅 Event loaded:', { - id: event.idEvent, - startDate: event.startDate, - endDate: event.endDate - }); + next: ({ event, sessions, tracks }) => { const startDate = event.startDate ? new Date(event.startDate) : undefined; const endDate = event.endDate ? new Date(event.endDate) : undefined; this.eventStartDate.set(startDate); this.eventEndDate.set(endDate); + this.availableTracks.set(tracks); const sortedSessions = this.formatterService.sortByTitle(sessions); this.listService.updateItems(sortedSessions); @@ -115,10 +112,12 @@ export class SessionListPageComponent implements OnInit { resolve(); }, error: (error) => { + console.error('Error loading data:', error); this.listService.updateState({ error: 'Failed to load sessions. Please try again.' }); this.listService.updateItems([]); + this.availableTracks.set([]); reject(error); } }); @@ -199,9 +198,6 @@ export class SessionListPageComponent implements OnInit { } onCreateSession(): void { - const startDate = this.eventStartDate(); - const endDate = this.eventEndDate(); - this.showCreatePopup.set(true); } From 37b16e72353e38c61fc8920afc2e199cf5803ebd Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:34:15 +0200 Subject: [PATCH 22/23] resolve abstractText default for create session --- .../com/speakerspace/dto/session/SessionCreateRequestDTO.java | 1 - 1 file changed, 1 deletion(-) diff --git a/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java index ce129072..6598f52a 100644 --- a/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java +++ b/back/src/main/java/com/speakerspace/dto/session/SessionCreateRequestDTO.java @@ -15,7 +15,6 @@ public record SessionCreateRequestDTO( String title, @Size(max = 2000, message = "Abstract must not exceed 2000 characters") - @JsonProperty("abstract") String abstractText, @Size(max = 1000, message = "References must not exceed 1000 characters") From 15245a77a08027261b3687eea546a04b2eb8d756 Mon Sep 17 00:00:00 2001 From: MarineGiroux <85103261+MarineGiroux@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:04:29 +0200 Subject: [PATCH 23/23] create services for and event information and create session --- .../information-event.component.html | 6 +- .../information-event.component.ts | 232 +++----------- .../session-create-popup.component.html | 24 +- .../session-create-popup.component.ts | 299 +++--------------- .../event/event-form-fields.service.spec.ts | 16 + .../event/event-form-fields.service.ts | 22 ++ .../services/event/event-form.service.ts | 108 +++++++ .../session-create-state.service.spec.ts | 16 + .../sessions/session-create-state.service.ts | 235 ++++++++++++++ 9 files changed, 499 insertions(+), 459 deletions(-) create mode 100644 front/src/app/feature/admin-management/services/event/event-form-fields.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/event/event-form-fields.service.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-create-state.service.spec.ts create mode 100644 front/src/app/feature/admin-management/services/sessions/session-create-state.service.ts diff --git a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html index fe2ff6ad..c81eb066 100644 --- a/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html +++ b/front/src/app/feature/admin-management/components/event/information-event/information-event.component.html @@ -1,5 +1,5 @@
- @if (showAutoSaveIndicator) { + @if (showAutoSaveIndicator()) { @@ -86,11 +86,11 @@
- @if (showNavigationButtons) { + @if (showNavigationButtons()) {
} - @if (showNavigationButtons) { + @if (showNavigationButtons()) {
('create'); - initialData = input | null>(null); - - formSubmitted = output(); - doItLater = output(); - - isSubmitted = signal(false); - eventId = signal(''); - teamId = signal(null); - teamUrl = signal(null); - eventName = signal(''); - currentEvent = signal({} as EventDTO); - saveStatus = signal('idle'); + private readonly eventFormService = inject(EventFormService); + private readonly autoSaveService = inject(AutoSaveService); + private readonly eventService = inject(EventService); + private readonly eventDataService = inject(EventDataService); + private readonly snackBar = inject(MatSnackBar); + private readonly teamService = inject(TeamService); + + readonly mode = input<'create' | 'edit'>('create'); + readonly initialData = input | null>(null); + readonly formSubmitted = output>(); + readonly doItLater = output(); + + readonly isSubmitted = signal(false); + readonly eventId = signal(''); + readonly teamId = signal(null); + readonly teamUrl = signal(null); + readonly eventName = signal(''); + readonly currentEvent = signal({} as EventDTO); + readonly saveStatus = signal('idle'); + + readonly formFields = EVENT_FORM_FIELDS; + readonly additionalFields = EVENT_ADDITIONAL_FIELDS; + + readonly showNavigationButtons = (): boolean => this.mode() === 'create'; + readonly showAutoSaveIndicator = (): boolean => this.mode() === 'edit'; form!: FormGroup; private autoSaveDestroy$ = new Subject(); private subscriptions = new Subscription(); - constructor( - private fb: FormBuilder, - private autoSaveService: AutoSaveService, - private eventService: EventService, - private eventDataService: EventDataService, - private snackBar: MatSnackBar, - private teamService: TeamService - ) { - this.initializeForm(); - } - ngOnInit(): void { + this.form = this.eventFormService.createEventForm(); this.setupSubscriptions(); if (this.initialData() && this.mode() === 'edit') { - this.loadInitialData(this.initialData()!); + this.eventFormService.loadFormData(this.form, this.initialData()!); this.setupAutoSave(); } } @@ -73,15 +77,11 @@ export class InformationEventComponent implements OnInit, OnDestroy { private setupSubscriptions(): void { this.subscriptions.add( - this.eventDataService.eventId$.subscribe(id => { - this.eventId.set(id); - }) + this.eventDataService.eventId$.subscribe(id => this.eventId.set(id)) ); this.subscriptions.add( - this.eventDataService.eventName$.subscribe(name => { - this.eventName.set(name); - }) + this.eventDataService.eventName$.subscribe(name => this.eventName.set(name)) ); this.subscriptions.add( @@ -101,7 +101,7 @@ export class InformationEventComponent implements OnInit, OnDestroy { } }); } - this.loadInitialData(event); + this.eventFormService.loadFormData(this.form, event); } private setupAutoSave(): void { @@ -113,7 +113,7 @@ export class InformationEventComponent implements OnInit, OnDestroy { this.form, (data: Partial) => this.eventService.updateEvent(data), { - extractValidFields: () => this.extractValidEventData(), + extractValidFields: () => this.eventFormService.extractValidEventData(this.form, this.initialData()), onSaveStart: () => { this.form.markAsPristine(); this.saveStatus.set('saving'); @@ -122,7 +122,7 @@ export class InformationEventComponent implements OnInit, OnDestroy { console.log('Event information auto-saved successfully:', result); this.saveStatus.set('saved'); }, - onSaveError: (error: any) => { + onSaveError: (error: unknown) => { console.error('Auto-save failed:', error); this.saveStatus.set('error'); this.snackBar.open('Erreur lors de la sauvegarde automatique', 'Fermer', { @@ -137,40 +137,6 @@ export class InformationEventComponent implements OnInit, OnDestroy { this.autoSaveDestroy$ = destroy$; } - private extractValidEventData(): Partial { - const formValue = this.form.value; - const initialData = this.initialData(); - const data: Partial = { - idEvent: initialData?.idEvent - }; - - if (formValue.startDate !== undefined && formValue.startDate !== this.formatDateForInput(initialData?.startDate)) { - data.startDate = formValue.startDate ? new Date(formValue.startDate).toISOString() : undefined; - } - - if (formValue.endDate !== undefined && formValue.endDate !== this.formatDateForInput(initialData?.endDate)) { - data.endDate = formValue.endDate ? new Date(formValue.endDate).toISOString() : undefined; - } - - if (formValue.venueLocation !== initialData?.location) { - data.location = formValue.venueLocation; - } - - if (formValue.description !== initialData?.description) { - data.description = formValue.description; - } - - if (formValue.isOnline !== initialData?.isOnline) { - data.isOnline = formValue.isOnline; - } - - if (formValue.webLinkUrl !== initialData?.webLinkUrl) { - data.webLinkUrl = formValue.webLinkUrl; - } - - return data; - } - async onSubmit(): Promise { if (this.mode() === 'edit') { return; @@ -178,31 +144,14 @@ export class InformationEventComponent implements OnInit, OnDestroy { this.isSubmitted.set(true); - if (this.form.invalid || !this.validateDates()) { + if (this.form.invalid || !this.eventFormService.validateDates(this.form)) { return; } - const formValues = this.form.value; - const formData = { - startDate: formValues.startDate ? new Date(formValues.startDate).toISOString() : undefined, - endDate: formValues.endDate ? new Date(formValues.endDate).toISOString() : undefined, - location: formValues.venueLocation, - description: formValues.description, - isOnline: formValues.isOnline, - webLinkUrl: formValues.webLinkUrl, - }; - + const formData = this.eventFormService.prepareSubmitData(this.form); this.formSubmitted.emit(formData); } - get showNavigationButtons(): boolean { - return this.mode() === 'create'; - } - - get showAutoSaveIndicator(): boolean { - return this.mode() === 'edit'; - } - onGoBack(): void { this.doItLater.emit(); } @@ -210,85 +159,4 @@ export class InformationEventComponent implements OnInit, OnDestroy { getFormControl(name: string): FormControl { return this.form.get(name) as FormControl; } - - private initializeForm(): void { - this.form = this.fb.group({ - startDate: ['', Validators.required], - endDate: ['', Validators.required], - isOnline: [false], - venueLocation: [''], - description: [''], - webLinkUrl: [''] - }); - - this.form.get('isOnline')?.valueChanges.subscribe(isOnline => { - const venueLocationControl = this.form.get('venueLocation'); - if (!isOnline) { - venueLocationControl?.setValidators([Validators.required]); - } else { - venueLocationControl?.clearValidators(); - } - venueLocationControl?.updateValueAndValidity(); - }); - } - - private loadInitialData(data: Partial): void { - if (data.startDate) { - this.form.get('startDate')?.setValue(this.formatDateForInput(data.startDate)); - } - if (data.endDate) { - this.form.get('endDate')?.setValue(this.formatDateForInput(data.endDate)); - } - this.form.get('isOnline')?.setValue(data.isOnline === true); - if (data.webLinkUrl) { - this.form.get('webLinkUrl')?.setValue(data.webLinkUrl); - } - if (data.location) { - this.form.get('venueLocation')?.setValue(data.location); - } - if (data.description) { - this.form.get('description')?.setValue(data.description); - } - } - - validateDates(): boolean { - const startDate = this.form.value.startDate ? new Date(this.form.value.startDate) : null; - const endDate = this.form.value.endDate ? new Date(this.form.value.endDate) : null; - - this.form.get('endDate')?.setErrors(null); - - if (startDate && endDate && endDate <= startDate) { - this.form.get('endDate')?.setErrors({'endBeforeStart': true}); - return false; - } - - return true; - } - - private formatDateForInput(date: Date | string | undefined): string { - if (!date) return ''; - const d = new Date(date); - return d.toISOString().split('T')[0]; - } - - formFields: FormField[] = [ - { - name: 'startDate', - label: 'Start date', - type: 'date', - required: true, - }, - { - name: 'endDate', - label: 'End date', - type: 'date', - required: true, - } - ]; - - additionalFields: FormField[] = [ - {name: 'webLinkUrl', label: 'Event web link', type: 'text'}, - {name: 'venueLocation', label: 'Venue location (address, city, country)', type: 'text'}, - {name: 'description', label: 'Description', type: 'textarea'} - ]; } diff --git a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html index f732885e..21718f88 100644 --- a/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html +++ b/front/src/app/feature/admin-management/components/session/session-create-popup/session-create-popup.component.html @@ -2,38 +2,38 @@ title="Create New Session" submitText="Create Session" submittingText="Creating..." - [isSubmitting]="isSubmitting()" + [isSubmitting]="state.isSubmitting()" (closed)="onClose()" (submitted)="onSubmit()">
- @if (errorMessage()) { + @if (state.errorMessage()) { } (); @@ -31,283 +33,56 @@ export class SessionCreatePopupComponent implements OnInit { sessionCreated = output(); popupClosed = output(); - private readonly fb = inject(FormBuilder); - private readonly destroyRef = inject(DestroyRef); - private readonly sessionService = inject(SessionService); - private readonly speakerService = inject(SpeakerService); - private readonly formSubmission = inject(FormSubmissionService); - private readonly errorHandler = inject(HttpErrorHandlerService); - private readonly dateCalculator = inject(SessionDateCalculatorService); - private readonly requestBuilder = inject(SessionRequestBuilderService); - - sessionForm!: FormGroup; - isSubmitting = signal(false); - errorMessage = signal(null); - isLoadingSpeakers = signal(false); - isLoadingEmptySessions = signal(false); - availableSpeakers = signal([]); - availableEmptySessions = signal([]); - selectedFormats = signal([]); - selectedCategories = signal([]); - selectedSpeakers = signal([]); - selectedLanguages = signal([]); - selectedDuration = signal(60); - - isFormValid = computed(() => { - const formValid = this.sessionForm?.valid ?? false; - const notSubmitting = !this.isSubmitting(); - - Object.keys(this.sessionForm?.controls || {}).forEach(key => { - const control = this.sessionForm.get(key); - console.log(`Field "${key}":`, { - value: control?.value, - valid: control?.valid, - errors: control?.errors, - touched: control?.touched, - dirty: control?.dirty - }); - }); - - return formValid && notSubmitting; - }); - - sortedAvailableSpeakers = computed(() => - [...this.availableSpeakers()].sort((a, b) => a.name.localeCompare(b.name)) - ); - - matchingEmptySession = computed(() => { - const speakers = this.selectedSpeakers(); - const emptySessions = this.availableEmptySessions(); - - if (!speakers.length || !emptySessions.length) { - return null; - } - - const speakerEmails = speakers.map(s => s.email); - return emptySessions.find(session => - session.speakers.some(speaker => speakerEmails.includes(speaker.email)) - ) || null; - }); + protected readonly state = inject(SessionCreateStateService); constructor() { effect(() => { - const emptySession = this.matchingEmptySession(); + const emptySession = this.state.matchingEmptySession(); if (emptySession) { - this.prefillFormFromEmptySession(emptySession); + this.state.prefillFormFromEmptySession(emptySession); } }); } ngOnInit(): void { - this.initializeForm(); - this.loadAvailableSpeakers(); - this.loadEmptySessions(); - this.setDefaultStartDate(); - } - - private initializeForm(): void { - this.sessionForm = this.fb.group({ - title: ['', [ - Validators.required, - Validators.minLength(3), - Validators.maxLength(200) - ]], - abstractText: ['', [Validators.maxLength(2000)]], - references: ['', [Validators.maxLength(1000)]], - level: [''], - track: ['', [Validators.maxLength(50)]], - startDate: ['', [ - Validators.required, - this.eventDateRangeValidator.bind(this) - ]], - startTime: [''] + this.state.initialize({ + eventId: this.eventId(), + availableFormats: this.availableFormats(), + availableCategories: this.availableCategories(), + eventStartDate: this.eventStartDate(), + eventEndDate: this.eventEndDate() }); - - this.sessionForm.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - console.log(' Form changed, triggering validation check'); - }); - - this.sessionForm.statusChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(status => { - console.log('Form status changed:', status); - }); } onSubmit(): void { - - if (!this.sessionForm.valid) { - this.sessionForm.markAllAsTouched(); - console.log('Form errors:', this.sessionForm.errors); - - Object.keys(this.sessionForm.controls).forEach(key => { - const control = this.sessionForm.get(key); - if (control?.invalid) { - console.error(`Field "${key}" errors:`, control.errors); - } - }); - - console.groupEnd(); - return; - } - - this.formSubmission.submit({ - form: this.sessionForm, - isSubmitting: this.isSubmitting, - errorMessage: this.errorMessage, - buildRequest: () => { - const request = this.buildCreateRequest(); - return request; - }, - submitRequest: (request) => { - return this.sessionService.createSession(this.eventId(), request); - }, - onSuccess: (response) => { - this.onSuccess(response); - }, - extractError: (error) => { - return this.errorHandler.extractSessionErrorMessage(error); - } + this.state.submit((response: SessionImportData) => { + this.sessionCreated.emit(); + this.onClose(); }); } - private buildCreateRequest(): SessionCreateRequest { - const formValue = this.sessionForm.value; - const selectedFormats = this.requestBuilder.getSelectedFormats( - this.availableFormats(), - this.selectedFormats() - ); - const selectedCategories = this.requestBuilder.getSelectedCategories( - this.availableCategories(), - this.selectedCategories() - ); - - return this.requestBuilder.buildCreateRequest( - formValue, - this.eventId(), - this.selectedDuration(), - this.selectedLanguages(), - selectedFormats, - selectedCategories, - this.selectedSpeakers() - ); - } - - private onSuccess(response: SessionImportData): void { - this.sessionCreated.emit(); - this.onClose(); + onClose(): void { + if (this.state.isSubmitting()) return; + this.popupClosed.emit(); } onDurationChange(duration: number): void { - this.selectedDuration.set(duration); + this.state.selectedDuration.set(duration); } onSpeakersChange(speakers: Speaker[]): void { - this.selectedSpeakers.set(speakers); + this.state.selectedSpeakers.set(speakers); } onFormatsChange(formats: string[]): void { - this.selectedFormats.set(formats); + this.state.selectedFormats.set(formats); } onCategoriesChange(categories: string[]): void { - this.selectedCategories.set(categories); + this.state.selectedCategories.set(categories); } onLanguagesChange(languages: string[]): void { - this.selectedLanguages.set(languages); - } - - onClose(): void { - if (this.isSubmitting()) return; - this.popupClosed.emit(); - } - - private loadAvailableSpeakers(): void { - this.isLoadingSpeakers.set(true); - - this.speakerService.getSpeakersByEventId(this.eventId()) - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.isLoadingSpeakers.set(false)) - ) - .subscribe({ - next: (speakers) => this.availableSpeakers.set(speakers), - error: (error) => { - console.error('Error loading speakers:', error); - this.availableSpeakers.set([]); - } - }); - } - - private loadEmptySessions(): void { - this.isLoadingEmptySessions.set(true); - - this.sessionService.getEmptySessionsForEvent(this.eventId()) - .pipe( - takeUntilDestroyed(this.destroyRef), - finalize(() => this.isLoadingEmptySessions.set(false)) - ) - .subscribe({ - next: (sessions) => this.availableEmptySessions.set(sessions), - error: (error) => { - console.error('Error loading empty sessions:', error); - this.availableEmptySessions.set([]); - } - }); - } - - private setDefaultStartDate(): void { - const startDate = this.eventStartDate(); - if (startDate) { - const defaultDate = this.dateCalculator.formatDateForInput(startDate); - this.sessionForm.patchValue({ startDate: defaultDate }); - } - } - - private eventDateRangeValidator(control: AbstractControl): ValidationErrors | null { - if (!control.value) { - return null; - } - - const startDate = this.eventStartDate(); - const endDate = this.eventEndDate(); - - if (!startDate || !endDate) { - return null; - } - - const selectedDate = new Date(control.value); - const eventStart = new Date(startDate); - const eventEnd = new Date(endDate); - - eventStart.setHours(0, 0, 0, 0); - eventEnd.setHours(23, 59, 59, 999); - selectedDate.setHours(12, 0, 0, 0); - - if (selectedDate < eventStart || selectedDate > eventEnd) { - return { - eventDateOutOfRange: { - selectedDate: selectedDate.toISOString().split('T')[0], - eventStart: eventStart.toISOString().split('T')[0], - eventEnd: eventEnd.toISOString().split('T')[0] - } - }; - } - - return null; - } - - private prefillFormFromEmptySession(session: SessionImportData): void { - const speaker = session.speakers[0]; - const currentTitle = this.sessionForm.get('title')?.value; - - if (speaker && !currentTitle) { - this.sessionForm.patchValue({ - title: `Session by ${speaker.name}` - }); - } + this.state.selectedLanguages.set(languages); } } diff --git a/front/src/app/feature/admin-management/services/event/event-form-fields.service.spec.ts b/front/src/app/feature/admin-management/services/event/event-form-fields.service.spec.ts new file mode 100644 index 00000000..f770403c --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form-fields.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EventFormFieldsService } from './event-form-fields.service'; + +describe('EventFormFieldsService', () => { + let service: EventFormFieldsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EventFormFieldsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/event/event-form-fields.service.ts b/front/src/app/feature/admin-management/services/event/event-form-fields.service.ts new file mode 100644 index 00000000..b70d8597 --- /dev/null +++ b/front/src/app/feature/admin-management/services/event/event-form-fields.service.ts @@ -0,0 +1,22 @@ +import { FormField } from '../../../../shared/input/interface/form-field'; + +export const EVENT_FORM_FIELDS: FormField[] = [ + { + name: 'startDate', + label: 'Start date', + type: 'date', + required: true, + }, + { + name: 'endDate', + label: 'End date', + type: 'date', + required: true, + } +]; + +export const EVENT_ADDITIONAL_FIELDS: FormField[] = [ + { name: 'webLinkUrl', label: 'Event web link', type: 'text' }, + { name: 'venueLocation', label: 'Venue location (address, city, country)', type: 'text' }, + { name: 'description', label: 'Description', type: 'textarea' } +]; diff --git a/front/src/app/feature/admin-management/services/event/event-form.service.ts b/front/src/app/feature/admin-management/services/event/event-form.service.ts index 7475606d..516935f8 100644 --- a/front/src/app/feature/admin-management/services/event/event-form.service.ts +++ b/front/src/app/feature/admin-management/services/event/event-form.service.ts @@ -5,6 +5,7 @@ import { ActivatedRoute } from '@angular/router'; import {EventDataService} from './event-data.service'; import {FormConfig} from '../../type/event/form-config'; import {environment} from '../../../../../environments/environment.development'; +import {EventDTO} from '../../type/event/eventDTO'; @Injectable() export class EventFormService { @@ -91,4 +92,111 @@ export class EventFormService { .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-'); } + + createEventForm(): FormGroup { + const form = this.fb.group({ + startDate: ['', Validators.required], + endDate: ['', Validators.required], + isOnline: [false], + venueLocation: [''], + description: [''], + webLinkUrl: [''] + }); + + form.get('isOnline')?.valueChanges.subscribe(isOnline => { + const venueLocationControl = form.get('venueLocation'); + if (!isOnline) { + venueLocationControl?.setValidators([Validators.required]); + } else { + venueLocationControl?.clearValidators(); + } + venueLocationControl?.updateValueAndValidity(); + }); + + return form; + } + + loadFormData(form: FormGroup, data: Partial): void { + if (data.startDate) { + form.get('startDate')?.setValue(this.formatDateForInput(data.startDate)); + } + if (data.endDate) { + form.get('endDate')?.setValue(this.formatDateForInput(data.endDate)); + } + form.get('isOnline')?.setValue(data.isOnline === true); + if (data.webLinkUrl) { + form.get('webLinkUrl')?.setValue(data.webLinkUrl); + } + if (data.location) { + form.get('venueLocation')?.setValue(data.location); + } + if (data.description) { + form.get('description')?.setValue(data.description); + } + } + + validateDates(form: FormGroup): boolean { + const startDate = form.value.startDate ? new Date(form.value.startDate) : null; + const endDate = form.value.endDate ? new Date(form.value.endDate) : null; + + form.get('endDate')?.setErrors(null); + + if (startDate && endDate && endDate <= startDate) { + form.get('endDate')?.setErrors({ 'endBeforeStart': true }); + return false; + } + + return true; + } + + extractValidEventData(form: FormGroup, initialData: Partial | null): Partial { + const formValue = form.value; + const data: Partial = { + idEvent: initialData?.idEvent + }; + + if (formValue.startDate !== undefined && formValue.startDate !== this.formatDateForInput(initialData?.startDate)) { + data.startDate = formValue.startDate ? new Date(formValue.startDate).toISOString() : undefined; + } + + if (formValue.endDate !== undefined && formValue.endDate !== this.formatDateForInput(initialData?.endDate)) { + data.endDate = formValue.endDate ? new Date(formValue.endDate).toISOString() : undefined; + } + + if (formValue.venueLocation !== initialData?.location) { + data.location = formValue.venueLocation; + } + + if (formValue.description !== initialData?.description) { + data.description = formValue.description; + } + + if (formValue.isOnline !== initialData?.isOnline) { + data.isOnline = formValue.isOnline; + } + + if (formValue.webLinkUrl !== initialData?.webLinkUrl) { + data.webLinkUrl = formValue.webLinkUrl; + } + + return data; + } + + prepareSubmitData(form: FormGroup): Partial { + const formValues = form.value; + return { + startDate: formValues.startDate ? new Date(formValues.startDate).toISOString() : undefined, + endDate: formValues.endDate ? new Date(formValues.endDate).toISOString() : undefined, + location: formValues.venueLocation, + description: formValues.description, + isOnline: formValues.isOnline, + webLinkUrl: formValues.webLinkUrl, + }; + } + + private formatDateForInput(date: Date | string | undefined): string { + if (!date) return ''; + const d = new Date(date); + return d.toISOString().split('T')[0]; + } } diff --git a/front/src/app/feature/admin-management/services/sessions/session-create-state.service.spec.ts b/front/src/app/feature/admin-management/services/sessions/session-create-state.service.spec.ts new file mode 100644 index 00000000..7933c091 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-create-state.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SessionCreateStateService } from './session-create-state.service'; + +describe('SessionCreateStateService', () => { + let service: SessionCreateStateService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionCreateStateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front/src/app/feature/admin-management/services/sessions/session-create-state.service.ts b/front/src/app/feature/admin-management/services/sessions/session-create-state.service.ts new file mode 100644 index 00000000..bba3e484 --- /dev/null +++ b/front/src/app/feature/admin-management/services/sessions/session-create-state.service.ts @@ -0,0 +1,235 @@ +import { Injectable, inject, signal, computed, DestroyRef } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { finalize } from 'rxjs/operators'; +import {SessionService} from './session.service'; +import {SpeakerService} from '../speaker/speaker.service'; +import {FormSubmissionService} from '../../components/services/create/form-submission.service'; +import {SessionCreateRequest} from '../../type/session/session-create'; +import {Category, Format, SessionImportData, Speaker} from '../../type/session/session'; +import {HttpErrorHandlerService} from '../../components/services/create/http-error-handler.service'; +import {SessionDateCalculatorService} from '../../components/services/create/session-date-calculator.service'; +import {SessionRequestBuilderService} from '../../components/services/create/session-request-builder.service'; + +@Injectable() +export class SessionCreateStateService { + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + private readonly sessionService = inject(SessionService); + private readonly speakerService = inject(SpeakerService); + private readonly formSubmission = inject(FormSubmissionService); + private readonly errorHandler = inject(HttpErrorHandlerService); + private readonly dateCalculator = inject(SessionDateCalculatorService); + private readonly requestBuilder = inject(SessionRequestBuilderService); + + sessionForm!: FormGroup; + + isSubmitting = signal(false); + errorMessage = signal(null); + isLoadingSpeakers = signal(false); + isLoadingEmptySessions = signal(false); + availableSpeakers = signal([]); + availableEmptySessions = signal([]); + selectedFormats = signal([]); + selectedCategories = signal([]); + selectedSpeakers = signal([]); + selectedLanguages = signal([]); + selectedDuration = signal(60); + + sortedAvailableSpeakers = computed(() => + [...this.availableSpeakers()].sort((a, b) => a.name.localeCompare(b.name)) + ); + + matchingEmptySession = computed(() => { + const speakers = this.selectedSpeakers(); + const emptySessions = this.availableEmptySessions(); + + if (!speakers.length || !emptySessions.length) return null; + + const speakerEmails = speakers.map(s => s.email); + return emptySessions.find(session => + session.speakers.some(speaker => speakerEmails.includes(speaker.email)) + ) || null; + }); + + private eventConfig = { + eventId: '', + availableFormats: [] as Format[], + availableCategories: [] as Category[], + eventStartDate: undefined as Date | undefined, + eventEndDate: undefined as Date | undefined + }; + + initialize(config: { + eventId: string; + availableFormats: Format[]; + availableCategories: Category[]; + eventStartDate: Date | undefined; + eventEndDate: Date | undefined; + }): void { + this.eventConfig = config; + this.initializeForm(); + this.loadAvailableSpeakers(); + this.loadEmptySessions(); + this.setDefaultStartDate(); + } + + private initializeForm(): void { + this.sessionForm = this.fb.group({ + title: ['', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(200) + ]], + abstractText: ['', [Validators.maxLength(2000)]], + references: ['', [Validators.maxLength(1000)]], + level: [''], + track: ['', [Validators.maxLength(50)]], + startDate: ['', [ + Validators.required, + this.eventDateRangeValidator.bind(this) + ]], + startTime: [''] + }); + + this.sessionForm.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => console.log('Form changed')); + + this.sessionForm.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(status => console.log('Form status:', status)); + } + + submit(onSuccess: (response: SessionImportData) => void): void { + if (!this.sessionForm.valid) { + this.sessionForm.markAllAsTouched(); + this.logFormErrors(); + return; + } + + this.formSubmission.submit({ + form: this.sessionForm, + isSubmitting: this.isSubmitting, + errorMessage: this.errorMessage, + buildRequest: () => this.buildCreateRequest(), + submitRequest: (request) => + this.sessionService.createSession(this.eventConfig.eventId, request), + onSuccess, + extractError: (error) => + this.errorHandler.extractSessionErrorMessage(error) + }); + } + + private buildCreateRequest(): SessionCreateRequest { + const formValue = this.sessionForm.value; + const selectedFormats = this.requestBuilder.getSelectedFormats( + this.eventConfig.availableFormats, + this.selectedFormats() + ); + const selectedCategories = this.requestBuilder.getSelectedCategories( + this.eventConfig.availableCategories, + this.selectedCategories() + ); + + return this.requestBuilder.buildCreateRequest( + formValue, + this.eventConfig.eventId, + this.selectedDuration(), + this.selectedLanguages(), + selectedFormats, + selectedCategories, + this.selectedSpeakers() + ); + } + + private loadAvailableSpeakers(): void { + this.isLoadingSpeakers.set(true); + + this.speakerService.getSpeakersByEventId(this.eventConfig.eventId) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isLoadingSpeakers.set(false)) + ) + .subscribe({ + next: (speakers) => this.availableSpeakers.set(speakers), + error: (error) => { + console.error('Error loading speakers:', error); + this.availableSpeakers.set([]); + } + }); + } + + private loadEmptySessions(): void { + this.isLoadingEmptySessions.set(true); + + this.sessionService.getEmptySessionsForEvent(this.eventConfig.eventId) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isLoadingEmptySessions.set(false)) + ) + .subscribe({ + next: (sessions) => this.availableEmptySessions.set(sessions), + error: (error) => { + console.error('Error loading empty sessions:', error); + this.availableEmptySessions.set([]); + } + }); + } + + private setDefaultStartDate(): void { + const startDate = this.eventConfig.eventStartDate; + if (startDate) { + const defaultDate = this.dateCalculator.formatDateForInput(startDate); + this.sessionForm.patchValue({ startDate: defaultDate }); + } + } + + private eventDateRangeValidator(control: AbstractControl): ValidationErrors | null { + if (!control.value) return null; + + const { eventStartDate, eventEndDate } = this.eventConfig; + if (!eventStartDate || !eventEndDate) return null; + + const selectedDate = new Date(control.value); + const eventStart = new Date(eventStartDate); + const eventEnd = new Date(eventEndDate); + + eventStart.setHours(0, 0, 0, 0); + eventEnd.setHours(23, 59, 59, 999); + selectedDate.setHours(12, 0, 0, 0); + + if (selectedDate < eventStart || selectedDate > eventEnd) { + return { + eventDateOutOfRange: { + selectedDate: selectedDate.toISOString().split('T')[0], + eventStart: eventStart.toISOString().split('T')[0], + eventEnd: eventEnd.toISOString().split('T')[0] + } + }; + } + + return null; + } + + prefillFormFromEmptySession(session: SessionImportData): void { + const speaker = session.speakers[0]; + const currentTitle = this.sessionForm.get('title')?.value; + + if (speaker && !currentTitle) { + this.sessionForm.patchValue({ + title: `Session by ${speaker.name}` + }); + } + } + + private logFormErrors(): void { + console.log('Form errors:', this.sessionForm.errors); + Object.keys(this.sessionForm.controls).forEach(key => { + const control = this.sessionForm.get(key); + if (control?.invalid) { + console.error(`Field "${key}" errors:`, control.errors); + } + }); + } +}