feat(persons): surface personType + title in forms and detail card #333
@@ -63,27 +63,33 @@ public class PersonController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.createPerson(dto));
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
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")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
|
@NotNull
|
||||||
|
private PersonType personType;
|
||||||
@Size(max = 50)
|
@Size(max = 50)
|
||||||
private String title;
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
|||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
/** A person name alias with the given ID does not exist. 404 */
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
|
INVALID_PERSON_TYPE,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(PersonUpdateDTO dto) {
|
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());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
@@ -136,9 +140,13 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
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());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + 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.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
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.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +229,53 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
.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())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,10 +288,10 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +300,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +313,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
}
|
}
|
||||||
@@ -317,11 +363,10 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
// firstName valid, lastName blank → second || operand = true → 400
|
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +384,7 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
"\"notes\":\"Some notes\"}"))
|
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
@@ -355,7 +400,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.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());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +411,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +422,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +431,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,43 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
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) ─────────────────────────────
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.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) ─────────────────────────────────────────────────
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
|
"form_label_person_type": "Typ",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "Vorname",
|
"form_label_first_name": "Vorname",
|
||||||
"form_label_last_name": "Nachname",
|
"form_label_last_name": "Nachname",
|
||||||
"form_label_alias": "Rufname / Alias",
|
"form_label_alias": "Rufname / Alias",
|
||||||
@@ -527,6 +529,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Gruppe",
|
"person_type_GROUP": "Gruppe",
|
||||||
"person_type_UNKNOWN": "Unbekannt",
|
"person_type_UNKNOWN": "Unbekannt",
|
||||||
|
"a11y_type_changed": "Typ geändert zu {type}",
|
||||||
"person_alias_add_heading": "Name hinzufuegen",
|
"person_alias_add_heading": "Name hinzufuegen",
|
||||||
"person_alias_label_type": "Art",
|
"person_alias_label_type": "Art",
|
||||||
"person_alias_label_last_name": "Nachname",
|
"person_alias_label_last_name": "Nachname",
|
||||||
@@ -536,6 +539,9 @@
|
|||||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
"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.",
|
"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_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
|
"form_label_person_type": "Type",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "First name",
|
"form_label_first_name": "First name",
|
||||||
"form_label_last_name": "Last name",
|
"form_label_last_name": "Last name",
|
||||||
"form_label_alias": "Nickname / Alias",
|
"form_label_alias": "Nickname / Alias",
|
||||||
@@ -527,6 +529,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Group",
|
"person_type_GROUP": "Group",
|
||||||
"person_type_UNKNOWN": "Unknown",
|
"person_type_UNKNOWN": "Unknown",
|
||||||
|
"a11y_type_changed": "Type changed to {type}",
|
||||||
"person_alias_add_heading": "Add name",
|
"person_alias_add_heading": "Add name",
|
||||||
"person_alias_label_type": "Type",
|
"person_alias_label_type": "Type",
|
||||||
"person_alias_label_last_name": "Last name",
|
"person_alias_label_last_name": "Last name",
|
||||||
@@ -536,6 +539,9 @@
|
|||||||
"person_alias_delete_body": "This name will be removed from search results.",
|
"person_alias_delete_body": "This name will be removed from search results.",
|
||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
|
"validation_last_name_required": "Last name is required.",
|
||||||
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
"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.",
|
"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_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
|
"form_label_person_type": "Tipo",
|
||||||
|
"form_label_name": "Nombre",
|
||||||
"form_label_first_name": "Nombre",
|
"form_label_first_name": "Nombre",
|
||||||
"form_label_last_name": "Apellido",
|
"form_label_last_name": "Apellido",
|
||||||
"form_label_alias": "Apodo / Alias",
|
"form_label_alias": "Apodo / Alias",
|
||||||
@@ -527,6 +529,7 @@
|
|||||||
"person_type_INSTITUTION": "Institución",
|
"person_type_INSTITUTION": "Institución",
|
||||||
"person_type_GROUP": "Grupo",
|
"person_type_GROUP": "Grupo",
|
||||||
"person_type_UNKNOWN": "Desconocido",
|
"person_type_UNKNOWN": "Desconocido",
|
||||||
|
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||||
"person_alias_add_heading": "Agregar nombre",
|
"person_alias_add_heading": "Agregar nombre",
|
||||||
"person_alias_label_type": "Tipo",
|
"person_alias_label_type": "Tipo",
|
||||||
"person_alias_label_last_name": "Apellido",
|
"person_alias_label_last_name": "Apellido",
|
||||||
@@ -536,6 +539,9 @@
|
|||||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
"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.",
|
"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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function radioGroupNav(
|
||||||
|
node: HTMLElement,
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||||
|
let onChangeFn = onChange;
|
||||||
|
|
||||||
|
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();
|
||||||
|
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOnChange) {
|
||||||
|
onChangeFn = newOnChange;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('keydown', handleKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
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;
|
||||||
|
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||||
|
onchange?.(type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={m.form_label_person_type()}
|
||||||
|
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
|
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||||
|
>
|
||||||
|
{#each TYPES as type (type)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={type}
|
||||||
|
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" aria-atomic="true">{announcement}</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
|
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('PersonTypeSelector', () => {
|
||||||
|
it('radiogroup has an accessible name via aria-label', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||||
|
expect(radiogroup).not.toBeNull();
|
||||||
|
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
it('exactly one button is aria-checked=true for the initial value', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
||||||
|
expect(checked).toHaveLength(1);
|
||||||
|
expect(unchecked).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-checked=true moves to clicked button on click', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
||||||
|
await userEvent.click(groupButton);
|
||||||
|
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
||||||
|
const others = buttons.filter((b) => b !== groupButton);
|
||||||
|
for (const btn of others) {
|
||||||
|
expect(btn.getAttribute('aria-checked')).toBe('false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||||
|
expect(selected!.getAttribute('tabindex')).toBe('0');
|
||||||
|
for (const btn of unselected) {
|
||||||
|
expect(btn.getAttribute('tabindex')).toBe('-1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
|
|||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
| 'ALIAS_NOT_FOUND'
|
| 'ALIAS_NOT_FOUND'
|
||||||
|
| 'INVALID_PERSON_TYPE'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_person_not_found();
|
return m.error_person_not_found();
|
||||||
case 'ALIAS_NOT_FOUND':
|
case 'ALIAS_NOT_FOUND':
|
||||||
return m.error_alias_not_found();
|
return m.error_alias_not_found();
|
||||||
|
case 'INVALID_PERSON_TYPE':
|
||||||
|
return m.error_invalid_person_type();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
case 'DOCUMENT_NO_FILE':
|
||||||
|
|||||||
40
frontend/src/lib/person-validation.test.ts
Normal file
40
frontend/src/lib/person-validation.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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 key when lastName is missing', () => {
|
||||||
|
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lastName error key when lastName is undefined', () => {
|
||||||
|
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
|
||||||
|
'validation_last_name_required'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName error key when type is PERSON and firstName is missing', () => {
|
||||||
|
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
|
||||||
|
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
|
||||||
|
'validation_first_name_required'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/lib/person-validation.ts
Normal file
39
frontend/src/lib/person-validation.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||||
|
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||||
|
|
||||||
|
export type PersonFormData = {
|
||||||
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
alias?: string | null;
|
||||||
|
birthYear?: number | null;
|
||||||
|
deathYear?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
||||||
|
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonValidationKey =
|
||||||
|
| 'validation_last_name_required'
|
||||||
|
| 'validation_first_name_required';
|
||||||
|
|
||||||
|
export function resolveValidationMessage(key: PersonValidationKey): string {
|
||||||
|
return key === 'validation_last_name_required'
|
||||||
|
? m.validation_last_name_required()
|
||||||
|
: m.validation_first_name_required();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePersonFields(
|
||||||
|
personType: string,
|
||||||
|
firstName: string | undefined | null,
|
||||||
|
lastName: string | undefined | null
|
||||||
|
): PersonValidationKey | null {
|
||||||
|
if (!lastName) return 'validation_last_name_required';
|
||||||
|
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ let {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
personType?: string | null;
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthYear?: number | null;
|
||||||
deathYear?: number | null;
|
deathYear?: number | null;
|
||||||
@@ -66,6 +67,14 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#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 -->
|
<!-- Name — centered, serif -->
|
||||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import {
|
||||||
|
normalizePersonType,
|
||||||
|
validatePersonFields,
|
||||||
|
resolveValidationMessage
|
||||||
|
} from '$lib/person-validation';
|
||||||
|
|
||||||
export async function load({ params, fetch, locals }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
@@ -22,12 +27,16 @@ export async function load({ params, fetch, locals }) {
|
|||||||
throw error(result.response.status, getErrorMessage(code));
|
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 = {
|
export const actions = {
|
||||||
update: async ({ request, params, fetch }) => {
|
update: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||||
|
const title = formData.get('title')?.toString().trim() || undefined;
|
||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
@@ -37,15 +46,18 @@ export const actions = {
|
|||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
if (validationKey) {
|
||||||
|
return fail(400, { updateError: resolveValidationMessage(validationKey) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.PUT('/api/persons/{id}', {
|
const result = await api.PUT('/api/persons/{id}', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: {
|
body: {
|
||||||
firstName,
|
personType,
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(firstName ? { firstName } : {}),
|
||||||
lastName,
|
lastName,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
|
|||||||
@@ -1,93 +1,117 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||||
|
import {
|
||||||
|
PERSON_TYPES as TYPES,
|
||||||
|
type PersonType,
|
||||||
|
type PersonFormData
|
||||||
|
} from '$lib/person-validation';
|
||||||
|
|
||||||
let {
|
let { person }: { person: PersonFormData } = $props();
|
||||||
person
|
|
||||||
}: {
|
let selectedType = $state<PersonType>(
|
||||||
person: {
|
untrack(() =>
|
||||||
firstName?: string | null;
|
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
|
||||||
lastName: string;
|
)
|
||||||
alias?: string | null;
|
);
|
||||||
birthYear?: number | null;
|
|
||||||
deathYear?: number | null;
|
const isPerson = $derived(selectedType === 'PERSON');
|
||||||
notes?: string | null;
|
const lastNameLabel = $derived(
|
||||||
};
|
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||||
} = $props();
|
? m.form_label_name()
|
||||||
|
: m.form_label_last_name()
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelCls = 'mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase';
|
||||||
|
const inputCls =
|
||||||
|
'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';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div class="md:col-span-2">
|
||||||
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
<p class={labelCls}>
|
||||||
>{m.form_label_first_name()} *</label
|
{m.form_label_person_type()}
|
||||||
>
|
</p>
|
||||||
<input
|
<PersonTypeSelector
|
||||||
id="firstName"
|
value={selectedType}
|
||||||
name="firstName"
|
name="personType"
|
||||||
type="text"
|
onchange={(type: PersonType) => (selectedType = type)}
|
||||||
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>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
{#if isPerson}
|
||||||
>{m.form_label_last_name()} *</label
|
<div>
|
||||||
>
|
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
maxlength="50"
|
||||||
|
value={person.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={person.firstName ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||||
|
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.lastName}
|
value={person.lastName}
|
||||||
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"
|
class={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" value={person.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()}
|
||||||
|
value={person.birthYear ?? ''}
|
||||||
|
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()}
|
||||||
|
value={person.deathYear ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||||
>{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>
|
|
||||||
<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
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizePersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
|
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,11 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import {
|
||||||
|
normalizePersonType,
|
||||||
|
validatePersonFields,
|
||||||
|
resolveValidationMessage
|
||||||
|
} from '$lib/person-validation';
|
||||||
|
|
||||||
export async function load({ locals }: { locals: App.Locals }) {
|
export async function load({ locals }: { locals: App.Locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
@@ -12,6 +18,8 @@ export async function load({ locals }: { locals: App.Locals }) {
|
|||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||||
|
const title = formData.get('title')?.toString().trim() || undefined;
|
||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
@@ -19,8 +27,16 @@ export const actions = {
|
|||||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
|
if (validationKey) {
|
||||||
|
return fail(400, {
|
||||||
|
error: resolveValidationMessage(validationKey),
|
||||||
|
personType,
|
||||||
|
title,
|
||||||
|
firstName: firstName ?? '',
|
||||||
|
lastName: lastName ?? '',
|
||||||
|
alias
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||||
@@ -29,8 +45,10 @@ export const actions = {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.POST('/api/persons', {
|
const result = await api.POST('/api/persons', {
|
||||||
body: {
|
body: {
|
||||||
firstName,
|
personType,
|
||||||
lastName,
|
...(title ? { title } : {}),
|
||||||
|
...(firstName ? { firstName } : {}),
|
||||||
|
lastName: lastName!,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(birthYear ? { birthYear } : {}),
|
||||||
...(deathYear ? { deathYear } : {}),
|
...(deathYear ? { deathYear } : {}),
|
||||||
@@ -39,7 +57,15 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
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}`);
|
throw redirect(303, `/persons/${result.data!.id}`);
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||||
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
let { form } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||||
<!-- Heading -->
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
||||||
@@ -22,79 +44,92 @@ let { form } = $props();
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<div>
|
<div class="md:col-span-2">
|
||||||
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
|
<p class={labelCls}>{m.form_label_person_type()}</p>
|
||||||
>{m.form_label_first_name()} *</label
|
<PersonTypeSelector
|
||||||
>
|
value={selectedType}
|
||||||
<input
|
name="personType"
|
||||||
id="firstName"
|
onchange={(type: PersonType) => (selectedType = type)}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div>
|
{#if isPerson}
|
||||||
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
|
<div>
|
||||||
>{m.form_label_last_name()} *</label
|
<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
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
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>
|
</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">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||||
>{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
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
@@ -106,7 +141,6 @@ let { form } = $props();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Bar -->
|
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
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