Add scouthub-home-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:40:51 +02:00
parent 566754f825
commit c34bca869f
49 changed files with 10370 additions and 0 deletions
@@ -0,0 +1,20 @@
.crea-gruppo {
max-width: 480px;
margin: 2rem auto;
padding: 0 1rem;
}
.crea-gruppo__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.crea-gruppo__field {
width: 100%;
}
.crea-gruppo__error {
color: var(--mat-sys-error, #b3261e);
margin: 0;
}
@@ -0,0 +1,26 @@
<section class="crea-gruppo">
<h1>Crea un nuovo gruppo scout</h1>
<p>Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.</p>
<form class="crea-gruppo__form" (submit)="$event.preventDefault(); submit()" novalidate>
<mat-form-field appearance="outline" class="crea-gruppo__field">
<mat-label>Nome del gruppo scout</mat-label>
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
@if (nome.hasError('required')) {
<mat-error>Il nome del gruppo è obbligatorio.</mat-error>
} @else if (nome.hasError('minlength')) {
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
} @else if (nome.hasError('maxlength')) {
<mat-error>Il nome non può superare i 100 caratteri.</mat-error>
}
</mat-form-field>
@if (errorMessage(); as message) {
<p class="crea-gruppo__error" role="alert">{{ message }}</p>
}
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
</button>
</form>
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const CREA_GRUPPO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./crea-gruppo').then((m) => m.CreaGruppo),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,129 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { CreaGruppo } from './crea-gruppo';
import { GruppiApiService } from './gruppi-api.service';
describe('CreaGruppo', () => {
let component: CreaGruppo;
let fixture: ComponentFixture<CreaGruppo>;
let gruppiApi: { creaGruppo: ReturnType<typeof vi.fn> };
let keycloak: { updateToken: ReturnType<typeof vi.fn> };
let router: { navigateByUrl: ReturnType<typeof vi.fn> };
beforeEach(async () => {
gruppiApi = { creaGruppo: vi.fn() };
keycloak = { updateToken: vi.fn().mockResolvedValue(true) };
router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
await TestBed.configureTestingModule({
imports: [CreaGruppo],
providers: [
{ provide: GruppiApiService, useValue: gruppiApi },
{ provide: Keycloak, useValue: keycloak },
{ provide: Router, useValue: router }
]
}).compileComponents();
fixture = TestBed.createComponent(CreaGruppo);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('si crea correttamente', () => {
expect(component).toBeTruthy();
});
describe('validazione del campo nome', () => {
it('è invalido se vuoto (required)', () => {
component.nome.setValue('');
expect(component.nome.hasError('required')).toBe(true);
});
it('è invalido con meno di 3 caratteri', () => {
component.nome.setValue('Ab');
expect(component.nome.hasError('minlength')).toBe(true);
});
it('è valido con esattamente 3 caratteri', () => {
component.nome.setValue('Ab1');
expect(component.nome.valid).toBe(true);
});
it('è invalido oltre i 100 caratteri', () => {
component.nome.setValue('a'.repeat(101));
expect(component.nome.hasError('maxlength')).toBe(true);
});
it('è valido con esattamente 100 caratteri', () => {
component.nome.setValue('a'.repeat(100));
expect(component.nome.valid).toBe(true);
});
it('è valido con un nome tipico', () => {
component.nome.setValue('Agesci Milano 1');
expect(component.nome.valid).toBe(true);
});
it('non chiama l\'API e marca il controllo come touched se il nome non è valido', async () => {
component.nome.setValue('');
await component.submit();
expect(gruppiApi.creaGruppo).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
});
});
describe('gestione errore 409 (nome già esistente)', () => {
it('mostra il messaggio del backend e permette di correggere il nome senza ricaricare la pagina', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { message: 'Esiste già un gruppo scout con nome "Agesci Milano 1"' }
})
)
);
await component.submit();
expect(component.errorMessage()).toBe('Esiste già un gruppo scout con nome "Agesci Milano 1"');
expect(component.submitting()).toBe(false);
expect(router.navigateByUrl).not.toHaveBeenCalled();
// il form resta utilizzabile: si può correggere il nome e ritentare senza reload
component.nome.setValue('Agesci Milano 2');
expect(component.nome.valid).toBe(true);
});
it('mostra un messaggio generico per errori diversi da 409', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
await component.submit();
expect(component.errorMessage()).toBe('Si è verificato un errore imprevisto. Riprova.');
});
});
describe('redirect dopo il successo', () => {
it('aggiorna forzatamente il token e reindirizza alla home dopo la creazione del gruppo', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] }));
await component.submit();
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1');
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
expect(component.submitting()).toBe(false);
expect(component.errorMessage()).toBeNull();
});
});
});
@@ -0,0 +1,67 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { GruppiApiService } from './gruppi-api.service';
@Component({
selector: 'app-crea-gruppo',
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatProgressSpinnerModule],
templateUrl: './crea-gruppo.html',
styleUrl: './crea-gruppo.css'
})
export class CreaGruppo {
private readonly gruppiApi = inject(GruppiApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid) {
this.nome.markAsTouched();
return;
}
this.errorMessage.set(null);
this.submitting.set(true);
try {
await firstValueFrom(this.gruppiApi.creaGruppo(this.nome.value.trim()));
// Il gruppo è appena stato creato su Keycloak: il token corrente non contiene ancora
// il claim "organization" aggiornato, va quindi forzato un refresh prima di navigare.
await this.keycloak.updateToken(-1).catch(() => undefined);
this.submitting.set(false);
await this.router.navigateByUrl('/');
} catch (error) {
this.submitting.set(false);
this.handleError(error as HttpErrorResponse);
}
}
private handleError(error: HttpErrorResponse): void {
if (error.status === 409) {
this.errorMessage.set(error.error?.message ?? 'Esiste già un gruppo scout con questo nome.');
return;
}
this.errorMessage.set('Si è verificato un errore imprevisto. Riprova.');
}
}
@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface CreaGruppoResponse {
orgId: string;
gruppiCreati: string[];
}
@Injectable({ providedIn: 'root' })
export class GruppiApiService {
private readonly http = inject(HttpClient);
creaGruppo(nome: string): Observable<CreaGruppoResponse> {
return this.http.post<CreaGruppoResponse>(`${environment.orgServiceApiBaseUrl}/gruppi`, { nome });
}
}