frontend-toegevoegd
This commit is contained in:
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal file
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame title="Verwijderen bevestigen" [busy]="busy()" (dismissed)="closed.emit()">
|
||||
<p>
|
||||
Weet je zeker dat je <strong>{{ name() }}</strong> wilt verwijderen?
|
||||
</p>
|
||||
<p class="muted">{{ explanation() }}</p>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
autofocus
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="button" class="button danger" [disabled]="busy()" (click)="confirmed.emit()">
|
||||
{{ busy() ? 'Verwijderen…' : 'Definitief verwijderen' }}
|
||||
</button>
|
||||
</div>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly name = input.required<string>();
|
||||
readonly explanation = input('Dit kan niet ongedaan worden gemaakt.');
|
||||
readonly busy = input(false);
|
||||
readonly error = input('');
|
||||
readonly confirmed = output<void>();
|
||||
readonly closed = output<void>();
|
||||
}
|
||||
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal file
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dialog-frame',
|
||||
template: `
|
||||
<dialog #dialog (cancel)="cancel($event)" aria-labelledby="dialog-title">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
<p class="eyebrow">AGILE DANCERS</p>
|
||||
<h2 id="dialog-title">{{ title() }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
aria-label="Sluiten"
|
||||
[disabled]="busy()"
|
||||
(click)="dismissed.emit()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ng-content />
|
||||
</dialog>
|
||||
`,
|
||||
})
|
||||
export class DialogFrame implements AfterViewInit, OnDestroy {
|
||||
readonly title = input.required<string>();
|
||||
readonly busy = input(false);
|
||||
readonly dismissed = output<void>();
|
||||
private readonly dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');
|
||||
ngAfterViewInit(): void {
|
||||
this.dialog().nativeElement.showModal();
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
const dialog = this.dialog().nativeElement;
|
||||
if (dialog.open) dialog.close();
|
||||
}
|
||||
cancel(event: Event): void {
|
||||
event.preventDefault();
|
||||
if (!this.busy()) this.dismissed.emit();
|
||||
}
|
||||
}
|
||||
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal file
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { EntityEditor } from './entity-editor';
|
||||
import { EditorTarget } from '../core/api-models';
|
||||
|
||||
describe('EntityEditor', () => {
|
||||
const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EntityEditor],
|
||||
providers: [{ provide: SchoolApi, useValue: api }],
|
||||
});
|
||||
});
|
||||
function editor(target: EditorTarget) {
|
||||
const fixture = TestBed.createComponent(EntityEditor);
|
||||
fixture.componentRef.setInput('target', target);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
|
||||
it('rejects blank names and invalid age ranges before calling the API', async () => {
|
||||
const component = editor({ kind: 'group' });
|
||||
component.name = ' ';
|
||||
expect(component.validDetails()).toBe(false);
|
||||
component.name = 'Teens';
|
||||
component.startAge = 18;
|
||||
component.endAge = 12;
|
||||
await component.save();
|
||||
expect(api.saveGroup).not.toHaveBeenCalled();
|
||||
component.endAge = 18.5;
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept a future birth date', () => {
|
||||
const component = editor({ kind: 'dancer' });
|
||||
component.name = 'Sam';
|
||||
component.date = '2999-01-01';
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('trims the school name and emits saved only after success', async () => {
|
||||
api.saveSchool.mockReturnValue(of({ id: 0 }));
|
||||
const component = editor({ kind: 'school' });
|
||||
component.name = ' Studio ';
|
||||
const saved = vi.fn();
|
||||
component.saved.subscribe(saved);
|
||||
await component.save();
|
||||
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
|
||||
expect(saved).toHaveBeenCalledOnce();
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the editor open and preserves input when saving fails', async () => {
|
||||
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||
const component = editor({ kind: 'school' });
|
||||
component.name = 'Studio';
|
||||
const saved = vi.fn();
|
||||
component.saved.subscribe(saved);
|
||||
await component.save();
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
expect(component.name).toBe('Studio');
|
||||
expect(component.error()).toContain('niet bereikbaar');
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
});
|
||||
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal file
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
import { EditorTarget, GROUP_STYLES, GROUP_TYPES, GroupStyle, GroupType } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-editor',
|
||||
imports: [FormsModule, DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame [title]="title" [busy]="busy()" (dismissed)="closed.emit()">
|
||||
<form #form="ngForm" (ngSubmit)="form.valid && save()">
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (target().kind === 'group') {
|
||||
<label
|
||||
>Naam / categorie<input
|
||||
autofocus
|
||||
name="category"
|
||||
[(ngModel)]="name"
|
||||
required
|
||||
pattern=".*\\S.*"
|
||||
maxlength="160"
|
||||
placeholder="Bijvoorbeeld: Hiphop tieners"
|
||||
/></label>
|
||||
<label for="group-type">Type</label
|
||||
><select id="group-type" name="type" [(ngModel)]="type" required>
|
||||
@for (option of types; track option.value) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-grid">
|
||||
<label
|
||||
>Leeftijd vanaf<input
|
||||
type="number"
|
||||
name="startAge"
|
||||
[(ngModel)]="startAge"
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
/></label>
|
||||
<label
|
||||
>Leeftijd tot en met<input
|
||||
type="number"
|
||||
name="endAge"
|
||||
[(ngModel)]="endAge"
|
||||
required
|
||||
[min]="startAge ?? 0"
|
||||
step="1"
|
||||
/></label>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Dansstijlen <span class="muted">(optioneel)</span></legend>
|
||||
<div class="checkbox-grid">
|
||||
@for (style of styles; track style.value) {
|
||||
<label class="checkbox"
|
||||
><input
|
||||
type="checkbox"
|
||||
[name]="style.value"
|
||||
[ngModel]="selectedStyles.includes(style.value)"
|
||||
(ngModelChange)="toggleStyle(style.value, $event)"
|
||||
/>{{ style.label }}</label
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
} @else {
|
||||
<label
|
||||
>Naam<input
|
||||
autofocus
|
||||
name="name"
|
||||
[(ngModel)]="name"
|
||||
required
|
||||
pattern=".*\\S.*"
|
||||
maxlength="160"
|
||||
[placeholder]="target().kind === 'school' ? 'Bijvoorbeeld: 24 Dance Studio' : 'Naam'"
|
||||
/></label>
|
||||
@if (target().kind === 'dancer') {
|
||||
<label
|
||||
>Geboortedatum<input
|
||||
type="date"
|
||||
name="birthDate"
|
||||
[(ngModel)]="date"
|
||||
required
|
||||
[max]="today"
|
||||
/></label>
|
||||
}
|
||||
@if (target().kind === 'show') {
|
||||
<label>Datum<input type="date" name="showDate" [(ngModel)]="date" required /></label>
|
||||
<p class="form-hint">
|
||||
De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt
|
||||
later.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button
|
||||
class="button primary"
|
||||
type="submit"
|
||||
[disabled]="busy() || form.invalid || !validDetails()"
|
||||
>
|
||||
{{ busy() ? 'Opslaan…' : 'Opslaan' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class EntityEditor implements OnInit {
|
||||
readonly target = input.required<EditorTarget>();
|
||||
readonly schoolId = input<number>();
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<void>();
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly types = GROUP_TYPES;
|
||||
readonly styles = GROUP_STYLES;
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
private readonly api = inject(SchoolApi);
|
||||
name = '';
|
||||
date = '';
|
||||
type: GroupType = 'REGULAR';
|
||||
startAge: number | null = 0;
|
||||
endAge: number | null = 18;
|
||||
selectedStyles: GroupStyle[] = [];
|
||||
|
||||
get title(): string {
|
||||
const labels = { school: 'Dansschool', dancer: 'Danser', group: 'Dansgroep', show: 'Show' };
|
||||
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
||||
}
|
||||
ngOnInit(): void {
|
||||
const target = this.target();
|
||||
if (!target.value) return;
|
||||
if (target.kind === 'group') {
|
||||
this.name = target.value.category;
|
||||
this.type = target.value.type;
|
||||
this.startAge = target.value.startAge;
|
||||
this.endAge = target.value.endAge;
|
||||
this.selectedStyles = [...target.value.styles];
|
||||
} else {
|
||||
this.name = target.value.name;
|
||||
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
||||
if (target.kind === 'show') this.date = target.value.date;
|
||||
}
|
||||
}
|
||||
toggleStyle(style: GroupStyle, checked: boolean): void {
|
||||
this.selectedStyles = checked
|
||||
? [...this.selectedStyles, style]
|
||||
: this.selectedStyles.filter((value) => value !== style);
|
||||
}
|
||||
validDetails(): boolean {
|
||||
if (!this.name.trim()) return false;
|
||||
if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today;
|
||||
if (this.target().kind === 'show') return !!this.date;
|
||||
if (this.target().kind === 'group')
|
||||
return (
|
||||
this.startAge !== null &&
|
||||
this.endAge !== null &&
|
||||
Number.isInteger(this.startAge) &&
|
||||
Number.isInteger(this.endAge) &&
|
||||
this.startAge >= 0 &&
|
||||
this.endAge >= this.startAge
|
||||
);
|
||||
return true;
|
||||
}
|
||||
async save(): Promise<void> {
|
||||
if (this.busy() || !this.validDetails()) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
const target = this.target();
|
||||
const id = target.value?.id;
|
||||
const schoolId = this.schoolId();
|
||||
let request: Observable<unknown>;
|
||||
switch (target.kind) {
|
||||
case 'school':
|
||||
request = this.api.saveSchool({ name: this.name.trim() }, id);
|
||||
break;
|
||||
case 'dancer':
|
||||
request = this.api.saveDancer(
|
||||
schoolId!,
|
||||
{ name: this.name.trim(), birthDate: this.date },
|
||||
id,
|
||||
);
|
||||
break;
|
||||
case 'group':
|
||||
request = this.api.saveGroup(
|
||||
schoolId!,
|
||||
{
|
||||
category: this.name.trim(),
|
||||
type: this.type,
|
||||
startAge: this.startAge!,
|
||||
endAge: this.endAge!,
|
||||
styles: this.selectedStyles,
|
||||
},
|
||||
id,
|
||||
);
|
||||
break;
|
||||
case 'show':
|
||||
request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await firstValueFrom(request);
|
||||
this.saved.emit();
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal file
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Subject } from 'rxjs';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { GroupMembers } from './group-members';
|
||||
|
||||
describe('Group membership persistence', () => {
|
||||
const api = { membership: vi.fn() };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [GroupMembers],
|
||||
providers: [{ provide: SchoolApi, useValue: api }],
|
||||
});
|
||||
});
|
||||
function component() {
|
||||
const fixture = TestBed.createComponent(GroupMembers);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentRef.setInput('group', {
|
||||
id: 0,
|
||||
category: 'Teens',
|
||||
type: 'REGULAR',
|
||||
startAge: 12,
|
||||
endAge: 18,
|
||||
styles: [],
|
||||
dancerIds: [],
|
||||
});
|
||||
fixture.componentRef.setInput('dancers', [{ id: 3, name: 'Sam', birthDate: '2010-01-01' }]);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
function change(checked: boolean): { checkbox: HTMLInputElement; event: Event } {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = checked;
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: checkbox });
|
||||
return { checkbox, event };
|
||||
}
|
||||
|
||||
it('shows the pending choice but emits new membership only after the server confirms', async () => {
|
||||
const request = new Subject<void>();
|
||||
api.membership.mockReturnValue(request);
|
||||
const members = component();
|
||||
const changed = vi.fn();
|
||||
members.changed.subscribe(changed);
|
||||
const { checkbox, event } = change(true);
|
||||
const pending = members.toggle(3, event);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(members.busy()).toBe(true);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
request.next();
|
||||
request.complete();
|
||||
await pending;
|
||||
expect(changed).toHaveBeenCalledWith([3]);
|
||||
expect(members.memberIds()).toEqual([3]);
|
||||
expect(members.busy()).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the previous checkbox state when saving fails', async () => {
|
||||
const request = new Subject<void>();
|
||||
api.membership.mockReturnValue(request);
|
||||
const members = component();
|
||||
const changed = vi.fn();
|
||||
members.changed.subscribe(changed);
|
||||
const { checkbox, event } = change(true);
|
||||
const pending = members.toggle(3, event);
|
||||
request.error(new HttpErrorResponse({ status: 0 }));
|
||||
await pending;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(members.memberIds()).toEqual([]);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(members.error()).toContain('niet bereikbaar');
|
||||
expect(members.busy()).toBe(false);
|
||||
});
|
||||
});
|
||||
98
agiledancers-fe/src/app/shared/group-members.ts
Normal file
98
agiledancers-fe/src/app/shared/group-members.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DanceGroup, Dancer } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-group-members',
|
||||
imports: [DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame
|
||||
[title]="'Dansers in ' + group().category"
|
||||
[busy]="busy()"
|
||||
(dismissed)="closed.emit()"
|
||||
>
|
||||
<p class="muted">
|
||||
Koppel dansers aan deze groep. Een danser kan in meerdere groepen zitten. Wijzigingen worden
|
||||
direct opgeslagen.
|
||||
</p>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<p class="member-count" role="status">
|
||||
{{
|
||||
busy()
|
||||
? 'Koppeling opslaan…'
|
||||
: memberIds().length + ' van ' + dancers().length + ' dansers gekoppeld'
|
||||
}}
|
||||
</p>
|
||||
<div class="member-list" [attr.aria-busy]="busy()">
|
||||
@for (dancer of sortedDancers(); track dancer.id) {
|
||||
<label class="member-row"
|
||||
><span aria-hidden="true" class="avatar small">{{
|
||||
dancer.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
<span>{{ dancer.name }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="memberIds().includes(dancer.id)"
|
||||
[disabled]="busy()"
|
||||
(change)="toggle(dancer.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
} @empty {
|
||||
<p class="empty-inline">
|
||||
Er zijn nog geen dansers. Voeg eerst een danser toe via het tabblad Dansers.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button primary" type="button" [disabled]="busy()" (click)="closed.emit()">
|
||||
Gereed
|
||||
</button>
|
||||
</div>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class GroupMembers implements OnInit {
|
||||
readonly schoolId = input.required<number>();
|
||||
readonly group = input.required<DanceGroup>();
|
||||
readonly dancers = input.required<Dancer[]>();
|
||||
readonly closed = output<void>();
|
||||
readonly changed = output<number[]>();
|
||||
readonly memberIds = signal<number[]>([]);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly sortedDancers = computed(() =>
|
||||
[...this.dancers()].sort((a, b) => a.name.localeCompare(b.name, 'nl')),
|
||||
);
|
||||
private readonly api = inject(SchoolApi);
|
||||
ngOnInit(): void {
|
||||
this.memberIds.set([...this.group().dancerIds]);
|
||||
}
|
||||
async toggle(dancerId: number, event: Event): Promise<void> {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
const add = checkbox.checked;
|
||||
if (this.busy()) {
|
||||
checkbox.checked = this.memberIds().includes(dancerId);
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
|
||||
this.memberIds.update((ids) =>
|
||||
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
|
||||
);
|
||||
checkbox.checked = add;
|
||||
this.changed.emit(this.memberIds());
|
||||
} catch (error: unknown) {
|
||||
checkbox.checked = this.memberIds().includes(dancerId);
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user