import export functionality added
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
School bewerken
|
||||
</button>
|
||||
</div>
|
||||
<app-school-transfer [schoolId]="context.school.id" (imported)="reload()" />
|
||||
<nav class="tabs" aria-label="Dansschool onderdelen">
|
||||
@for (tab of tabs; track tab.value) {
|
||||
<a
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SchoolTransfer } from '../shared/school-transfer';
|
||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
@@ -26,7 +27,15 @@ interface DeleteTarget {
|
||||
|
||||
@Component({
|
||||
selector: 'app-school-page',
|
||||
imports: [DatePipe, RouterLink, RouterLinkActive, EntityEditor, ConfirmDialog, GroupMembers],
|
||||
imports: [
|
||||
SchoolTransfer,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
EntityEditor,
|
||||
ConfirmDialog,
|
||||
GroupMembers,
|
||||
],
|
||||
templateUrl: './school-page.html',
|
||||
})
|
||||
export class SchoolPage implements OnInit {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<app-school-transfer (imported)="load()" />
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">JOUW ORGANISATIE</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SchoolTransfer } from '../shared/school-transfer';
|
||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
@@ -8,7 +9,7 @@ import { EntityEditor } from '../shared/entity-editor';
|
||||
|
||||
@Component({
|
||||
selector: 'app-schools-page',
|
||||
imports: [RouterLink, EntityEditor],
|
||||
imports: [SchoolTransfer, RouterLink, EntityEditor],
|
||||
templateUrl: './schools-page.html',
|
||||
})
|
||||
export class SchoolsPage implements OnInit {
|
||||
|
||||
86
agiledancers-fe/src/app/shared/school-transfer.spec.ts
Normal file
86
agiledancers-fe/src/app/shared/school-transfer.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SchoolTransfer } from './school-transfer';
|
||||
|
||||
describe('School JSON import', () => {
|
||||
let http: HttpTestingController;
|
||||
let component: SchoolTransfer;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
component = TestBed.createComponent(SchoolTransfer).componentInstance;
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
async function select() {
|
||||
const selected = component.select({
|
||||
target: { files: [{ text: async () => '{"formatVersion":1}' }], value: 'file.json' },
|
||||
} as unknown as Event);
|
||||
await Promise.resolve();
|
||||
return { selected, request: http.expectOne('/api/dance-schools/import/preview') };
|
||||
}
|
||||
it('previews without committing and cancellation makes no write request', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush({
|
||||
name: 'Studio',
|
||||
dancers: 1,
|
||||
groups: 2,
|
||||
shows: 3,
|
||||
existingId: null,
|
||||
existingName: null,
|
||||
});
|
||||
await selected;
|
||||
expect(component.preview()?.name).toBe('Studio');
|
||||
http.expectNone('/api/dance-schools/import');
|
||||
component.cancel();
|
||||
await component.confirm();
|
||||
expect(component.preview()).toBeNull();
|
||||
});
|
||||
it('sends the identified school only after confirmation', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush({
|
||||
name: 'Studio',
|
||||
dancers: 1,
|
||||
groups: 2,
|
||||
shows: 3,
|
||||
existingId: 0,
|
||||
existingName: 'Old studio',
|
||||
});
|
||||
await selected;
|
||||
const completed = vi.fn();
|
||||
component.imported.subscribe(completed);
|
||||
const commit = component.confirm();
|
||||
const write = http.expectOne('/api/dance-schools/import?replaceId=0');
|
||||
expect(write.request.body).toBe('{"formatVersion":1}');
|
||||
write.flush({});
|
||||
await commit;
|
||||
expect(completed).toHaveBeenCalledOnce();
|
||||
expect(component.preview()).toBeNull();
|
||||
});
|
||||
it('shows validation errors and allows retrying a corrected file', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush(
|
||||
{ message: 'groep Hiphop verwijst naar een onbekende danser' },
|
||||
{ status: 400, statusText: 'Bad Request' },
|
||||
);
|
||||
await selected;
|
||||
expect(component.error()).toBe(
|
||||
'Import niet gelukt: groep Hiphop verwijst naar een onbekende danser',
|
||||
);
|
||||
expect(component.busy()).toBe(false);
|
||||
const retry = await select();
|
||||
retry.request.flush({
|
||||
name: 'Corrected',
|
||||
dancers: 0,
|
||||
groups: 0,
|
||||
shows: 0,
|
||||
existingId: null,
|
||||
existingName: null,
|
||||
});
|
||||
await retry.selected;
|
||||
expect(component.error()).toBe('');
|
||||
expect(component.preview()?.name).toBe('Corrected');
|
||||
});
|
||||
});
|
||||
152
agiledancers-fe/src/app/shared/school-transfer.ts
Normal file
152
agiledancers-fe/src/app/shared/school-transfer.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { apiError } from '../core/api-error';
|
||||
|
||||
interface Preview {
|
||||
name: string;
|
||||
dancers: number;
|
||||
groups: number;
|
||||
shows: number;
|
||||
existingId: number | null;
|
||||
existingName: string | null;
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-school-transfer',
|
||||
template: `
|
||||
<div class="quick-actions">
|
||||
@if (schoolId() !== undefined) {
|
||||
<button type="button" class="button secondary" (click)="download()" [disabled]="busy()">
|
||||
JSON exporteren
|
||||
</button>
|
||||
}
|
||||
<label
|
||||
>JSON importeren
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
[disabled]="busy()"
|
||||
(change)="select($event)"
|
||||
/></label>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (notice()) {
|
||||
<p role="status">{{ notice() }}</p>
|
||||
}
|
||||
@if (preview(); as p) {
|
||||
<section class="panel" aria-label="Importvoorbeeld">
|
||||
<h2>{{ p.name }}</h2>
|
||||
<p>{{ p.dancers }} dansers · {{ p.groups }} dansgroepen · {{ p.shows }} shows</p>
|
||||
@if (p.existingId !== null) {
|
||||
<p>
|
||||
De bestaande school {{ p.existingName }} wordt volledig vervangen. Alle huidige gegevens
|
||||
van deze school verdwijnen.
|
||||
</p>
|
||||
<button type="button" class="button primary" [disabled]="busy()" (click)="confirm()">
|
||||
Volledig vervangen bevestigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="notice.set('Samenvoegen wordt nog niet ondersteund.')"
|
||||
>
|
||||
Samenvoegen
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="button primary" [disabled]="busy()" (click)="confirm()">
|
||||
School toevoegen bevestigen
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="button secondary" [disabled]="busy()" (click)="cancel()">
|
||||
Annuleren
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SchoolTransfer {
|
||||
readonly schoolId = input<number>();
|
||||
readonly imported = output<void>();
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly preview = signal<Preview | null>(null);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly notice = signal('');
|
||||
private json = '';
|
||||
private readonly base = '/api/dance-schools';
|
||||
|
||||
cancel(): void {
|
||||
this.preview.set(null);
|
||||
this.json = '';
|
||||
this.notice.set('');
|
||||
}
|
||||
async select(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
this.cancel();
|
||||
this.error.set('');
|
||||
if (!file) return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
this.json = await file.text();
|
||||
this.preview.set(
|
||||
await firstValueFrom(
|
||||
this.http.post<Preview>(`${this.base}/import/preview`, this.json, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
this.error.set(
|
||||
`Import niet gelukt: ${e instanceof Error && !('status' in e) ? e.message : apiError(e)}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
async confirm(): Promise<void> {
|
||||
const p = this.preview();
|
||||
if (!p || this.busy()) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
|
||||
await firstValueFrom(
|
||||
this.http.post(`${this.base}/import${query}`, this.json, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
this.cancel();
|
||||
this.notice.set('School geïmporteerd.');
|
||||
this.imported.emit();
|
||||
} catch (e) {
|
||||
this.error.set(`Import niet gelukt: ${apiError(e)}`);
|
||||
this.preview.set(null);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
async download(): Promise<void> {
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const blob = await firstValueFrom(
|
||||
this.http.get(`${this.base}/${this.schoolId()}/export`, { responseType: 'blob' }),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `school-${this.schoolId()}.json`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
this.error.set(`Export niet gelukt: ${apiError(e)}`);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user