Compare commits
23 Commits
feat/issue
...
0c47c22185
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c47c22185 | ||
|
|
d97cd06f70 | ||
|
|
b41405cb4b | ||
|
|
c9e9300216 | ||
|
|
92b082ec62 | ||
|
|
8770ca874b | ||
|
|
e54240ea1b | ||
|
|
437144174c | ||
|
|
fe830ad64b | ||
|
|
8f75552503 | ||
|
|
e7573bbeda | ||
|
|
bf31380141 | ||
|
|
58b3dabea2 | ||
|
|
60a278ad8e | ||
|
|
39f722fec0 | ||
|
|
fef021bf51 | ||
|
|
6c117611b8 | ||
|
|
aac8250af0 | ||
|
|
18cad798fc | ||
|
|
0ddf43947b | ||
|
|
45f7642f8d | ||
|
|
5a13e61357 | ||
|
|
a91ee1f26d |
@@ -63,27 +63,33 @@ public class PersonController {
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.createPerson(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
private void validatePersonNames(PersonUpdateDTO dto) {
|
||||
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
||||
}
|
||||
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
||||
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
|
||||
@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private String documentLocation;
|
||||
private String archiveBox;
|
||||
private String archiveFolder;
|
||||
private String transcription;
|
||||
private String summary;
|
||||
private UUID senderId;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
private PersonType personType;
|
||||
@Size(max = 50)
|
||||
private String title;
|
||||
@Size(max = 100)
|
||||
|
||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
||||
PERSON_NOT_FOUND,
|
||||
/** A person name alias with the given ID does not exist. 404 */
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
|
||||
@@ -271,6 +271,8 @@ public class DocumentService {
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||
|
||||
@@ -109,8 +109,12 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person createPerson(PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
.firstName(dto.getFirstName())
|
||||
.lastName(dto.getLastName())
|
||||
@@ -136,9 +140,13 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\"}"))
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -225,11 +229,52 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||
when(personService.createPerson(any())).thenThrow(
|
||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -242,10 +287,10 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -254,7 +299,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -267,7 +312,7 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||
}
|
||||
@@ -317,11 +362,10 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
// firstName valid, lastName blank → second || operand = true → 400
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -339,7 +383,7 @@ class PersonControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
"\"notes\":\"Some notes\"}"))
|
||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||
@@ -355,7 +399,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -366,7 +410,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -377,7 +421,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -386,7 +430,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,23 @@ class DocumentServiceTest {
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setArchiveBox("K-03");
|
||||
dto.setArchiveFolder("Mappe B");
|
||||
|
||||
documentService.updateDocument(id, dto, null, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -114,6 +114,43 @@ class PersonServiceTest {
|
||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsPersonType() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsTitle() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsPersonType() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
"form_label_person_type": "Typ",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "Vorname",
|
||||
"form_label_last_name": "Nachname",
|
||||
"form_label_alias": "Rufname / Alias",
|
||||
@@ -527,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Gruppe",
|
||||
"person_type_UNKNOWN": "Unbekannt",
|
||||
"a11y_type_changed": "Typ geändert zu {type}",
|
||||
"person_alias_add_heading": "Name hinzufuegen",
|
||||
"person_alias_label_type": "Art",
|
||||
"person_alias_label_last_name": "Nachname",
|
||||
@@ -536,6 +539,7 @@
|
||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
"form_label_person_type": "Type",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "First name",
|
||||
"form_label_last_name": "Last name",
|
||||
"form_label_alias": "Nickname / Alias",
|
||||
@@ -527,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Group",
|
||||
"person_type_UNKNOWN": "Unknown",
|
||||
"a11y_type_changed": "Type changed to {type}",
|
||||
"person_alias_add_heading": "Add name",
|
||||
"person_alias_label_type": "Type",
|
||||
"person_alias_label_last_name": "Last name",
|
||||
@@ -536,6 +539,7 @@
|
||||
"person_alias_delete_body": "This name will be removed from search results.",
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
"form_label_person_type": "Tipo",
|
||||
"form_label_name": "Nombre",
|
||||
"form_label_first_name": "Nombre",
|
||||
"form_label_last_name": "Apellido",
|
||||
"form_label_alias": "Apodo / Alias",
|
||||
@@ -527,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institución",
|
||||
"person_type_GROUP": "Grupo",
|
||||
"person_type_UNKNOWN": "Desconocido",
|
||||
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||
"person_alias_add_heading": "Agregar nombre",
|
||||
"person_alias_label_type": "Tipo",
|
||||
"person_alias_label_last_name": "Apellido",
|
||||
@@ -536,6 +539,7 @@
|
||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||
|
||||
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { radioGroupNav } = await import('./radioGroupNav');
|
||||
|
||||
describe('radioGroupNav action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
|
||||
const container = document.createElement('div');
|
||||
container.setAttribute('role', 'radiogroup');
|
||||
const buttons: HTMLElement[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
||||
container.appendChild(btn);
|
||||
buttons.push(btn);
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
nodes.push(container);
|
||||
return { container, buttons };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowRight wraps from last to first', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[3].focus();
|
||||
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ArrowLeft moves focus to previous button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[2].focus();
|
||||
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowLeft wraps from first to last', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[3]);
|
||||
});
|
||||
|
||||
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
||||
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('destroy removes keydown listener', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
const { destroy } = radioGroupNav(container);
|
||||
destroy();
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ignores non-arrow keys', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
});
|
||||
28
frontend/src/lib/actions/radioGroupNav.ts
Normal file
28
frontend/src/lib/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function radioGroupNav(node: HTMLElement): { destroy: () => void } {
|
||||
function getRadios(): HTMLElement[] {
|
||||
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||
|
||||
const radios = getRadios();
|
||||
const current = radios.indexOf(document.activeElement as HTMLElement);
|
||||
if (current === -1) return;
|
||||
|
||||
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||
const next = (current + delta + radios.length) % radios.length;
|
||||
|
||||
radios[current].setAttribute('aria-checked', 'false');
|
||||
radios[next].setAttribute('aria-checked', 'true');
|
||||
radios[next].focus();
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
}
|
||||
53
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
53
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
type PersonType = (typeof TYPES)[number];
|
||||
|
||||
let {
|
||||
value = 'PERSON',
|
||||
name = 'personType',
|
||||
onchange
|
||||
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
|
||||
|
||||
let selected = $state<PersonType>(
|
||||
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
|
||||
);
|
||||
|
||||
const labels: Record<PersonType, () => string> = {
|
||||
PERSON: m.person_type_PERSON,
|
||||
INSTITUTION: m.person_type_INSTITUTION,
|
||||
GROUP: m.person_type_GROUP,
|
||||
UNKNOWN: m.person_type_UNKNOWN
|
||||
};
|
||||
|
||||
function select(type: PersonType) {
|
||||
selected = type;
|
||||
onchange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div role="radiogroup" class="grid grid-cols-2 gap-2 sm:grid-cols-4" use:radioGroupNav>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
{labels[type]()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite">
|
||||
{m.a11y_type_changed({ type: labels[selected]() })}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonTypeSelector', () => {
|
||||
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = container.querySelectorAll('[role="radio"]');
|
||||
const selected = Array.from(buttons).find((b) => b.getAttribute('aria-checked') === 'true');
|
||||
expect(selected).not.toBeNull();
|
||||
expect(selected!.className).toContain('bg-primary');
|
||||
expect(selected!.className).toContain('text-primary-fg');
|
||||
});
|
||||
|
||||
it('unselected buttons use semantic bg-surface, text-ink, border-line classes', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = container.querySelectorAll('[role="radio"]');
|
||||
const unselected = Array.from(buttons).filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||
expect(unselected.length).toBeGreaterThan(0);
|
||||
for (const btn of unselected) {
|
||||
expect(btn.className).toContain('bg-surface');
|
||||
expect(btn.className).toContain('text-ink');
|
||||
expect(btn.className).toContain('border-line');
|
||||
}
|
||||
});
|
||||
|
||||
it('focus ring uses semantic ring-focus-ring class', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = container.querySelectorAll('[role="radio"]');
|
||||
for (const btn of buttons) {
|
||||
expect(btn.className).toContain('ring-focus-ring');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,6 @@ let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||
let dateIso = $state('');
|
||||
let tags = $state<Tag[]>([]);
|
||||
// Bulk-edit only — replace-on-non-blank semantics.
|
||||
let documentLocation = $state('');
|
||||
let archiveBox = $state('');
|
||||
let archiveFolder = $state('');
|
||||
|
||||
@@ -231,7 +230,6 @@ async function saveBulkEdit() {
|
||||
tagNames: tags.map((t) => t.name),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
documentLocation: documentLocation || null,
|
||||
archiveBox: archiveBox || null,
|
||||
archiveFolder: archiveFolder || null
|
||||
};
|
||||
@@ -442,7 +440,6 @@ async function retrySave() {
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:documentLocation={documentLocation}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
@@ -494,7 +491,6 @@ async function retrySave() {
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:documentLocation={documentLocation}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
|
||||
@@ -397,8 +397,8 @@ describe('BulkDocumentEditLayout — mode="edit"', () => {
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||
// sender + documentLocation + archiveBox + archiveFolder = 4
|
||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
|
||||
// sender + archiveBox + archiveFolder = 3
|
||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
||||
|
||||
@@ -11,7 +11,8 @@ let {
|
||||
archiveBox = $bindable(''),
|
||||
archiveFolder = $bindable(''),
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialArchiveBox = '',
|
||||
initialArchiveFolder = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false,
|
||||
suggestedTitle = '',
|
||||
@@ -24,7 +25,8 @@ let {
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialArchiveBox?: string;
|
||||
initialArchiveFolder?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
@@ -41,7 +43,8 @@ let {
|
||||
let titleDirty = $state(false);
|
||||
onMount(() => {
|
||||
if (!currentTitle && initialTitle) currentTitle = initialTitle;
|
||||
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
|
||||
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
|
||||
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
|
||||
});
|
||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||
</script>
|
||||
@@ -110,55 +113,36 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aufbewahrungsort (optional) -->
|
||||
<div data-testid="description-document-location">
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_archive_location()}
|
||||
<!-- Karton -->
|
||||
<div data-testid="description-archive-box">
|
||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_box()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="documentLocation"
|
||||
id="archiveBox"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
bind:value={documentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
name="archiveBox"
|
||||
bind:value={archiveBox}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
||||
</div>
|
||||
|
||||
{#if editMode}
|
||||
<!-- Karton (only in editMode — bulk-editable replace) -->
|
||||
<div data-testid="description-archive-box">
|
||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_box()}
|
||||
<FieldLabelBadge variant="replace" />
|
||||
</label>
|
||||
<input
|
||||
id="archiveBox"
|
||||
type="text"
|
||||
name="archiveBox"
|
||||
bind:value={archiveBox}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
||||
<div data-testid="description-archive-folder">
|
||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_folder()}
|
||||
<FieldLabelBadge variant="replace" />
|
||||
</label>
|
||||
<input
|
||||
id="archiveFolder"
|
||||
type="text"
|
||||
name="archiveFolder"
|
||||
bind:value={archiveFolder}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Mappe -->
|
||||
<div data-testid="description-archive-folder">
|
||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_folder()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="archiveFolder"
|
||||
type="text"
|
||||
name="archiveFolder"
|
||||
bind:value={archiveFolder}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,19 +21,10 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
|
||||
expect(titleInput.value).toBe('Parent Title');
|
||||
});
|
||||
|
||||
it('pre-fills the documentLocation input from initialDocumentLocation', async () => {
|
||||
render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' });
|
||||
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Schrank 3, Mappe B');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound documentLocation that is already non-empty', async () => {
|
||||
render(DescriptionSection, {
|
||||
documentLocation: 'Bound Value',
|
||||
initialDocumentLocation: 'Should Not Win'
|
||||
});
|
||||
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Bound Value');
|
||||
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
|
||||
render(DescriptionSection, { editMode: false });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
|
||||
@@ -42,9 +33,25 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the editMode-only archive fields when editMode=false', async () => {
|
||||
render(DescriptionSection, { editMode: false });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull();
|
||||
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
|
||||
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
|
||||
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||
expect(input.value).toBe('K-03');
|
||||
});
|
||||
|
||||
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
|
||||
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
|
||||
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
|
||||
expect(input.value).toBe('Mappe B');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
|
||||
render(DescriptionSection, {
|
||||
archiveBox: 'Parent Value',
|
||||
initialArchiveBox: 'Should Not Win',
|
||||
hideTitle: true
|
||||
});
|
||||
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||
expect(input.value).toBe('Parent Value');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +207,8 @@ async function handleReplaceFile(e: Event) {
|
||||
bind:tags={tags}
|
||||
bind:currentTitle={currentTitle}
|
||||
initialTitle={doc.title ?? ''}
|
||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
||||
initialArchiveBox={doc.archiveBox ?? ''}
|
||||
initialArchiveFolder={doc.archiveFolder ?? ''}
|
||||
initialSummary={doc.summary ?? ''}
|
||||
titleRequired={true}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'INVALID_PERSON_TYPE'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
@@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_person_not_found();
|
||||
case 'ALIAS_NOT_FOUND':
|
||||
return m.error_alias_not_found();
|
||||
case 'INVALID_PERSON_TYPE':
|
||||
return m.error_invalid_person_type();
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
|
||||
38
frontend/src/lib/person-validation.test.ts
Normal file
38
frontend/src/lib/person-validation.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validatePersonFields } from './person-validation';
|
||||
|
||||
describe('validatePersonFields', () => {
|
||||
it('returns null when all required fields are present for PERSON', () => {
|
||||
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns lastName error when lastName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('Nachname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
it('returns lastName error when lastName is undefined', () => {
|
||||
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
|
||||
'Nachname ist Pflichtfeld.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns firstName error when type is PERSON and firstName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('Vorname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
it('returns firstName error when type is PERSON and firstName is undefined', () => {
|
||||
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe('Vorname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
it('returns null for INSTITUTION without firstName', () => {
|
||||
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for GROUP without firstName', () => {
|
||||
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for UNKNOWN without firstName', () => {
|
||||
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
|
||||
});
|
||||
});
|
||||
9
frontend/src/lib/person-validation.ts
Normal file
9
frontend/src/lib/person-validation.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function validatePersonFields(
|
||||
personType: string,
|
||||
firstName: string | undefined | null,
|
||||
lastName: string | undefined | null
|
||||
): string | null {
|
||||
if (!lastName) return 'Nachname ist Pflichtfeld.';
|
||||
if (personType === 'PERSON' && !firstName) return 'Vorname ist Pflichtfeld.';
|
||||
return null;
|
||||
}
|
||||
@@ -13,14 +13,12 @@ let {
|
||||
items,
|
||||
canWrite,
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort = 'DATE'
|
||||
}: {
|
||||
items: DocumentSearchItem[];
|
||||
canWrite: boolean;
|
||||
error?: string | null;
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: SortMode;
|
||||
} = $props();
|
||||
@@ -71,29 +69,6 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- RESULT COUNT -->
|
||||
{#if total > 0}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
|
||||
{/if}
|
||||
|
||||
<!-- ERROR -->
|
||||
{#if error}
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
|
||||
@@ -234,28 +234,54 @@ $effect(() => {
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if data.canWrite && data.totalElements > 0}
|
||||
<div class="mb-2 flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<p class="font-sans text-base text-ink-2">
|
||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||
</p>
|
||||
{#if data.canWrite}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.totalElements > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.totalElements}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
|
||||
@@ -85,7 +85,8 @@ export const actions = {
|
||||
if (doc.title) formData.set('title', doc.title);
|
||||
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
||||
if (doc.location) formData.set('location', doc.location);
|
||||
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
|
||||
if (doc.archiveBox) formData.set('archiveBox', doc.archiveBox);
|
||||
if (doc.archiveFolder) formData.set('archiveFolder', doc.archiveFolder);
|
||||
if (doc.transcription) formData.set('transcription', doc.transcription);
|
||||
if (doc.summary) formData.set('summary', doc.summary);
|
||||
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
||||
|
||||
@@ -13,6 +13,7 @@ let {
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
personType?: string | null;
|
||||
title?: string | null;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
@@ -66,6 +67,15 @@ let {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title (academic/honorific) — PERSON type only -->
|
||||
{#if person.personType === 'PERSON' && person.title}
|
||||
<p
|
||||
class="mb-0.5 text-center font-sans text-xs tracking-widest text-ink-3 [font-variant:small-caps]"
|
||||
>
|
||||
{person.title}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Name — centered, serif -->
|
||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||
{person.displayName}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import { validatePersonFields } from '$lib/person-validation';
|
||||
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN' | 'SKIP';
|
||||
|
||||
export function normalizePersonType(raw: string | undefined | null): Exclude<PersonType, 'SKIP'> {
|
||||
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as Exclude<PersonType, 'SKIP'>);
|
||||
}
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
const canWrite =
|
||||
@@ -22,12 +29,20 @@ export async function load({ params, fetch, locals }) {
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { person: result.data!, aliases: aliasesResult.data ?? [] };
|
||||
const person = result.data!;
|
||||
const personType = normalizePersonType(person.personType);
|
||||
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const personType = (formData.get('personType')?.toString() ?? 'PERSON') as
|
||||
| 'PERSON'
|
||||
| 'INSTITUTION'
|
||||
| 'GROUP'
|
||||
| 'UNKNOWN';
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
@@ -37,15 +52,18 @@ export const actions = {
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
const validationError = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationError) {
|
||||
return fail(400, { updateError: validationError });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/persons/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
firstName,
|
||||
personType,
|
||||
...(title ? { title } : {}),
|
||||
...(firstName ? { firstName } : {}),
|
||||
lastName,
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN';
|
||||
|
||||
let {
|
||||
person
|
||||
}: {
|
||||
person: {
|
||||
personType?: string | null;
|
||||
title?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
alias?: string | null;
|
||||
@@ -13,25 +19,68 @@ let {
|
||||
notes?: string | null;
|
||||
};
|
||||
} = $props();
|
||||
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
let selectedType = $state<PersonType>(
|
||||
untrack(() =>
|
||||
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
|
||||
)
|
||||
);
|
||||
|
||||
const isPerson = $derived(selectedType === 'PERSON');
|
||||
const lastNameLabel = $derived(
|
||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||
? m.form_label_name()
|
||||
: m.form_label_last_name()
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
<div class="md:col-span-2">
|
||||
<p class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_person_type()}
|
||||
</p>
|
||||
<PersonTypeSelector
|
||||
value={selectedType}
|
||||
name="personType"
|
||||
onchange={(type: PersonType) => (selectedType = type)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
{#if isPerson}
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_title()}</label
|
||||
>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
value={person.title ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="firstName"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>{lastNameLabel} *</label
|
||||
>
|
||||
<input
|
||||
id="lastName"
|
||||
@@ -42,48 +91,56 @@ let {
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isPerson}
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="birthYear"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="deathYear"
|
||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_notes()}</label
|
||||
|
||||
28
frontend/src/routes/persons/[id]/edit/page.server.test.ts
Normal file
28
frontend/src/routes/persons/[id]/edit/page.server.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizePersonType } from './+page.server';
|
||||
|
||||
describe('edit load — SKIP → UNKNOWN normalization', () => {
|
||||
it('maps SKIP to UNKNOWN', () => {
|
||||
expect(normalizePersonType('SKIP')).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('passes PERSON through unchanged', () => {
|
||||
expect(normalizePersonType('PERSON')).toBe('PERSON');
|
||||
});
|
||||
|
||||
it('passes INSTITUTION through unchanged', () => {
|
||||
expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION');
|
||||
});
|
||||
|
||||
it('passes GROUP through unchanged', () => {
|
||||
expect(normalizePersonType('GROUP')).toBe('GROUP');
|
||||
});
|
||||
|
||||
it('passes UNKNOWN through unchanged', () => {
|
||||
expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('defaults null to PERSON', () => {
|
||||
expect(normalizePersonType(null)).toBe('PERSON');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import { validatePersonFields } from '$lib/person-validation';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
const canWrite =
|
||||
@@ -12,6 +14,12 @@ export async function load({ locals }: { locals: App.Locals }) {
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const personType = (formData.get('personType')?.toString() ?? 'PERSON') as
|
||||
| 'PERSON'
|
||||
| 'INSTITUTION'
|
||||
| 'GROUP'
|
||||
| 'UNKNOWN';
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
@@ -19,8 +27,16 @@ export const actions = {
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
const validationError = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationError) {
|
||||
return fail(400, {
|
||||
error: validationError,
|
||||
personType,
|
||||
title,
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
alias
|
||||
});
|
||||
}
|
||||
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
@@ -29,8 +45,10 @@ export const actions = {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons', {
|
||||
body: {
|
||||
firstName,
|
||||
lastName,
|
||||
personType,
|
||||
...(title ? { title } : {}),
|
||||
...(firstName ? { firstName } : {}),
|
||||
lastName: lastName!,
|
||||
...(alias ? { alias } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {}),
|
||||
@@ -39,7 +57,15 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(code),
|
||||
personType,
|
||||
title,
|
||||
firstName,
|
||||
lastName: lastName!,
|
||||
alias
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${result.data!.id}`);
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN';
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let selectedType = $state<PersonType>(
|
||||
untrack(() =>
|
||||
TYPES.includes((form?.personType as PersonType) ?? 'PERSON')
|
||||
? ((form?.personType as PersonType) ?? 'PERSON')
|
||||
: 'PERSON'
|
||||
)
|
||||
);
|
||||
|
||||
const isPerson = $derived(selectedType === 'PERSON');
|
||||
const lastNameLabel = $derived(
|
||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||
? m.form_label_name()
|
||||
: m.form_label_last_name()
|
||||
);
|
||||
|
||||
const inputCls =
|
||||
'block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<BackButton />
|
||||
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
||||
@@ -22,79 +46,92 @@ let { form } = $props();
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
<div class="md:col-span-2">
|
||||
<p class={labelCls}>{m.form_label_person_type()}</p>
|
||||
<PersonTypeSelector
|
||||
value={selectedType}
|
||||
name="personType"
|
||||
onchange={(type: PersonType) => (selectedType = type)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>
|
||||
{#if isPerson}
|
||||
<div>
|
||||
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
value={form?.title ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={form?.firstName ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
value={form?.lastName ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isPerson}
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
placeholder={m.form_placeholder_alias()}
|
||||
value={form?.alias ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
placeholder={m.form_placeholder_alias()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_notes()}</label
|
||||
>
|
||||
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
@@ -106,7 +143,6 @@ let { form } = $props();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Bar -->
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user