Compare commits
23 Commits
feat/issue
...
f5eb14a76d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5eb14a76d | ||
|
|
00af97653d | ||
|
|
f9a982db43 | ||
|
|
8e1733abbf | ||
|
|
842ab28f59 | ||
|
|
0c47c22185 | ||
|
|
d97cd06f70 | ||
|
|
b41405cb4b | ||
|
|
c9e9300216 | ||
|
|
92b082ec62 | ||
|
|
8770ca874b | ||
|
|
e54240ea1b | ||
|
|
437144174c | ||
|
|
fe830ad64b | ||
|
|
8f75552503 | ||
|
|
e7573bbeda | ||
|
|
bf31380141 | ||
|
|
58b3dabea2 | ||
|
|
60a278ad8e | ||
|
|
39f722fec0 | ||
|
|
fef021bf51 | ||
|
|
6c117611b8 | ||
|
|
aac8250af0 |
@@ -1,3 +0,0 @@
|
|||||||
### Mark all blocks as reviewed
|
|
||||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
|
||||||
Authorization: Basic admin admin123
|
|
||||||
@@ -26,16 +26,7 @@ public enum AuditKind {
|
|||||||
COMMENT_ADDED,
|
COMMENT_ADDED,
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||||
MENTION_CREATED,
|
MENTION_CREATED;
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_CREATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_DELETED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
|
||||||
GROUP_MEMBERSHIP_CHANGED;
|
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -199,6 +197,4 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|||||||
ORDER BY ranked.document_id, ranked.rn
|
ORDER BY ranked.document_id, ranked.rn
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||||
|
|
||||||
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuditLogQueryService {
|
public class AuditLogQueryService {
|
||||||
@@ -57,11 +51,6 @@ public class AuditLogQueryService {
|
|||||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AuditLog> findRecentUserManagementEvents(int limit) {
|
|
||||||
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
|
|
||||||
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||||
for (ContributorRow row : rows) {
|
for (ContributorRow row : rows) {
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
boolean existsByKind(AuditKind kind);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,15 +90,6 @@ public class TranscriptionBlockController {
|
|||||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/review-all")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
|
||||||
@PathVariable UUID documentId,
|
|
||||||
Authentication authentication) {
|
|
||||||
UUID userId = requireUserId(authentication);
|
|
||||||
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{blockId}/history")
|
@GetMapping("/{blockId}/history")
|
||||||
@RequirePermission(Permission.READ_ALL)
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||||
|
|||||||
@@ -78,31 +78,24 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||||
@Valid @RequestBody CreateUserRequest request) {
|
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||||
@PathVariable UUID id,
|
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||||
@PathVariable UUID id) {
|
userService.deleteUser(id);
|
||||||
userService.deleteUser(actorId(authentication), id);
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
THEN to_tsquery('german', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
FROM documents d
|
FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
THEN to_tsquery('german', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
|
|||||||
@@ -205,18 +205,6 @@ public class TranscriptionService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
|
||||||
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
|
||||||
for (TranscriptionBlock block : blocks) {
|
|
||||||
if (!block.isReviewed()) {
|
|
||||||
block.setReviewed(true);
|
|
||||||
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return blockRepository.saveAll(blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||||
getBlock(documentId, blockId);
|
getBlock(documentId, blockId);
|
||||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -23,13 +21,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static java.util.stream.Collectors.toSet;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -38,10 +33,9 @@ public class UserService {
|
|||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuditService auditService;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||||
log.info("Creating or updating user: {}", request.getEmail());
|
log.info("Creating or updating user: {}", request.getEmail());
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
@@ -51,12 +45,10 @@ public class UserService {
|
|||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
AppUser user;
|
AppUser user;
|
||||||
boolean isNew;
|
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser.isPresent()) {
|
||||||
log.info("User exists, updating: {}", request.getEmail());
|
log.info("User exists, updating: {}", request.getEmail());
|
||||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
isNew = false;
|
|
||||||
} else {
|
} else {
|
||||||
log.info("Creating new user: {}", request.getEmail());
|
log.info("Creating new user: {}", request.getEmail());
|
||||||
user = AppUser.builder()
|
user = AppUser.builder()
|
||||||
@@ -69,42 +61,8 @@ public class UserService {
|
|||||||
.contact(request.getContact())
|
.contact(request.getContact())
|
||||||
.enabled(true)
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
isNew = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppUser saved = userRepository.save(user);
|
|
||||||
if (isNew) {
|
|
||||||
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
|
||||||
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
|
||||||
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
|
||||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
|
||||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
|
||||||
if (existingUser.isPresent()) {
|
|
||||||
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
|
||||||
return userRepository.save(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
AppUser user = AppUser.builder()
|
|
||||||
.email(request.getEmail())
|
|
||||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
|
||||||
.groups(groups)
|
|
||||||
.firstName(request.getFirstName())
|
|
||||||
.lastName(request.getLastName())
|
|
||||||
.birthDate(request.getBirthDate())
|
|
||||||
.contact(request.getContact())
|
|
||||||
.enabled(true)
|
|
||||||
.build();
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +94,10 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteUser(UUID actorId, UUID userId) {
|
public void deleteUser(UUID userId) {
|
||||||
AppUser user = userRepository.findById(userId)
|
AppUser user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||||
String email = user.getEmail();
|
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
|
||||||
Map.of("userId", userId.toString(), "email", email));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser getById(UUID id) {
|
public AppUser getById(UUID id) {
|
||||||
@@ -186,7 +141,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||||
AppUser user = getById(id);
|
AppUser user = getById(id);
|
||||||
|
|
||||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||||
@@ -211,27 +166,13 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.getGroupIds() != null) {
|
if (dto.getGroupIds() != null) {
|
||||||
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||||
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
user.setGroups(groups);
|
||||||
user.setGroups(after);
|
|
||||||
groupChangePayload(before, after, id, user.getEmail())
|
|
||||||
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<Map<String, Object>> groupChangePayload(
|
|
||||||
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
|
|
||||||
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
|
|
||||||
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
|
|
||||||
if (beforeIds.equals(afterIds)) return Optional.empty();
|
|
||||||
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
|
|
||||||
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
|
|
||||||
return Optional.of(Map.of("userId", userId.toString(), "email", email,
|
|
||||||
"addedGroups", added, "removedGroups", removed));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -6,19 +6,12 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.springframework.data.domain.PageImpl;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -54,21 +47,4 @@ class AuditLogQueryServiceTest {
|
|||||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
|
|
||||||
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
|
|
||||||
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
|
|
||||||
.thenReturn(new PageImpl<>(List.of(entry)));
|
|
||||||
|
|
||||||
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
|
|
||||||
|
|
||||||
assertThat(result).containsExactly(entry);
|
|
||||||
verify(queryRepository).findByKindIn(
|
|
||||||
argThat((Collection<AuditKind> kinds) ->
|
|
||||||
kinds.contains(AuditKind.USER_CREATED) &&
|
|
||||||
kinds.contains(AuditKind.USER_DELETED) &&
|
|
||||||
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
|
|
||||||
any(Pageable.class));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.GroupDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.UserGroup;
|
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.awaitility.Awaitility.await;
|
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
class UserManagementAuditIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
|
||||||
@Autowired UserService userService;
|
|
||||||
@Autowired AppUserRepository userRepository;
|
|
||||||
@Autowired AuditLogRepository auditLogRepository;
|
|
||||||
@Autowired AuditLogQueryService auditLogQueryService;
|
|
||||||
@Autowired TransactionTemplate transactionTemplate;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void clearAuditLog() {
|
|
||||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createAndDeleteUser_producesOrderedAuditEntries() {
|
|
||||||
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
|
||||||
CreateUserRequest adminReq = new CreateUserRequest();
|
|
||||||
adminReq.setEmail("admin@test.example.com");
|
|
||||||
adminReq.setInitialPassword("admin-secret");
|
|
||||||
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
|
|
||||||
UUID actorId = actor.getId();
|
|
||||||
|
|
||||||
// Create the target user — should emit USER_CREATED
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("audit-test@example.com");
|
|
||||||
req.setInitialPassword("secret");
|
|
||||||
transactionTemplate.execute(status -> {
|
|
||||||
userService.createUserOrUpdate(actorId, req);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
|
||||||
|
|
||||||
// Delete the target user — should emit USER_DELETED
|
|
||||||
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
|
||||||
transactionTemplate.execute(status -> {
|
|
||||||
userService.deleteUser(actorId, created.getId());
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
|
|
||||||
|
|
||||||
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
|
||||||
assertThat(events).hasSize(2);
|
|
||||||
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
|
|
||||||
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateUserGroups_producesGroupMembershipChangedEvent() {
|
|
||||||
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
|
|
||||||
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
|
|
||||||
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
|
|
||||||
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
|
|
||||||
|
|
||||||
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
|
||||||
CreateUserRequest actorReq = new CreateUserRequest();
|
|
||||||
actorReq.setEmail("actor-group-test@test.example.com");
|
|
||||||
actorReq.setInitialPassword("secret");
|
|
||||||
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
|
|
||||||
|
|
||||||
// Create target user pre-assigned to gA — emits USER_CREATED
|
|
||||||
CreateUserRequest targetReq = new CreateUserRequest();
|
|
||||||
targetReq.setEmail("target-group-test@test.example.com");
|
|
||||||
targetReq.setInitialPassword("secret");
|
|
||||||
targetReq.setGroupIds(List.of(gA.getId()));
|
|
||||||
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
|
|
||||||
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
|
||||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
|
||||||
|
|
||||||
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
|
|
||||||
|
|
||||||
// Change groups: Viewers → Editors
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
dto.setGroupIds(List.of(gB.getId()));
|
|
||||||
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
|
|
||||||
|
|
||||||
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
|
|
||||||
|
|
||||||
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
|
||||||
assertThat(events).hasSize(1);
|
|
||||||
AuditLog event = events.get(0);
|
|
||||||
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
|
|
||||||
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> added = (List<String>) event.getPayload().get("addedGroups");
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
|
|
||||||
assertThat(added).containsExactlyInAnyOrder("Editors");
|
|
||||||
assertThat(removed).containsExactlyInAnyOrder("Viewers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -154,13 +154,6 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
|
|||||||
@@ -260,13 +260,6 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
@@ -380,63 +373,4 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
|
||||||
.text("Block 1").sortOrder(0).reviewed(true).build();
|
|
||||||
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
|
||||||
.text("Block 2").sortOrder(1).reviewed(true).build();
|
|
||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
|
||||||
.thenReturn(List.of(b1, b2));
|
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$").isArray())
|
|
||||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
|
||||||
.andExpect(jsonPath("$[1].reviewed").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$").isArray())
|
|
||||||
.andExpect(jsonPath("$").isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -106,55 +104,4 @@ class UserControllerTest {
|
|||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── permission enforcement ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users")
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── unauthenticated access ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users")
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,22 +179,6 @@ class DocumentFtsTest {
|
|||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
|
||||||
// "Wille" stems to "will" via the German Snowball stemmer.
|
|
||||||
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
|
||||||
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
|
||||||
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
|
||||||
UUID annotationId = annotation(doc.getId());
|
|
||||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
|
|||||||
@@ -506,86 +506,4 @@ class TranscriptionServiceTest {
|
|||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
|
||||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
|
||||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
|
||||||
.thenReturn(List.of(block1, block2));
|
|
||||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
|
||||||
|
|
||||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
|
||||||
verify(blockRepository).saveAll(List.of(block1, block2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
|
||||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
|
||||||
.thenReturn(List.of(block));
|
|
||||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
|
||||||
|
|
||||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
|
||||||
verify(blockRepository).saveAll(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
|
||||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
|
||||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
|
||||||
.thenReturn(List.of(block1, block2));
|
|
||||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
|
||||||
|
|
||||||
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
|
||||||
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
|
||||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
|
||||||
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
|
||||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
|
||||||
|
|
||||||
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
|
||||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -37,7 +34,6 @@ class UserServiceTest {
|
|||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock AuditService auditService;
|
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
|
|
||||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||||
@@ -65,7 +61,7 @@ class UserServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ class UserServiceTest {
|
|||||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
userService.deleteUser(UUID.randomUUID(), id);
|
userService.deleteUser(id);
|
||||||
|
|
||||||
verify(userRepository).delete(user);
|
verify(userRepository).delete(user);
|
||||||
}
|
}
|
||||||
@@ -94,7 +90,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
AppUser result = userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(userRepository).save(any());
|
verify(userRepository).save(any());
|
||||||
@@ -112,7 +108,7 @@ class UserServiceTest {
|
|||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(userRepository, times(1)).save(existing);
|
verify(userRepository, times(1)).save(existing);
|
||||||
}
|
}
|
||||||
@@ -233,7 +229,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
@@ -250,7 +246,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada");
|
dto.setFirstName("Ada");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
}
|
}
|
||||||
@@ -268,7 +264,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
}
|
}
|
||||||
@@ -285,7 +281,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of());
|
dto.setGroupIds(List.of());
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).isEmpty();
|
assertThat(result.getGroups()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -317,7 +313,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
AppUser result = userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
@@ -382,7 +378,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword("newSecret");
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
}
|
}
|
||||||
@@ -397,7 +393,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword(" ");
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("original");
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
verify(passwordEncoder, never()).encode(any());
|
verify(passwordEncoder, never()).encode(any());
|
||||||
@@ -412,7 +408,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(" ");
|
dto.setEmail(" ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("blank");
|
.hasMessageContaining("blank");
|
||||||
}
|
}
|
||||||
@@ -429,7 +425,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("taken@example.com");
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("E-Mail");
|
.hasMessageContaining("E-Mail");
|
||||||
}
|
}
|
||||||
@@ -501,7 +497,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -565,7 +561,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(null);
|
dto.setContact(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -580,7 +576,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" ");
|
dto.setContact(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -595,7 +591,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" phone: 555 ");
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
}
|
}
|
||||||
@@ -610,7 +606,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(null);
|
dto.setEmail(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
}
|
}
|
||||||
@@ -626,7 +622,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("me@example.com");
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +640,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -703,160 +699,6 @@ class UserServiceTest {
|
|||||||
assertThat(result).containsExactly(g);
|
assertThat(result).containsExactly(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
|
||||||
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
|
||||||
assertThat(payload).containsEntry("email", "u@example.com");
|
|
||||||
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
|
||||||
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
dto.setGroupIds(List.of(group.getId()));
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
// groupIds not set → null
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
userService.deleteUser(actorId, userId);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
|
||||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("new@example.com");
|
|
||||||
req.setInitialPassword("secret");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
|
||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
userService.createUserOrUpdate(actorId, req);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
|
||||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("existing@example.com");
|
|
||||||
req.setInitialPassword("pass");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
|
||||||
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
|
||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
|
||||||
|
|
||||||
userService.createUserOrUpdate(actorId, req);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("bootstrap@example.com");
|
|
||||||
req.setInitialPassword("secret");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
|
||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
AppUser result = userService.createUserForBootstrap(req);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -40,26 +40,6 @@ export default defineConfig(
|
|||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig
|
||||||
}
|
}
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
|
|
||||||
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
|
|
||||||
// For any text label use text-primary or text-ink instead. This rule catches
|
|
||||||
// the pattern where text-accent appears inside a JavaScript string literal
|
|
||||||
// (e.g. conditional ternary class expressions in Svelte templates).
|
|
||||||
'no-restricted-syntax': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
selector: 'Literal[value=/\\btext-accent\\b/]',
|
|
||||||
message:
|
|
||||||
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
|
|
||||||
message:
|
|
||||||
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
|
||||||
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"pagination_next": "Weiter",
|
"pagination_next": "Weiter",
|
||||||
"pagination_page_of": "Seite {page} von {total}",
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
"pagination_nav_label": "Seitennavigation",
|
"pagination_nav_label": "Seitennavigation",
|
||||||
"pagination_page_button": "Seite {page}",
|
|
||||||
|
|
||||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"nav_conversations": "Letters",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
"theme_toggle_to_light": "Switch to light mode",
|
|
||||||
"theme_toggle_to_dark": "Switch to dark mode",
|
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
"pagination_page_of": "Page {page} of {total}",
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
"pagination_nav_label": "Pagination",
|
"pagination_nav_label": "Pagination",
|
||||||
"pagination_page_button": "Page {page}",
|
|
||||||
|
|
||||||
"common_opens_new_tab": "(opens in new tab)",
|
"common_opens_new_tab": "(opens in new tab)",
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"nav_conversations": "Cartas",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
"theme_toggle_to_light": "Cambiar a modo claro",
|
|
||||||
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
@@ -818,7 +816,6 @@
|
|||||||
"pagination_next": "Siguiente",
|
"pagination_next": "Siguiente",
|
||||||
"pagination_page_of": "Página {page} de {total}",
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
"pagination_nav_label": "Paginación",
|
"pagination_nav_label": "Paginación",
|
||||||
"pagination_page_button": "Página {page}",
|
|
||||||
|
|
||||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick,
|
onAnnotationClick
|
||||||
onDeleteRequest
|
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canDraw: boolean;
|
canDraw: boolean;
|
||||||
@@ -30,7 +29,6 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onDraw: (rect: DrawRect) => void;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onDeleteRequest?: (annotationId: string) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -114,8 +112,6 @@ const containerStyle = $derived(
|
|||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
blockNumber={blockNumbers[annotation.id]}
|
blockNumber={blockNumbers[annotation.id]}
|
||||||
isFlashing={flashAnnotationId === annotation.id}
|
isFlashing={flashAnnotationId === annotation.id}
|
||||||
showDelete={canDraw}
|
|
||||||
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
|
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
onpointerenter={() => (hoveredId = annotation.id)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onpointerleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
|
|||||||
expect(el2.style.opacity).toBe('1');
|
expect(el2.style.opacity).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show delete button when annotation is not hovered or active', async () => {
|
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1')],
|
annotations: [makeAnnotation('ann-1')],
|
||||||
canDraw: true,
|
canDraw: true,
|
||||||
@@ -107,19 +107,6 @@ describe('AnnotationLayer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
|
||||||
render(AnnotationLayer, {
|
|
||||||
annotations: [makeAnnotation('ann-1')],
|
|
||||||
canDraw: false,
|
|
||||||
color: '#00C7B1',
|
|
||||||
activeAnnotationId: 'ann-1',
|
|
||||||
onDraw: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
|
||||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ let {
|
|||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
isResizable = false,
|
isResizable = false,
|
||||||
showDelete = false,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -25,15 +23,11 @@ let {
|
|||||||
blockNumber?: number | undefined;
|
blockNumber?: number | undefined;
|
||||||
isFlashing?: boolean;
|
isFlashing?: boolean;
|
||||||
isResizable?: boolean;
|
isResizable?: boolean;
|
||||||
showDelete?: boolean;
|
|
||||||
onDeleteRequest?: () => void;
|
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onpointerenter: () => void;
|
onpointerenter: () => void;
|
||||||
onpointerleave: () => void;
|
onpointerleave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
@@ -89,7 +83,6 @@ let shapeStyle = $derived(
|
|||||||
onclick={onclick}
|
onclick={onclick}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onclick();
|
if (e.key === 'Enter' || e.key === ' ') onclick();
|
||||||
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
|
||||||
}}
|
}}
|
||||||
onpointerenter={onpointerenter}
|
onpointerenter={onpointerenter}
|
||||||
onpointerleave={onpointerleave}
|
onpointerleave={onpointerleave}
|
||||||
@@ -119,51 +112,6 @@ let shapeStyle = $derived(
|
|||||||
{blockNumber}
|
{blockNumber}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if deleteVisible}
|
|
||||||
<button
|
|
||||||
data-testid="annotation-delete-{annotation.id}"
|
|
||||||
type="button"
|
|
||||||
aria-label="Löschen"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteRequest?.();
|
|
||||||
}}
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid var(--color-error, #e53e3e);
|
|
||||||
color: var(--color-error, #e53e3e);
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 10;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if isResizable}
|
{#if isResizable}
|
||||||
<AnnotationEditOverlay annotation={annotation} />
|
<AnnotationEditOverlay annotation={annotation} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
import AnnotationShape from './AnnotationShape.svelte';
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function makeAnnotation(id = 'ann-1') {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
documentId: 'doc-1',
|
|
||||||
pageNumber: 1,
|
|
||||||
x: 0.1,
|
|
||||||
y: 0.1,
|
|
||||||
width: 0.3,
|
|
||||||
height: 0.2,
|
|
||||||
color: '#00C7B1',
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AnnotationShape', () => {
|
|
||||||
it('renders the annotation element', async () => {
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: false,
|
|
||||||
isActive: false,
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show delete button when showDelete is false', async () => {
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: true,
|
|
||||||
isActive: false,
|
|
||||||
showDelete: false,
|
|
||||||
onDeleteRequest: vi.fn(),
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: false,
|
|
||||||
isActive: false,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest: vi.fn(),
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows delete button when showDelete is true and isHovered is true', async () => {
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: true,
|
|
||||||
isActive: false,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest: vi.fn(),
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows delete button when showDelete is true and isActive is true', async () => {
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: false,
|
|
||||||
isActive: true,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest: vi.fn(),
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onDeleteRequest when delete button is clicked', async () => {
|
|
||||||
const onDeleteRequest = vi.fn();
|
|
||||||
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: true,
|
|
||||||
isActive: false,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
|
||||||
await deleteBtn.click();
|
|
||||||
|
|
||||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call onclick when delete button is clicked', async () => {
|
|
||||||
const onclick = vi.fn();
|
|
||||||
const onDeleteRequest = vi.fn();
|
|
||||||
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: true,
|
|
||||||
isActive: false,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick,
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
|
||||||
await deleteBtn.click();
|
|
||||||
|
|
||||||
expect(onclick).not.toHaveBeenCalled();
|
|
||||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
|
|
||||||
const onDeleteRequest = vi.fn();
|
|
||||||
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: false,
|
|
||||||
isActive: true,
|
|
||||||
showDelete: true,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
|
||||||
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
|
||||||
|
|
||||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
|
|
||||||
const onDeleteRequest = vi.fn();
|
|
||||||
|
|
||||||
render(AnnotationShape, {
|
|
||||||
annotation: makeAnnotation(),
|
|
||||||
isHovered: false,
|
|
||||||
isActive: true,
|
|
||||||
showDelete: false,
|
|
||||||
onDeleteRequest,
|
|
||||||
onclick: () => {},
|
|
||||||
onpointerenter: () => {},
|
|
||||||
onpointerleave: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
|
||||||
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
|
||||||
|
|
||||||
expect(onDeleteRequest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -24,7 +24,6 @@ type Props = {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -39,8 +38,7 @@ let {
|
|||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw
|
||||||
onDeleteAnnotationRequest
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,7 +98,6 @@ let {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
onTranscriptionDraw={onTranscriptionDraw}
|
onTranscriptionDraw={onTranscriptionDraw}
|
||||||
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
|
|
||||||
documentFileHash={doc.fileHash ?? null}
|
documentFileHash={doc.fileHash ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
|
|||||||
@@ -48,12 +48,6 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bellLabel = $derived(
|
|
||||||
stream.unreadCount > 0
|
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
|
||||||
: m.notification_bell_label()
|
|
||||||
);
|
|
||||||
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,11 +72,12 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={bellLabel}
|
aria-label={stream.unreadCount > 0
|
||||||
title={bellLabel}
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,34 +55,6 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('NotificationBell — cursor and tooltip', () => {
|
|
||||||
it('bell button has cursor-pointer class', async () => {
|
|
||||||
render(NotificationBell);
|
|
||||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
||||||
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
|
||||||
mockNotificationList.value = [];
|
|
||||||
render(NotificationBell);
|
|
||||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
||||||
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
|
||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
|
||||||
mockNotificationList.value = [
|
|
||||||
makeNotification({ id: 'n1' }),
|
|
||||||
makeNotification({ id: 'n2' }),
|
|
||||||
makeNotification({ id: 'n3' })
|
|
||||||
];
|
|
||||||
render(NotificationBell);
|
|
||||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
||||||
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
|
||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
@@ -20,48 +20,6 @@ const controlBase =
|
|||||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||||
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||||
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||||
const activePageBase =
|
|
||||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the sliding window of 1-indexed page numbers to render as buttons.
|
|
||||||
* Always shows: first, last, current, one neighbor each side.
|
|
||||||
* null entries represent ellipsis gaps.
|
|
||||||
*/
|
|
||||||
const pageWindow = $derived.by(() => {
|
|
||||||
const first = 1;
|
|
||||||
const last = totalPages;
|
|
||||||
const current = page + 1; // convert to 1-indexed
|
|
||||||
|
|
||||||
const windowStart = Math.max(first, current - 1);
|
|
||||||
const windowEnd = Math.min(last, current + 1);
|
|
||||||
|
|
||||||
const result: (number | null)[] = [];
|
|
||||||
|
|
||||||
result.push(first);
|
|
||||||
|
|
||||||
if (windowStart > first + 2) {
|
|
||||||
result.push(null); // left ellipsis
|
|
||||||
} else if (windowStart === first + 2) {
|
|
||||||
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
|
|
||||||
result.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (windowEnd < last - 2) {
|
|
||||||
result.push(null); // right ellipsis
|
|
||||||
} else if (windowEnd === last - 2) {
|
|
||||||
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last > first) {
|
|
||||||
result.push(last);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
@@ -94,60 +52,13 @@ const pageWindow = $derived.by(() => {
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
|
|
||||||
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
|
|
||||||
<span
|
<span
|
||||||
data-testid="pagination-page-label"
|
data-testid="pagination-page-label"
|
||||||
aria-hidden="true"
|
aria-current="page"
|
||||||
class="font-sans text-sm text-ink-2 sm:hidden"
|
class="font-sans text-sm text-ink-2"
|
||||||
>
|
>
|
||||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
<!-- Always in the AT tree: announces current page regardless of breakpoint.
|
|
||||||
On mobile, the desktop button container is display:none so this is the only AT anchor.
|
|
||||||
On desktop, the active page button also carries aria-current — both announce the same info. -->
|
|
||||||
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
|
|
||||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Desktop: numbered page buttons (hidden below sm:) -->
|
|
||||||
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
|
|
||||||
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
|
|
||||||
{#if entry === null}
|
|
||||||
{#if i === 1}
|
|
||||||
<span
|
|
||||||
data-testid="pagination-ellipsis-left"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="px-2 text-sm text-ink-2">…</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
data-testid="pagination-ellipsis-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="px-2 text-sm text-ink-2">…</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else if entry === page + 1}
|
|
||||||
<span
|
|
||||||
data-testid="pagination-page-{entry}"
|
|
||||||
aria-current="page"
|
|
||||||
aria-label={m.pagination_page_button({ page: entry })}
|
|
||||||
class={activePageBase}
|
|
||||||
>
|
|
||||||
{entry}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
data-testid="pagination-page-{entry}"
|
|
||||||
aria-label={m.pagination_page_button({ page: entry })}
|
|
||||||
href={makeHref(entry - 1)}
|
|
||||||
class={linkBase}
|
|
||||||
>
|
|
||||||
{entry}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hasNext}
|
{#if hasNext}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -19,145 +19,11 @@ describe('Pagination', () => {
|
|||||||
await expect.element(label).toHaveTextContent(/10/);
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
|
it('marks the current page label with aria-current="page"', async () => {
|
||||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const label = page.getByTestId('pagination-page-label');
|
const label = page.getByTestId('pagination-page-label');
|
||||||
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||||
});
|
|
||||||
|
|
||||||
describe('page number buttons', () => {
|
|
||||||
it('renders page number buttons when totalPages > 1', async () => {
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
// active page button — the current page (5, 1-indexed)
|
|
||||||
const activeBtn = nav.getByTestId('pagination-page-5');
|
|
||||||
await expect.element(activeBtn).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render page number buttons when totalPages <= 1', async () => {
|
|
||||||
render(Pagination, { page: 0, totalPages: 1, makeHref });
|
|
||||||
|
|
||||||
// entire nav is hidden
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
await expect.element(nav).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the active page button with aria-current="page"', async () => {
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const activeBtn = nav.getByTestId('pagination-page-5');
|
|
||||||
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('active page button has brand-navy background', async () => {
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const activeBtn = nav.getByTestId('pagination-page-5');
|
|
||||||
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('active page button has 44px touch target', async () => {
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const activeBtn = nav.getByTestId('pagination-page-5');
|
|
||||||
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
|
|
||||||
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inactive page buttons link to their target page via makeHref', async () => {
|
|
||||||
const spy = vi.fn(makeHref);
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
|
|
||||||
const firstPageBtn = nav.getByTestId('pagination-page-1');
|
|
||||||
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders first and last page buttons always visible', async () => {
|
|
||||||
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
|
||||||
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders ellipsis span between first page and window when gap exists', async () => {
|
|
||||||
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
|
|
||||||
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const ellipses = nav.getByTestId('pagination-ellipsis-left');
|
|
||||||
await expect.element(ellipses).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders ellipsis span between window and last page when gap exists', async () => {
|
|
||||||
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
|
|
||||||
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
|
|
||||||
await expect.element(ellipsis).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render left ellipsis when window is adjacent to first page', async () => {
|
|
||||||
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
|
|
||||||
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
|
|
||||||
await expect.element(leftEllipsis).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render right ellipsis when window is adjacent to last page', async () => {
|
|
||||||
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
|
|
||||||
render(Pagination, { page: 11, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
|
|
||||||
await expect.element(rightEllipsis).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
|
|
||||||
// The page buttons container must be hidden below sm: breakpoint
|
|
||||||
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const pageButtons = nav.getByTestId('pagination-pages');
|
|
||||||
await expect.element(pageButtons).toHaveClass(/hidden/);
|
|
||||||
await expect.element(pageButtons).toHaveClass(/sm:flex/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders both pages without ellipsis when totalPages is 2', async () => {
|
|
||||||
render(Pagination, { page: 0, totalPages: 2, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
|
||||||
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
|
|
||||||
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
|
|
||||||
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
|
|
||||||
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
|
||||||
|
|
||||||
const label = page.getByTestId('pagination-page-label');
|
|
||||||
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
|
|
||||||
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
|
||||||
const srLabel = nav.getByTestId('pagination-current-page-sr');
|
|
||||||
await expect.element(srLabel).toBeInTheDocument();
|
|
||||||
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ let {
|
|||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
: 'bg-surface/10 text-primary'}"
|
: 'bg-surface/10 text-accent'}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 shrink-0"
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { vi, describe, it, expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
|
|
||||||
import PdfControls from './PdfControls.svelte';
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 3,
|
|
||||||
isLoaded: true,
|
|
||||||
showAnnotations: false,
|
|
||||||
annotationCount: 0,
|
|
||||||
onPrev: vi.fn(),
|
|
||||||
onNext: vi.fn(),
|
|
||||||
onZoomIn: vi.fn(),
|
|
||||||
onZoomOut: vi.fn(),
|
|
||||||
onToggleAnnotations: vi.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PdfControls — annotation toggle visibility', () => {
|
|
||||||
it('renders annotation toggle when annotationCount is greater than zero', async () => {
|
|
||||||
render(PdfControls, { ...defaultProps, annotationCount: 3 });
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render annotation toggle when annotationCount is zero', async () => {
|
|
||||||
render(PdfControls, { ...defaultProps, annotationCount: 0 });
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: /annotierungen/i }))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PdfControls — annotation toggle label', () => {
|
|
||||||
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
|
|
||||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
|
|
||||||
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
|
|
||||||
await expect.element(btn).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
|
|
||||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
|
|
||||||
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
|
|
||||||
await expect.element(btn).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
|
||||||
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
|
|
||||||
const { container } = render(PdfControls, {
|
|
||||||
...defaultProps,
|
|
||||||
annotationCount: 2,
|
|
||||||
showAnnotations: false
|
|
||||||
});
|
|
||||||
const allButtons = container.querySelectorAll('button');
|
|
||||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
|
||||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
|
||||||
);
|
|
||||||
expect(annotationBtn).not.toBeNull();
|
|
||||||
expect(annotationBtn!.className).toContain('text-primary');
|
|
||||||
expect(annotationBtn!.className).not.toContain('text-accent');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,7 +18,6 @@ let {
|
|||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw,
|
||||||
onDeleteAnnotationRequest,
|
|
||||||
documentFileHash,
|
documentFileHash,
|
||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null
|
flashAnnotationId = null
|
||||||
@@ -31,7 +30,6 @@ let {
|
|||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
|
||||||
documentFileHash?: string | null;
|
documentFileHash?: string | null;
|
||||||
annotationsDimmed?: boolean;
|
annotationsDimmed?: boolean;
|
||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
@@ -266,7 +264,6 @@ function handleAnnotationClick(id: string) {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onDeleteRequest={onDeleteAnnotationRequest}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ function select(type: PersonType) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={m.form_label_person_type()}
|
|
||||||
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ import PersonTypeSelector from './PersonTypeSelector.svelte';
|
|||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe('PersonTypeSelector', () => {
|
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 () => {
|
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
@@ -25,47 +18,32 @@ describe('PersonTypeSelector', () => {
|
|||||||
|
|
||||||
expect(hiddenInput.value).toBe('INSTITUTION');
|
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||||
});
|
});
|
||||||
|
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
|
||||||
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
expect(hiddenInput.value).toBe('PERSON');
|
const selected = Array.from(buttons).find((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
expect(selected).not.toBeNull();
|
||||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
expect(selected!.className).toContain('bg-primary');
|
||||||
personButton.focus();
|
expect(selected!.className).toContain('text-primary-fg');
|
||||||
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 () => {
|
it('unselected buttons use semantic bg-surface, text-ink, border-line classes', () => {
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
const unselected = Array.from(buttons).filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||||
await userEvent.click(groupButton);
|
expect(unselected.length).toBeGreaterThan(0);
|
||||||
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
for (const btn of unselected) {
|
||||||
const others = buttons.filter((b) => b !== groupButton);
|
expect(btn.className).toContain('bg-surface');
|
||||||
for (const btn of others) {
|
expect(btn.className).toContain('text-ink');
|
||||||
expect(btn.getAttribute('aria-checked')).toBe('false');
|
expect(btn.className).toContain('border-line');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
it('focus ring uses semantic ring-focus-ring class', () => {
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
for (const btn of buttons) {
|
||||||
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
expect(btn.className).toContain('ring-focus-ring');
|
||||||
expect(selected!.getAttribute('tabindex')).toBe('0');
|
|
||||||
for (const btn of unselected) {
|
|
||||||
expect(btn.getAttribute('tabindex')).toBe('-1');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
|
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
|
||||||
>
|
>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
|
|||||||
expect(el.className).toContain('text-gray-400');
|
expect(el.className).toContain('text-gray-400');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a primary-colored label when percentage is > 0', async () => {
|
it('renders a mint-colored label when percentage is > 0', async () => {
|
||||||
render(ProgressRing, { percentage: 75 });
|
render(ProgressRing, { percentage: 75 });
|
||||||
const label = page.getByText('75%');
|
const label = page.getByText('75%');
|
||||||
await expect.element(label).toBeInTheDocument();
|
await expect.element(label).toBeInTheDocument();
|
||||||
const el = (await label.element()) as HTMLElement;
|
const el = (await label.element()) as HTMLElement;
|
||||||
expect(el.className).toContain('text-primary');
|
expect(el.className).toContain('text-accent');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a fully filled arc for 100%', async () => {
|
it('renders a fully filled arc for 100%', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -20,10 +19,6 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
const themeLabel = $derived(
|
|
||||||
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -34,8 +29,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={themeLabel}
|
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
title={themeLabel}
|
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
import ThemeToggle from './ThemeToggle.svelte';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
localStorage.removeItem('theme');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ThemeToggle — label derivation (light mode)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aria-label invites switching to dark mode when theme is light', async () => {
|
|
||||||
render(ThemeToggle);
|
|
||||||
const btn = await page.getByRole('button').element();
|
|
||||||
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('title equals aria-label in light mode', async () => {
|
|
||||||
render(ThemeToggle);
|
|
||||||
const btn = await page.getByRole('button').element();
|
|
||||||
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ThemeToggle — label derivation (dark mode)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aria-label invites switching to light mode when theme is dark', async () => {
|
|
||||||
render(ThemeToggle);
|
|
||||||
const btn = await page.getByRole('button').element();
|
|
||||||
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('title equals aria-label in dark mode', async () => {
|
|
||||||
render(ThemeToggle);
|
|
||||||
const btn = await page.getByRole('button').element();
|
|
||||||
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -19,7 +19,6 @@ type Props = {
|
|||||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||||
onReviewToggle: (blockId: string) => Promise<void>;
|
onReviewToggle: (blockId: string) => Promise<void>;
|
||||||
onMarkAllReviewed?: () => Promise<void>;
|
|
||||||
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
trainingLabels?: string[];
|
trainingLabels?: string[];
|
||||||
@@ -38,7 +37,6 @@ let {
|
|||||||
onSaveBlock,
|
onSaveBlock,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onReviewToggle,
|
onReviewToggle,
|
||||||
onMarkAllReviewed,
|
|
||||||
onTriggerOcr,
|
onTriggerOcr,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
trainingLabels = [],
|
trainingLabels = [],
|
||||||
@@ -48,14 +46,12 @@ let {
|
|||||||
let activeBlockId: string | null = $state(null);
|
let activeBlockId: string | null = $state(null);
|
||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||||
let listEl: HTMLElement | null = $state(null);
|
let listEl: HTMLElement | null = $state(null);
|
||||||
let markingAllReviewed = $state(false);
|
|
||||||
|
|
||||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
const hasBlocks = $derived(blocks.length > 0);
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||||
const totalCount = $derived(blocks.length);
|
const totalCount = $derived(blocks.length);
|
||||||
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||||
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
|
|
||||||
|
|
||||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -64,16 +60,6 @@ $effect(() => {
|
|||||||
if (block) activeBlockId = block.id;
|
if (block) activeBlockId = block.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleMarkAllReviewed() {
|
|
||||||
if (!onMarkAllReviewed) return;
|
|
||||||
markingAllReviewed = true;
|
|
||||||
try {
|
|
||||||
await onMarkAllReviewed();
|
|
||||||
} finally {
|
|
||||||
markingAllReviewed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||||
|
|
||||||
const dragDrop = createBlockDragDrop({
|
const dragDrop = createBlockDragDrop({
|
||||||
@@ -161,56 +147,9 @@ async function handleLabelToggle(label: string) {
|
|||||||
{#if hasBlocks}
|
{#if hasBlocks}
|
||||||
<!-- Sticky review progress header -->
|
<!-- Sticky review progress header -->
|
||||||
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||||
<div class="flex items-center justify-between">
|
<p class="font-sans text-xs text-ink-2">
|
||||||
<p class="font-sans text-xs text-ink-2">
|
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
</p>
|
||||||
</p>
|
|
||||||
{#if onMarkAllReviewed}
|
|
||||||
<button
|
|
||||||
onclick={handleMarkAllReviewed}
|
|
||||||
disabled={allReviewed || markingAllReviewed}
|
|
||||||
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
|
||||||
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{#if markingAllReviewed}
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 animate-spin"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
Alle als fertig markieren
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||||
|
|||||||
@@ -49,11 +49,6 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreviewedBlock1 = { ...block1, reviewed: false };
|
|
||||||
const unreviewedBlock2 = { ...block2, reviewed: false };
|
|
||||||
const reviewedBlock1 = { ...block1, reviewed: true };
|
|
||||||
const reviewedBlock2 = { ...block2, reviewed: true };
|
|
||||||
|
|
||||||
describe('TranscriptionEditView — rendering', () => {
|
describe('TranscriptionEditView — rendering', () => {
|
||||||
it('renders blocks in sort order', async () => {
|
it('renders blocks in sort order', async () => {
|
||||||
renderView();
|
renderView();
|
||||||
@@ -274,61 +269,3 @@ describe('TranscriptionEditView — review progress counter', () => {
|
|||||||
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('TranscriptionEditView — mark all reviewed', () => {
|
|
||||||
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
|
|
||||||
renderView({
|
|
||||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
|
||||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables button when all blocks are already reviewed', async () => {
|
|
||||||
renderView({
|
|
||||||
blocks: [reviewedBlock1, reviewedBlock2],
|
|
||||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
|
||||||
.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
|
|
||||||
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
|
|
||||||
renderView({
|
|
||||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
|
||||||
onMarkAllReviewed
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
|
||||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables button while operation is in-flight', async () => {
|
|
||||||
let resolveMarkAll!: () => void;
|
|
||||||
const onMarkAllReviewed = vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
|
|
||||||
renderView({
|
|
||||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
|
||||||
onMarkAllReviewed
|
|
||||||
});
|
|
||||||
|
|
||||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
|
||||||
await btn.click();
|
|
||||||
await expect.element(btn).toBeDisabled();
|
|
||||||
resolveMarkAll();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,17 +3,6 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||||
export type PersonType = (typeof PERSON_TYPES)[number];
|
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 {
|
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
||||||
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,9 @@ import { getErrorMessage } from '$lib/errors';
|
|||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
const canWrite = $derived(data.canWrite ?? false);
|
const canWrite = $derived(data.canWrite ?? false);
|
||||||
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
@@ -108,26 +105,6 @@ async function deleteBlock(blockId: string) {
|
|||||||
annotationReloadKey++;
|
annotationReloadKey++;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAnnotationDeleteRequest(annotationId: string) {
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: m.transcription_block_delete_confirm(),
|
|
||||||
destructive: true
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
|
||||||
if (block) {
|
|
||||||
await deleteBlock(block.id);
|
|
||||||
} else {
|
|
||||||
// Annotation has no linked block — delete the annotation directly
|
|
||||||
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Delete annotation failed');
|
|
||||||
annotationReloadKey++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reviewToggle(blockId: string) {
|
async function reviewToggle(blockId: string) {
|
||||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
|
||||||
method: 'PUT'
|
method: 'PUT'
|
||||||
@@ -137,18 +114,6 @@ async function reviewToggle(blockId: string) {
|
|||||||
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllReviewed() {
|
|
||||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
|
|
||||||
method: 'PUT'
|
|
||||||
});
|
|
||||||
if (!res.ok) return;
|
|
||||||
const updated = await res.json();
|
|
||||||
for (const b of updated) {
|
|
||||||
const existing = transcriptionBlocks.find((x) => x.id === b.id);
|
|
||||||
if (existing) existing.reviewed = b.reviewed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTrainingLabel(label: string, enrolled: boolean) {
|
async function toggleTrainingLabel(label: string, enrolled: boolean) {
|
||||||
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
|
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -416,7 +381,6 @@ onMount(() => {
|
|||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onTranscriptionDraw={createBlockFromDraw}
|
onTranscriptionDraw={createBlockFromDraw}
|
||||||
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -513,7 +477,6 @@ onMount(() => {
|
|||||||
onSaveBlock={saveBlock}
|
onSaveBlock={saveBlock}
|
||||||
onDeleteBlock={deleteBlock}
|
onDeleteBlock={deleteBlock}
|
||||||
onReviewToggle={reviewToggle}
|
onReviewToggle={reviewToggle}
|
||||||
onMarkAllReviewed={markAllReviewed}
|
|
||||||
onTriggerOcr={triggerOcr}
|
onTriggerOcr={triggerOcr}
|
||||||
onToggleTrainingLabel={toggleTrainingLabel}
|
onToggleTrainingLabel={toggleTrainingLabel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -365,11 +365,6 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--c-focus-ring);
|
outline: 2px solid var(--c-focus-ring);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let mergeTargetId = $state('');
|
|||||||
let showMergeConfirm = $state(false);
|
let showMergeConfirm = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||||
<p class="mb-5 font-sans text-sm text-ink-2">
|
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
import PersonMergePanel from './PersonMergePanel.svelte';
|
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
|
||||||
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({
|
|
||||||
default: () => null
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
const makePerson = (overrides = {}) => ({
|
|
||||||
displayName: 'Hans Müller',
|
|
||||||
...overrides
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Danger indicator ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMergePanel — danger indicator', () => {
|
|
||||||
it('renders outer container with red border class', () => {
|
|
||||||
const { container } = render(PersonMergePanel, {
|
|
||||||
props: { person: makePerson(), form: null }
|
|
||||||
});
|
|
||||||
const panel = container.firstElementChild as HTMLElement;
|
|
||||||
expect(panel?.classList.contains('border-red-200')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Initial state ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMergePanel — initial state', () => {
|
|
||||||
it('renders merge heading', async () => {
|
|
||||||
render(PersonMergePanel, { props: { person: makePerson(), form: null } });
|
|
||||||
const heading = page.getByRole('heading', { level: 2 });
|
|
||||||
await expect.element(heading).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merge button is disabled when no target selected', async () => {
|
|
||||||
render(PersonMergePanel, { props: { person: makePerson(), form: null } });
|
|
||||||
const mergeBtn = page.getByRole('button', { name: /zusammenführen/i });
|
|
||||||
await expect.element(mergeBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMergePanel — error state', () => {
|
|
||||||
it('renders mergeError when form contains error', async () => {
|
|
||||||
render(PersonMergePanel, {
|
|
||||||
props: { person: makePerson(), form: { mergeError: 'Zielperson nicht gefunden.' } }
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Zielperson nicht gefunden.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ import BackButton from '$lib/components/BackButton.svelte';
|
|||||||
import PersonEditForm from './PersonEditForm.svelte';
|
import PersonEditForm from './PersonEditForm.svelte';
|
||||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||||
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||||
import PersonMergePanel from '../PersonMergePanel.svelte';
|
import PersonDangerZone from './PersonDangerZone.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const person = $derived(data.person);
|
const person = $derived(data.person);
|
||||||
@@ -35,9 +35,7 @@ const person = $derived(data.person);
|
|||||||
|
|
||||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||||
|
|
||||||
{#key person.id}
|
<PersonDangerZone person={person} form={form} />
|
||||||
<PersonMergePanel person={person} form={form} />
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonMergePanel from '../PersonMergePanel.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
person,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
|
form?: { mergeError?: string } | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="flex w-full items-center justify-between px-6 py-4 text-left"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
|
||||||
|
{m.person_danger_zone_heading()}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="border-t border-red-100 px-6 py-4">
|
||||||
|
{#key person.id}
|
||||||
|
<PersonMergePanel person={person} form={form} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -2,13 +2,22 @@
|
|||||||
import { untrack } from 'svelte';
|
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 PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||||
import {
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
PERSON_TYPES as TYPES,
|
|
||||||
type PersonType,
|
|
||||||
type PersonFormData
|
|
||||||
} from '$lib/person-validation';
|
|
||||||
|
|
||||||
let { person }: { person: PersonFormData } = $props();
|
let {
|
||||||
|
person
|
||||||
|
}: {
|
||||||
|
person: {
|
||||||
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
alias?: string | null;
|
||||||
|
birthYear?: number | null;
|
||||||
|
deathYear?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let selectedType = $state<PersonType>(
|
let selectedType = $state<PersonType>(
|
||||||
untrack(() =>
|
untrack(() =>
|
||||||
@@ -22,15 +31,11 @@ const lastNameLabel = $derived(
|
|||||||
? m.form_label_name()
|
? m.form_label_name()
|
||||||
: m.form_label_last_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 class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<p class={labelCls}>
|
<p class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.form_label_person_type()}
|
{m.form_label_person_type()}
|
||||||
</p>
|
</p>
|
||||||
<PersonTypeSelector
|
<PersonTypeSelector
|
||||||
@@ -42,48 +47,68 @@ const inputCls =
|
|||||||
|
|
||||||
{#if isPerson}
|
{#if isPerson}
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
<label for="title" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_title()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
value={person.title ?? ''}
|
value={person.title ?? ''}
|
||||||
class={inputCls}
|
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>
|
<div>
|
||||||
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
<label
|
||||||
|
for="firstName"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_first_name()} *</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.firstName ?? ''}
|
value={person.firstName ?? ''}
|
||||||
class={inputCls}
|
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>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||||
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{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={inputCls}
|
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>
|
||||||
|
|
||||||
{#if isPerson}
|
{#if isPerson}
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
>{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>
|
||||||
<div>
|
<div>
|
||||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
<label
|
||||||
|
for="birthYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_birth_year()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="birthYear"
|
id="birthYear"
|
||||||
name="birthYear"
|
name="birthYear"
|
||||||
@@ -92,11 +117,15 @@ const inputCls =
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.birthYear ?? ''}
|
value={person.birthYear ?? ''}
|
||||||
class={inputCls}
|
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>
|
<div>
|
||||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
<label
|
||||||
|
for="deathYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_death_year()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="deathYear"
|
id="deathYear"
|
||||||
name="deathYear"
|
name="deathYear"
|
||||||
@@ -105,13 +134,15 @@ const inputCls =
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.deathYear ?? ''}
|
value={person.deathYear ?? ''}
|
||||||
class={inputCls}
|
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>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
<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"
|
||||||
|
|||||||
Reference in New Issue
Block a user