import export functionality added

This commit is contained in:
2026-10-28 08:07:42 +01:00
parent bd497aefd5
commit e3e362c2ac
16 changed files with 609 additions and 4 deletions

View File

@@ -10,6 +10,8 @@ import java.util.LinkedHashSet;
@Builder @Builder
@ToString(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true)
public class DanceSchool extends AbstractEntity { public class DanceSchool extends AbstractEntity {
@Builder.Default
private final java.util.UUID key = java.util.UUID.randomUUID();
@Setter @Setter
private String name; private String name;
@Builder.Default @Builder.Default

View File

@@ -13,5 +13,9 @@ public class DayPart extends AbstractEntity {
@ToString.Include @ToString.Include
private final DayPartType type; private final DayPartType type;
@ToString.Include @ToString.Include
private final List<DanceGroup> danceGroups; private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) {
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
}
} }

View File

@@ -9,9 +9,13 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
protected Map<Long, T> entities = new LinkedHashMap<>(); protected Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
return sequence++;
}
public T add(T entity) { public T add(T entity) {
if (entity.getId() == null) { if (entity.getId() == null) {
entity.setId(sequence++); entity.setId(nextId());
} }
entities.put(entity.getId(), entity); entities.put(entity.getId(), entity);
return entity; return entity;

View File

@@ -0,0 +1,43 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
@Inject SchoolManagementService service;
@Inject ObjectMapper mapper;
@GET @Path("/{id}/export")
public Response export(@PathParam("id") long id) throws Exception {
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
}
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
public SchoolManagementService.ImportPreview preview(String json) { return service.preview(parse(json)); }
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
return SchoolView.of(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(String json) {
try {
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.readValue(json, SchoolSnapshot.class);
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
return snapshot;
} catch (Exception e) {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

View File

@@ -106,6 +106,7 @@ public class SchoolManagementService {
public synchronized void deleteGroup(long schoolId, long groupId) { public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId); DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
school(schoolId).removeDanceGroup(group); school(schoolId).removeDanceGroup(group);
groups.delete(groupId); groups.delete(groupId);
} }
@@ -150,6 +151,60 @@ public class SchoolManagementService {
shows.delete(showId); shows.delete(showId);
} }
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public synchronized SchoolSnapshot exportSchool(long id) {
return SchoolSnapshot.of(school(id));
}
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
snapshot.restore();
DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null);
return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(),
existing == null ? null : existing.getId(), existing == null ? null : existing.getName());
}
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
DanceSchool staged = snapshot.restore();
ImportPreview preview = preview(snapshot);
if (!Objects.equals(preview.existingId(), replaceId)) {
throw new ManagementException(INVALID_INPUT, "De bestaande school is gewijzigd. Bekijk de import opnieuw.");
}
// All validation and construction finish before changing repository state.
// Rebuild sets after assigning runtime IDs: entity hash codes include the ID.
List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
List<Show> importedShows = new ArrayList<>(staged.getShows());
Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
importedGroups.forEach(g -> memberships.put(g, new ArrayList<>(g.getDancers())));
importedDancers.forEach(d -> d.setId(dancers.nextId()));
importedGroups.forEach(g -> {
g.setId(groups.nextId());
});
List<DanceGroup> rebuiltGroups = new ArrayList<>();
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
for (DanceGroup g : importedGroups) {
DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType())
.startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles()))
.coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build();
rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt);
}
List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) {
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
}
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups))
.shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build();
if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); }
importedDancers.forEach(dancers::add);
rebuiltGroups.forEach(groups::add);
rebuiltShows.forEach(shows::add);
return schools.add(imported);
}
private static void validateAgeRange(int startAge, int endAge) { private static void validateAgeRange(int startAge, int endAge) {
if (startAge > endAge) { if (startAge > endAge) {
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge"); throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");

View File

@@ -0,0 +1,96 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate;
import java.util.*;
/** Version 1 file format. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record Person(String id, String name, LocalDate birthDate) {}
public record Trainer(String id, String name) {}
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(1, s.getKey(), s.getName(),
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
g.getDancers().stream().map(dancers::get).toList(),
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
show.getDayParts().stream().map(p -> new Part(p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
Map<T, String> result = new IdentityHashMap<>();
for (T value : values) result.put(value, Integer.toString(result.size() + 1));
return result;
}
public DanceSchool restore() {
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser"); text(d.name, "dansernaam");
check(d.birthDate != null && !d.birthDate.isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name);
Dancer dancer = Dancer.builder().name(d.name).birthDate(d.birthDate).build();
// Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size());
put(dancerMap, d.id, dancer); school.addDancer(dancer);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); text(c.name, "coachnaam");
Coach coach = Coach.builder().name(c.name).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id, coach); school.addCoach(coach);
}
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) {
check(g != null, "Lege groep"); text(g.category, "groepsnaam");
check(g.type != null && g.startAge != null && g.endAge != null && g.startAge >= 0 && g.endAge >= g.startAge,
"Ongeldig type of leeftijdsbereik voor groep " + g.category);
DanceGroup group = DanceGroup.builder().category(g.category).type(g.type).startAge(g.startAge).endAge(g.endAge)
.styles(new LinkedHashSet<>(list(g.styles, "styles"))).coach(g.coachId == null ? null : ref(coachMap, g.coachId, "groep " + g.category + " verwijst naar een onbekende coach" )).build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds, "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category + " verwijst naar een onbekende danser"));
put(groupMap, g.id, group); school.addDanceGroup(group);
}
Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id, "show-id");
check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam");
check(show.date != null, "Showdatum ontbreekt voor " + show.name);
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) {
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
}
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).build();
entity.setId((long) showIds.size()); school.addShow(entity);
}
return school;
}
private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); }
private static <T> List<T> list(List<T> value, String field) { check(value != null, field + " ontbreekt"); return value; }
private static List<String> unique(List<String> value, String field) {
list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value;
}
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementException.Kind.INVALID_INPUT, message); }
}

View File

@@ -0,0 +1,32 @@
package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
.then().statusCode(201).extract().jsonPath().getLong("id");
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString();
try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
.then().statusCode(400).body("message", not(emptyOrNullString()));
}
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import")
.then().statusCode(200).extract().jsonPath().getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); }
}
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new SchoolSnapshot.Trainer("c", "Alex")),
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
var service = new SchoolManagementService();
var existing = service.createSchool("Other");
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1));
var imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size());
var dancer = imported.getDancers().iterator().next();
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
var restarted = new SchoolManagementService();
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId()));
// Membership removal must work after remapping hash-based sets.
service.deleteDancer(imported.getId(), dancer.getId());
imported.getDanceGroups().forEach(g -> assertTrue(g.getDancers().isEmpty()));
assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService(); var snapshot = complete();
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
var original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1));
var other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
assertEquals(2, original.getDancers().size());
var replaced = service.importSchool(snapshot, original.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
}
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
assertThrows(ManagementException.class, () -> service.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
}
@Test void emptySchoolRoundTrips() {
var service = new SchoolManagementService();
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
}
}

View File

@@ -27,6 +27,7 @@
School bewerken School bewerken
</button> </button>
</div> </div>
<app-school-transfer [schoolId]="context.school.id" (imported)="reload()" />
<nav class="tabs" aria-label="Dansschool onderdelen"> <nav class="tabs" aria-label="Dansschool onderdelen">
@for (tab of tabs; track tab.value) { @for (tab of tabs; track tab.value) {
<a <a

View File

@@ -1,3 +1,4 @@
import { SchoolTransfer } from '../shared/school-transfer';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -26,7 +27,15 @@ interface DeleteTarget {
@Component({ @Component({
selector: 'app-school-page', selector: 'app-school-page',
imports: [DatePipe, RouterLink, RouterLinkActive, EntityEditor, ConfirmDialog, GroupMembers], imports: [
SchoolTransfer,
DatePipe,
RouterLink,
RouterLinkActive,
EntityEditor,
ConfirmDialog,
GroupMembers,
],
templateUrl: './school-page.html', templateUrl: './school-page.html',
}) })
export class SchoolPage implements OnInit { export class SchoolPage implements OnInit {

View File

@@ -1,3 +1,4 @@
<app-school-transfer (imported)="load()" />
<div class="page-heading"> <div class="page-heading">
<div> <div>
<p class="eyebrow">JOUW ORGANISATIE</p> <p class="eyebrow">JOUW ORGANISATIE</p>

View File

@@ -1,3 +1,4 @@
import { SchoolTransfer } from '../shared/school-transfer';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
@@ -8,7 +9,7 @@ import { EntityEditor } from '../shared/entity-editor';
@Component({ @Component({
selector: 'app-schools-page', selector: 'app-schools-page',
imports: [RouterLink, EntityEditor], imports: [SchoolTransfer, RouterLink, EntityEditor],
templateUrl: './schools-page.html', templateUrl: './schools-page.html',
}) })
export class SchoolsPage implements OnInit { export class SchoolsPage implements OnInit {

View 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');
});
});

View 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);
}
}
}

View File

@@ -0,0 +1,49 @@
# School JSON format, version 1
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
`application/json`, validates without writing, and returns `name`, `dancers`,
`groups`, `shows`, `existingId` and `existingName`.
Commit: `POST /api/dance-schools/import` accepts the same file. For replacement,
provide `?replaceId=<existingId>` as explicit confirmation. A mismatch is rejected;
refresh the preview if another import added or removed the school meanwhile.
There is no merge endpoint.
```json
{
"formatVersion": 1,
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
"name": "Studio",
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
"coaches": [{ "id": "c1", "name": "Alex" }],
"groups": [{
"id": "g1", "category": "Hiphop", "type": "REGULAR",
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
"dancerIds": ["d1"], "coachId": "c1"
}],
"shows": [{
"id": "s1", "name": "Summer show", "date": "2027-06-12",
"dayParts": [{ "type": "MORNING", "groupIds": ["g1"] }]
}]
}
```
`key` is a persistent UUID independent of the server's numeric school ID. IDs
inside each collection are nonblank strings, unique within that collection, and
local to this file. They can change on export. References must resolve within the
file. A dancer or coach can be referenced by multiple groups. A group can appear
in multiple day parts. A day-part type can occur once per show.
All fields are required except `coachId`, which may be absent or null. Collections
may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`;
birth dates cannot be in the future. Ages are nonnegative integers with
`startAge <= endAge`. Enum values use the same uppercase names as the management
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`.
Unknown fields, duplicate JSON properties, duplicate IDs or references, invalid
values and unsupported versions are rejected. Validation is repeated on commit.
The entire school is prepared before publishing it under the shared service lock.
Replacing retains the numeric school ID for existing browser links; imported
child IDs are reassigned to avoid collisions with other schools.
Group types: `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`, `REGULAR`, `PRE_SCHOOLER`.
Styles: `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`, `CLASSIC`, `BREAKDANCE`.