import export functionality added
This commit is contained in:
@@ -10,6 +10,8 @@ import java.util.LinkedHashSet;
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class DanceSchool extends AbstractEntity {
|
||||
@Builder.Default
|
||||
private final java.util.UUID key = java.util.UUID.randomUUID();
|
||||
@Setter
|
||||
private String name;
|
||||
@Builder.Default
|
||||
|
||||
@@ -13,5 +13,9 @@ public class DayPart extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final DayPartType type;
|
||||
@ToString.Include
|
||||
private final List<DanceGroup> danceGroups;
|
||||
private List<DanceGroup> danceGroups;
|
||||
|
||||
public void removeDanceGroup(DanceGroup group) {
|
||||
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||
|
||||
protected Map<Long, T> entities = new LinkedHashMap<>();
|
||||
|
||||
public long nextId() {
|
||||
return sequence++;
|
||||
}
|
||||
|
||||
public T add(T entity) {
|
||||
if (entity.getId() == null) {
|
||||
entity.setId(sequence++);
|
||||
entity.setId(nextId());
|
||||
}
|
||||
entities.put(entity.getId(), entity);
|
||||
return entity;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ public class SchoolManagementService {
|
||||
|
||||
public synchronized void deleteGroup(long schoolId, long groupId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
|
||||
school(schoolId).removeDanceGroup(group);
|
||||
groups.delete(groupId);
|
||||
}
|
||||
@@ -150,6 +151,60 @@ public class SchoolManagementService {
|
||||
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) {
|
||||
if (startAge > endAge) {
|
||||
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
specs/School-JSON-format.md
Normal file
49
specs/School-JSON-format.md
Normal 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`.
|
||||
Reference in New Issue
Block a user