From f9ac963b9fbcd6ac44bf91755900255a0c486e41 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 15:15:17 +0200 Subject: [PATCH 01/51] feat(#221): add V39 migration for tag hierarchy and colors Adds parent_id FK (ON DELETE SET NULL), self-reference check constraint, parent_id index, and nullable color column to the tag table. Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V39__add_tag_hierarchy.sql | 9 +++ .../repository/MigrationIntegrationTest.java | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql diff --git a/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql b/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql new file mode 100644 index 00000000..330d10df --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql @@ -0,0 +1,9 @@ +-- Add self-referencing parent FK for tag hierarchy (adjacency list model). +-- ON DELETE SET NULL: deleting a parent promotes its children to root level. +ALTER TABLE tag ADD COLUMN parent_id UUID REFERENCES tag(id) ON DELETE SET NULL; +ALTER TABLE tag ADD CONSTRAINT chk_tag_no_self_reference CHECK (parent_id != id); +CREATE INDEX idx_tag_parent_id ON tag(parent_id); + +-- Optional color token (e.g. "sage", "teal") for root-level tags. +-- Validated against the allowed palette in TagService before save. +ALTER TABLE tag ADD COLUMN color VARCHAR(20); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index db5b98a3..78ff9861 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -168,8 +168,63 @@ class MigrationIntegrationTest { assertThat(rows).isEqualTo(1); } + // ─── V39: tag hierarchy — parent_id FK + self-reference check + color ────── + + @Test + void v39_parentId_allowsNull() { + UUID tagId = createTag("TagWithoutParent"); + + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM tag WHERE id = ? AND parent_id IS NULL", Integer.class, tagId); + assertThat(count).isEqualTo(1); + } + + @Test + void v39_selfReferenceCheck_rejectsSelfAsParent() { + UUID tagId = createTag("SelfRef"); + + assertThatThrownBy(() -> + jdbc.update("UPDATE tag SET parent_id = id WHERE id = ?", tagId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v39_parentId_acceptsValidParent() { + UUID parent = createTag("Parent"); + UUID child = createTag("Child"); + + int rows = jdbc.update("UPDATE tag SET parent_id = ? WHERE id = ?", parent, child); + assertThat(rows).isEqualTo(1); + } + + @Test + void v39_color_allowsNull() { + UUID tagId = createTag("ColorlessTag"); + + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM tag WHERE id = ? AND color IS NULL", Integer.class, tagId); + assertThat(count).isEqualTo(1); + } + + @Test + void v39_color_storesTokenName() { + UUID tagId = createTag("ColoredTag"); + + int rows = jdbc.update("UPDATE tag SET color = 'sage' WHERE id = ?", tagId); + String stored = jdbc.queryForObject("SELECT color FROM tag WHERE id = ?", String.class, tagId); + + assertThat(rows).isEqualTo(1); + assertThat(stored).isEqualTo("sage"); + } + // ─── helpers ───────────────────────────────────────────────────────────── + private UUID createTag(String name) { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO tag (id, name) VALUES (?, ?)", id, name); + return id; + } + private UUID createDocument() { Document doc = documentRepository.save(Document.builder() .title("Testdokument") -- 2.49.1 From 3fba7404695691393358d896a5be520fcb8ee74d Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 15:26:23 +0200 Subject: [PATCH 02/51] feat(#221): tag entity hierarchy fields, service, repository, controller - Tag entity: add parentId (UUID FK) and color (String) fields - TagUpdateDTO and TagTreeNodeDTO records - ErrorCode: INVALID_TAG_COLOR, TAG_CYCLE_DETECTED - TagRepository: findAncestorIds() recursive CTE query - TagService: cycle detection, color validation, getTagTree() - TagController: use TagUpdateDTO, add GET /api/tags/tree endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../controller/TagController.java | 12 +- .../familienarchiv/dto/TagTreeNodeDTO.java | 6 + .../familienarchiv/dto/TagUpdateDTO.java | 5 + .../familienarchiv/exception/ErrorCode.java | 6 + .../org/raddatz/familienarchiv/model/Tag.java | 7 + .../repository/TagRepository.java | 26 +++- .../familienarchiv/service/TagService.java | 100 ++++++++++++- .../controller/TagControllerTest.java | 29 +++- .../service/TagServiceTest.java | 132 +++++++++++++++++- 9 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java index c3d99299..71bf1263 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java @@ -1,9 +1,10 @@ package org.raddatz.familienarchiv.controller; import java.util.List; -import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; +import org.raddatz.familienarchiv.dto.TagUpdateDTO; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; @@ -31,8 +32,8 @@ public class TagController { @PutMapping("/{id}") @RequirePermission(Permission.ADMIN_TAG) - public ResponseEntity updateTag(@PathVariable UUID id, @RequestBody Map payload) { - return ResponseEntity.ok(tagService.update(id, payload.get("name"))); + public ResponseEntity updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) { + return ResponseEntity.ok(tagService.update(id, dto)); } @DeleteMapping("/{id}") @@ -46,4 +47,9 @@ public class TagController { public List searchTags(@RequestParam(defaultValue = "") String query) { return tagService.search(query); } + + @GetMapping("/tree") + public List getTagTree() { + return tagService.getTagTree(); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java new file mode 100644 index 00000000..14e59da9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.List; +import java.util.UUID; + +public record TagTreeNodeDTO(UUID id, String name, String color, int documentCount, List children) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java new file mode 100644 index 00000000..7b840228 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +public record TagUpdateDTO(String name, UUID parentId, String color) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 26aab838..279b9cef 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -78,6 +78,12 @@ public enum ErrorCode { /** A training run is already in progress. 409 */ TRAINING_ALREADY_RUNNING, + // --- Tags --- + /** The supplied color token is not in the allowed palette. 400 */ + INVALID_TAG_COLOR, + /** Setting this parent would create a cycle in the tag hierarchy. 400 */ + TAG_CYCLE_DETECTED, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java index 5063ffa3..59c173f3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java @@ -20,4 +20,11 @@ public class Tag { @Column(unique = true, nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String name; + + /** UUID of the parent tag, or null for root-level tags. */ + @Column(name = "parent_id") + private UUID parentId; + + /** Color token name (e.g. "sage"), only set on root-level tags. Null means no color. */ + private String color; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java index d4e58a04..a9f2c11d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java @@ -6,8 +6,32 @@ import java.util.UUID; import org.raddatz.familienarchiv.model.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TagRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); + List findByNameContainingIgnoreCase(String name); -} \ No newline at end of file + + /** + * Returns the IDs of all ancestors of the given tag (parent, grandparent, …) + * via a recursive CTE. Used for cycle detection before assigning a new parent. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT parent_id, 0 AS depth + FROM tag + WHERE id = :tagId AND parent_id IS NOT NULL + UNION ALL + SELECT t.parent_id, a.depth + 1 + FROM tag t + JOIN ancestors a ON t.id = a.parent_id + WHERE t.parent_id IS NOT NULL AND a.depth < 50 + ) + SELECT parent_id FROM ancestors + """, nativeQuery = true) + List findAncestorIds(@Param("tagId") UUID tagId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java index 06b8b862..336ed624 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -1,8 +1,16 @@ package org.raddatz.familienarchiv.service; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; +import org.raddatz.familienarchiv.dto.TagUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.TagRepository; import org.springframework.http.HttpStatus; @@ -16,6 +24,11 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class TagService { + static final Set ALLOWED_TAG_COLORS = Set.of( + "navy", "teal", "ocean", "forest", "sage", + "sienna", "terracotta", "ochre", "rose", "violet" + ); + private final TagRepository tagRepository; public List search(String query) { @@ -34,9 +47,22 @@ public class TagService { } @Transactional - public Tag update(UUID id, String newName) { + public Tag update(UUID id, TagUpdateDTO dto) { Tag tag = getById(id); - tag.setName(newName); + + if (dto.parentId() != null) { + validateNoSelfReference(id, dto.parentId()); + validateNoAncestorCycle(id, dto.parentId()); + getById(dto.parentId()); // ensure parent exists + } + + if (dto.color() != null) { + validateColor(dto.color()); + } + + tag.setName(dto.name()); + tag.setParentId(dto.parentId()); + tag.setColor(dto.color()); return tagRepository.save(tag); } @@ -44,4 +70,74 @@ public class TagService { public void delete(UUID id) { tagRepository.delete(getById(id)); } + + /** + * Returns all tags assembled into a tree. Document counts are not included here + * (they are populated by the controller layer if needed, or set to 0). + */ + public List getTagTree() { + List all = tagRepository.findAll(); + return buildTree(all); + } + + // ─── private helpers ───────────────────────────────────────────────────── + + private void validateNoSelfReference(UUID tagId, UUID proposedParentId) { + if (tagId.equals(proposedParentId)) { + throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED, + "A tag cannot be its own parent: " + tagId); + } + } + + private void validateNoAncestorCycle(UUID tagId, UUID proposedParentId) { + // TOCTOU: concurrent writes could both pass this check; the self-reference + // CHECK constraint on the DB column is the hard backstop for the self-loop case. + List ancestors = tagRepository.findAncestorIds(proposedParentId); + if (ancestors.contains(tagId)) { + throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED, + "Assigning parent " + proposedParentId + " to tag " + tagId + " would create a cycle"); + } + } + + private void validateColor(String color) { + if (!ALLOWED_TAG_COLORS.contains(color)) { + throw DomainException.badRequest(ErrorCode.INVALID_TAG_COLOR, + "Color '" + color + "' is not in the allowed palette"); + } + } + + private List buildTree(List tags) { + Map> childrenByParent = new HashMap<>(); + + for (Tag tag : tags) { + childrenByParent.putIfAbsent(tag.getId(), new ArrayList<>()); + if (tag.getParentId() != null) { + childrenByParent.computeIfAbsent(tag.getParentId(), k -> new ArrayList<>()); + } + } + + for (Tag tag : tags) { + TagTreeNodeDTO node = new TagTreeNodeDTO( + tag.getId(), tag.getName(), tag.getColor(), 0, + childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()) + ); + if (tag.getParentId() != null) { + childrenByParent.get(tag.getParentId()).add(node); + } else { + childrenByParent.get(tag.getId()); // ensure root is tracked + } + } + + // Collect root nodes (tags without a parent) + List roots = new ArrayList<>(); + for (Tag tag : tags) { + if (tag.getParentId() == null) { + roots.add(new TagTreeNodeDTO( + tag.getId(), tag.getName(), tag.getColor(), 0, + childrenByParent.getOrDefault(tag.getId(), new ArrayList<>()) + )); + } + } + return roots; + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java index 77c2176f..b61adea6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; @@ -19,10 +20,10 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.UUID; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(TagController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -82,6 +83,30 @@ class TagControllerTest { .andExpect(status().isOk()); } + // ─── GET /api/tags/tree ─────────────────────────────────────────────────── + + @Test + void getTagTree_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/tags/tree")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getTagTree_returns200_withTreeStructure() throws Exception { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of()); + TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child)); + when(tagService.getTagTree()).thenReturn(List.of(parent)); + + mockMvc.perform(get("/api/tags/tree")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Immobilie")) + .andExpect(jsonPath("$[0].color").value("teal")) + .andExpect(jsonPath("$[0].children[0].name").value("Haus")); + } + // ─── DELETE /api/tags/{id} ──────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java index 8700e153..5f42e964 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -5,10 +5,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.TagUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.TagRepository; import org.springframework.web.server.ResponseStatusException; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -89,22 +93,146 @@ class TagServiceTest { when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); - Tag result = tagService.update(id, "New"); + Tag result = tagService.update(id, new TagUpdateDTO("New", null, null)); assertThat(result.getName()).isEqualTo("New"); } + @Test + void update_savesParentId() { + UUID id = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Child").build(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.findById(parentId)).thenReturn(Optional.of(parent)); + when(tagRepository.findAncestorIds(parentId)).thenReturn(List.of()); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Child", parentId, null)); + + assertThat(result.getParentId()).isEqualTo(parentId); + } + + @Test + void update_savesColor() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Colored").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Colored", null, "sage")); + + assertThat(result.getColor()).isEqualTo("sage"); + } + @Test void update_throwsNotFound_whenTagMissing() { UUID id = UUID.randomUUID(); when(tagRepository.findById(id)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> tagService.update(id, "New")) + assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null))) .isInstanceOf(ResponseStatusException.class) .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) .isEqualTo(404); } + // ─── color validation ───────────────────────────────────────────────────── + + @Test + void update_throwsInvalidTagColor_whenColorNotInAllowedPalette() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Tag").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + + assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("Tag", null, "hotpink"))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_TAG_COLOR); + } + + @Test + void update_allowsNullColor() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Tag").color("sage").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Tag", null, null)); + + assertThat(result.getColor()).isNull(); + } + + // ─── cycle detection ───────────────────────────────────────────────────── + + @Test + void update_throwsCycleDetected_whenTagIsAncestorOfProposedParent() { + UUID tagId = UUID.randomUUID(); + UUID proposedParentId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); + // tagId appears in the ancestor chain of proposedParentId → cycle + when(tagRepository.findAncestorIds(proposedParentId)).thenReturn(List.of(tagId)); + + assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", proposedParentId, null))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED); + } + + @Test + void update_throwsCycleDetected_whenTagIsSameAsProposedParent() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); + + assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", tagId, null))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED); + } + + // ─── getTagTree ─────────────────────────────────────────────────────────── + + @Test + void getTagTree_returnsEmptyList_whenNoTags() { + when(tagRepository.findAll()).thenReturn(List.of()); + + assertThat(tagService.getTagTree()).isEmpty(); + } + + @Test + void getTagTree_returnsFlatRootTags_whenNoParentRelationships() { + UUID idA = UUID.randomUUID(); + UUID idB = UUID.randomUUID(); + List tags = List.of( + Tag.builder().id(idA).name("Alpha").build(), + Tag.builder().id(idB).name("Beta").build() + ); + when(tagRepository.findAll()).thenReturn(tags); + + var tree = tagService.getTagTree(); + + assertThat(tree).hasSize(2); + assertThat(tree).allSatisfy(node -> assertThat(node.children()).isEmpty()); + } + + @Test + void getTagTree_nestsChildrenUnderParent() { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build(); + when(tagRepository.findAll()).thenReturn(List.of(parent, child)); + + var tree = tagService.getTagTree(); + + assertThat(tree).hasSize(1); + assertThat(tree.get(0).id()).isEqualTo(parentId); + assertThat(tree.get(0).children()).hasSize(1); + assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId); + } + // ─── delete ─────────────────────────────────────────────────────────────── @Test -- 2.49.1 From 57dc72b51db79e5302c1d05b5bdec15707ac7bd8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 15:44:18 +0200 Subject: [PATCH 03/51] feat(#221): add AND/OR tag filtering with hierarchy expansion in document search - Replace hasTags(List) spec with hasTags(List>, useOr) - AND mode: one EXISTS subquery per expanded tag ID set; empty set = disjunction - OR mode: union of all expanded sets into a single EXISTS subquery - DocumentService calls tagService.expandTagNamesToDescendantIdSets() before building spec - DocumentController exposes ?tagOp=AND|OR query param (default AND) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 5 +- .../repository/DocumentSpecifications.java | 69 ++++++++---- .../service/DocumentService.java | 7 +- .../controller/DocumentControllerTest.java | 10 +- .../repository/DocumentRepositoryTest.java | 103 ++++++++++++++++++ .../DocumentSpecificationsTest.java | 52 +++++---- .../service/DocumentServiceSortTest.java | 6 +- .../service/DocumentServiceTest.java | 16 +-- 8 files changed, 209 insertions(+), 59 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 91e3c250..6f47ea01 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -204,11 +204,12 @@ public class DocumentController { @RequestParam(required = false) String tagQ, @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, - @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) { + @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir, + @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC"); } - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir)); + return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, tagOp)); } // --- TRAINING LABELS --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index 4ce5cb63..8e89034d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -4,6 +4,7 @@ import jakarta.persistence.criteria.*; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; import org.raddatz.familienarchiv.model.Document; @@ -54,34 +55,64 @@ public class DocumentSpecifications { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } - // Filtert nach Schlagworten (UND-Verknüpfung, exakter Match) - public static Specification hasTags(List tags) { + /** + * Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik. + * + *

AND (useOr=false): Das Dokument muss mindestens einen Tag aus jedem Set besitzen. + *

OR (useOr=true): Das Dokument muss mindestens einen Tag aus der Vereinigung aller Sets besitzen. + * + *

Jedes Set repräsentiert einen ausgewählten Tag inklusive aller seiner Nachkommen + * (vorausgeweitet durch {@code TagRepository.findDescendantIdsByName}). + */ + public static Specification hasTags(List> tagIdSets, boolean useOr) { return (root, query, cb) -> { - if (tags == null || tags.isEmpty()) + if (tagIdSets == null || tagIdSets.isEmpty()) return null; - List predicates = new ArrayList<>(); - - for (String tagName : tags) { - if (!StringUtils.hasText(tagName)) continue; - - Subquery subquery = query.subquery(Long.class); - Root subRoot = subquery.from(Document.class); - Join subTags = subRoot.join("tags"); - - subquery.select(subRoot.get("id")) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase()) - ); - - predicates.add(cb.exists(subquery)); + if (!useOr) { + // AND mode: an empty set means the tag resolved to no IDs (doesn't exist) — + // no document can satisfy the condition, so return no results immediately. + boolean hasEmptySet = tagIdSets.stream().anyMatch(s -> s == null || s.isEmpty()); + if (hasEmptySet) return cb.disjunction(); } + List> nonEmpty = tagIdSets.stream() + .filter(s -> s != null && !s.isEmpty()) + .toList(); + if (nonEmpty.isEmpty()) return null; + + if (useOr) { + Set union = new java.util.HashSet<>(); + nonEmpty.forEach(union::addAll); + return documentHasTagIn(root, query, cb, union); + } + + // AND: one EXISTS subquery per set + List predicates = new ArrayList<>(); + for (Set ids : nonEmpty) { + predicates.add(documentHasTagIn(root, query, cb, ids)); + } return cb.and(predicates.toArray(new Predicate[0])); }; } + private static Predicate documentHasTagIn( + Root root, + jakarta.persistence.criteria.CriteriaQuery query, + jakarta.persistence.criteria.CriteriaBuilder cb, + Set tagIds) { + Subquery subquery = query.subquery(UUID.class); + Root subRoot = subquery.from(Document.class); + Join subTags = subRoot.join("tags"); + + subquery.select(subRoot.get("id")) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + subTags.get("id").in(tagIds) + ); + return cb.exists(subquery); + } + // Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche public static Specification hasTagPartial(String tagQ) { return (root, query, cb) -> { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index ab50ab22..f4b8f0c9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -293,7 +293,7 @@ public class DocumentService { } // 1. Allgemeine Suche (für das Suchfeld im Frontend) - public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) { + public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, String tagOperator) { boolean hasText = StringUtils.hasText(text); List rankedIds = null; @@ -302,12 +302,15 @@ public class DocumentService { if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of()); } + boolean useOrLogic = "OR".equalsIgnoreCase(tagOperator); + List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); + Specification textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null; Specification spec = Specification.where(textSpec) .and(isBetween(from, to)) .and(hasSender(sender)) .and(hasReceiver(receiver)) - .and(hasTags(tags)) + .and(hasTags(expandedTagSets, useOrLogic)) .and(hasTagPartial(tagQ)) .and(hasStatus(status)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index c7e2f279..dd73aa91 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -62,7 +62,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_returns200_whenAuthenticated() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -72,13 +72,13 @@ class DocumentControllerTest { @Test @WithMockUser void search_withStatusParam_passesItToService() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) .andExpect(status().isOk()); - verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()); } @Test @@ -105,7 +105,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_responseContainsTotalCount() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -126,7 +126,7 @@ class DocumentControllerTest { .build(); var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData( "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 80f1fd00..8d12a678 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -37,6 +38,9 @@ class DocumentRepositoryTest { @Autowired private PersonRepository personRepository; + @Autowired + private TagRepository tagRepository; + @Autowired private AnnotationRepository annotationRepository; @@ -345,6 +349,105 @@ class DocumentRepositoryTest { assertThat(stats.getTranscriptionCount()).isEqualTo(0L); } + // ─── hasTags specification — AND/OR + hierarchy ─────────────────────────── + + @Test + void hasTags_and_findsDocumentThatHasBothTags() { + Tag tagA = tagRepository.save(Tag.builder().name("TagA").build()); + Tag tagB = tagRepository.save(Tag.builder().name("TagB").build()); + Tag tagC = tagRepository.save(Tag.builder().name("TagC").build()); + + Document docAB = documentRepository.save(Document.builder() + .title("DocAB").originalFilename("docab.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA, tagB))).build()); + documentRepository.save(Document.builder() + .title("DocA").originalFilename("doca.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA))).build()); + + // AND: must have both TagA and TagB + List setA = tagRepository.findDescendantIdsByName("TagA").stream().toList(); + List setB = tagRepository.findDescendantIdsByName("TagB").stream().toList(); + List setC = tagRepository.findDescendantIdsByName("TagC").stream().toList(); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(setA), new HashSet<>(setB)), false); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(docAB.getId()); + } + + @Test + void hasTags_or_findsDocumentThatHasEitherTag() { + Tag tagA = tagRepository.save(Tag.builder().name("OrTagA").build()); + Tag tagB = tagRepository.save(Tag.builder().name("OrTagB").build()); + + Document docA = documentRepository.save(Document.builder() + .title("OrDocA").originalFilename("ordoca.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA))).build()); + Document docB = documentRepository.save(Document.builder() + .title("OrDocB").originalFilename("ordocb.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagB))).build()); + + List setA = tagRepository.findDescendantIdsByName("OrTagA").stream().toList(); + List setB = tagRepository.findDescendantIdsByName("OrTagB").stream().toList(); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(setA), new HashSet<>(setB)), true); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(2); + assertThat(results).extracting(Document::getId).containsExactlyInAnyOrder(docA.getId(), docB.getId()); + } + + @Test + void hasTags_hierarchySearch_findsDocumentTaggedWithChildWhenSearchingByParent() { + Tag parent = tagRepository.save(Tag.builder().name("HierParent").build()); + Tag child = tagRepository.save(Tag.builder().name("HierChild").parentId(parent.getId()).build()); + + Document docWithChild = documentRepository.save(Document.builder() + .title("DocWithChild").originalFilename("docwithchild.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(child))).build()); + documentRepository.save(Document.builder() + .title("DocWithParent").originalFilename("docwithparent.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(parent))).build()); + + // Searching by "HierParent" should include descendants (HierChild) + List parentAndDescendants = tagRepository.findDescendantIdsByName("HierParent") + .stream().toList(); + + // Must include both parent and child IDs + assertThat(parentAndDescendants).contains(parent.getId(), child.getId()); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(parentAndDescendants)), false); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(2); // both doc-with-child and doc-with-parent match + } + + @Test + void findDescendantIdsByName_returnsOnlyMatchingTag_whenNoChildren() { + Tag tag = tagRepository.save(Tag.builder().name("Leaf").build()); + + List ids = tagRepository.findDescendantIdsByName("Leaf") + .stream().toList(); + + assertThat(ids).containsExactly(tag.getId()); + } + + @Test + void findDescendantIdsByName_returnsParentAndAllDescendants() { + Tag grandparent = tagRepository.save(Tag.builder().name("Grandparent").build()); + Tag parent2 = tagRepository.save(Tag.builder().name("ParentNode").parentId(grandparent.getId()).build()); + Tag child2 = tagRepository.save(Tag.builder().name("ChildNode").parentId(parent2.getId()).build()); + + List ids = tagRepository.findDescendantIdsByName("Grandparent") + .stream().toList(); + + assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId()); + } + // ─── seeding helpers ───────────────────────────────────────────────────── private Document uploaded(String title) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index 80ca4c08..cefe9918 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -15,8 +15,10 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.domain.Specification; import java.time.LocalDate; +import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*; @@ -156,47 +158,57 @@ class DocumentSpecificationsTest { // ─── hasTags ────────────────────────────────────────────────────────────── @Test - void hasTags_returnsAllDocuments_whenTagListIsNull() { - List result = documentRepository.findAll(Specification.where(hasTags(null))); + void hasTags_returnsAllDocuments_whenTagSetListIsNull() { + List result = documentRepository.findAll(Specification.where(hasTags(null, false))); assertThat(result).hasSize(3); } @Test - void hasTags_returnsAllDocuments_whenTagListIsEmpty() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of()))); + void hasTags_returnsAllDocuments_whenTagSetListIsEmpty() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of(), false))); assertThat(result).hasSize(3); } @Test - void hasTags_filtersDocumentsByTag() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie")))); + void hasTags_and_filtersDocumentsByTag() { + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + List result = documentRepository.findAll(Specification.where(hasTags(List.of(familieIds), false))); assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); } @Test - void hasTags_isCaseInsensitive() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("familie")))); - assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); - } - - @Test - void hasTags_requiresAllTagsToBePresent_andLogic() { - // briefEarly has "Familie" but not "Urlaub" — should be excluded + void hasTags_and_requiresAllTagsToBePresent() { + // briefEarly has "Familie" but not "Urlaub" — AND should return empty + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + Set urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub")); List result = documentRepository.findAll( - Specification.where(hasTags(List.of("Familie", "Urlaub")))); + Specification.where(hasTags(List.of(familieIds, urlaubIds), false))); assertThat(result).isEmpty(); } @Test - void hasTags_skipsEmptyTagNames() { - // An empty string in the tag list should be ignored - List result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie")))); - assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + void hasTags_or_findsDocumentWithEitherTag() { + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + Set urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub")); + List result = documentRepository.findAll( + Specification.where(hasTags(List.of(familieIds, urlaubIds), true))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasTags_returnsEmpty_whenTagIdSetIsEmpty() { + // An empty ID set means the requested tag resolved to nothing — no docs can match + List result = documentRepository.findAll( + Specification.where(hasTags(List.of(new HashSet<>()), false))); + assertThat(result).isEmpty(); } @Test void hasTags_returnsEmpty_whenTagDoesNotExist() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt")))); + // Non-existent tag → findDescendantIdsByName returns empty list → hasTags returns no results + Set unknownIds = new HashSet<>(tagRepository.findDescendantIdsByName("Unbekannt")); + List result = documentRepository.findAll(Specification.where(hasTags(List.of(unknownIds), false))); assertThat(result).isEmpty(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java index f089635c..49726999 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java @@ -53,7 +53,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(newer, older)); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC"); + "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null); // Expect: date order (newer 1960 first), NOT rank order (older 1940 first) assertThat(result.documents()).hasSize(2); @@ -75,7 +75,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(doc2, doc1)); // unordered from DB DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); // Expect: rank order restored (id1 first) assertThat(result.documents().get(0).getId()).isEqualTo(id1); @@ -94,7 +94,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(doc2, doc1)); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, null, null); + "Brief", null, null, null, null, null, null, null, null, null, null); assertThat(result.documents().get(0).getId()).isEqualTo(id1); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 93fddb8f..2070dd46 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1204,7 +1204,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null); + documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } @@ -1214,7 +1214,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null); + documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } @@ -1292,7 +1292,7 @@ class DocumentServiceTest { .thenReturn(List.of(withSender, noSender)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); assertThat(result.documents()).hasSize(2); assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender"); @@ -1312,7 +1312,7 @@ class DocumentServiceTest { .thenReturn(List.of(noReceivers, withReceiver)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null); assertThat(result.documents()).extracting(Document::getTitle) .containsExactly("Has Receiver", "No Receivers"); @@ -1334,7 +1334,7 @@ class DocumentServiceTest { .thenReturn(List.of(docNullName, docSmith)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); // null lastName should sort to end (treated as empty), not before "smith" (as "null") assertThat(result.documents()).extracting(Document::getTitle) @@ -1356,7 +1356,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); assertThat(result.matchData()).containsKey(docId); SearchMatchData md = result.matchData().get(docId); @@ -1370,7 +1370,7 @@ class DocumentServiceTest { .thenReturn(List.of()); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); assertThat(result.matchData()).isEmpty(); } @@ -1389,7 +1389,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); SearchMatchData md = result.matchData().get(docId); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); -- 2.49.1 From c3e007d421ad893e7b04098cc22867198374b617 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 15:48:37 +0200 Subject: [PATCH 04/51] chore(#221): regenerate TypeScript API types with Tag hierarchy fields Adds TagTreeNodeDTO, TagUpdateDTO (parentId + color), /api/tags/tree endpoint, and parentId/color fields on Tag schema. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 68 +++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index dc00b483..0bc57555 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -740,6 +740,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/tags/tree": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getTagTree"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/stats": { parameters: { query?: never; @@ -1190,10 +1206,19 @@ export interface components { notifyOnReply?: boolean; notifyOnMention?: boolean; }; + TagUpdateDTO: { + name?: string; + /** Format: uuid */ + parentId?: string; + color?: string; + }; Tag: { /** Format: uuid */ id: string; name: string; + /** Format: uuid */ + parentId?: string; + color?: string; }; PersonUpdateDTO: { title?: string; @@ -1533,6 +1558,15 @@ export interface components { /** Format: int32 */ reviewedBlockCount: number; }; + TagTreeNodeDTO: { + /** Format: uuid */ + id?: string; + name?: string; + color?: string; + /** Format: int32 */ + documentCount?: number; + children?: components["schemas"]["TagTreeNodeDTO"][]; + }; StatsDTO: { /** Format: int64 */ totalPersons?: number; @@ -1596,13 +1630,11 @@ export interface components { timeout?: number; }; PageNotificationDTO: { - /** Format: int64 */ - totalElements?: number; /** Format: int32 */ totalPages?: number; + /** Format: int64 */ + totalElements?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1611,6 +1643,8 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { @@ -1875,9 +1909,7 @@ export interface operations { }; requestBody: { content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["TagUpdateDTO"]; }; }; responses: { @@ -3279,6 +3311,26 @@ export interface operations { }; }; }; + getTagTree: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TagTreeNodeDTO"][]; + }; + }; + }; + }; getStats: { parameters: { query?: never; @@ -3668,6 +3720,8 @@ export interface operations { sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE"; /** @description Sort direction: ASC or DESC */ dir?: string; + /** @description Tag operator: AND (default) or OR */ + tagOp?: string; }; header?: never; path?: never; -- 2.49.1 From e4f21bd896a4bf1f82654458e0286dcb84ad951f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 15:50:13 +0200 Subject: [PATCH 05/51] feat(#221): add --c-tag-* CSS custom properties for 10 semantic tag color tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light and dark variants for: sage, sienna, amber, slate, violet, rose, cobalt, moss, sand, coral — used as decorative dot colors on tag chips. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index d9b311e7..39a433e7 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -115,6 +115,18 @@ --c-danger: #c0392b; --c-danger-fg: #ffffff; + /* Tag color tokens — decorative dot colors on tag chips */ + --c-tag-sage: #5a8a6a; + --c-tag-sienna: #a0522d; + --c-tag-amber: #c17a00; + --c-tag-slate: #607080; + --c-tag-violet: #7a4f9a; + --c-tag-rose: #c0446e; + --c-tag-cobalt: #3060b0; + --c-tag-moss: #4a7a3a; + --c-tag-sand: #9a8040; + --c-tag-coral: #c05540; + /* PersonType badge — institution (navy-tinted blue) */ --c-badge-institution-bg: #e8eff7; --c-badge-institution-text: #1a4971; @@ -183,6 +195,18 @@ /* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */ --c-danger: #e55347; --c-danger-fg: #ffffff; + + /* Tag color tokens — lighter for visibility on dark backgrounds */ + --c-tag-sage: #7abf8a; + --c-tag-sienna: #cc7050; + --c-tag-amber: #f0aa30; + --c-tag-slate: #8a9db0; + --c-tag-violet: #b07ad0; + --c-tag-rose: #f07090; + --c-tag-cobalt: #6090e0; + --c-tag-moss: #70b060; + --c-tag-sand: #c0a060; + --c-tag-coral: #f07060; } } @@ -235,6 +259,18 @@ /* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */ --c-danger: #e55347; --c-danger-fg: #ffffff; + + /* Tag color tokens — lighter for visibility on dark backgrounds */ + --c-tag-sage: #7abf8a; + --c-tag-sienna: #cc7050; + --c-tag-amber: #f0aa30; + --c-tag-slate: #8a9db0; + --c-tag-violet: #b07ad0; + --c-tag-rose: #f07090; + --c-tag-cobalt: #6090e0; + --c-tag-moss: #70b060; + --c-tag-sand: #c0a060; + --c-tag-coral: #f07060; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */ -- 2.49.1 From e8e54cc282f9095ff02cb9f15964ee9579e50756 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 16:11:38 +0200 Subject: [PATCH 06/51] feat(#221): change TagInput binding to Tag[], add color dots and hierarchy grouping Backend: - TagRepository: add findDescendantIdsByName() recursive CTE query - TagService: add expandTagNamesToDescendantIdSets() for document search Frontend: - TagInput: accept Tag[] (id, name, color, parentId) instead of string[] - Chips show color dot via var(--c-tag-{color}) when tag has color - Suggestions grouped hierarchically: children indented under their parents - Update DescriptionSection, edit/new pages, SearchFilterBar, +page.svelte Co-Authored-By: Claude Sonnet 4.6 --- .../repository/TagRepository.java | 17 ++++ .../familienarchiv/service/TagService.java | 14 +++ frontend/src/lib/components/TagInput.svelte | 86 +++++++++++++++---- .../lib/components/TagInput.svelte.spec.ts | 50 +++++++++-- .../document/DescriptionSection.svelte | 8 +- frontend/src/routes/+page.svelte | 12 +-- frontend/src/routes/SearchFilterBar.svelte | 4 +- .../routes/documents/[id]/edit/+page.svelte | 2 +- .../src/routes/documents/new/+page.svelte | 2 +- frontend/src/routes/enrich/[id]/+page.svelte | 2 +- 10 files changed, 158 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java index a9f2c11d..7e43d52d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java @@ -34,4 +34,21 @@ public interface TagRepository extends JpaRepository { SELECT parent_id FROM ancestors """, nativeQuery = true) List findAncestorIds(@Param("tagId") UUID tagId); + + /** + * Returns the IDs of the tag with the given name AND all of its descendants + * via a recursive CTE. Used to expand a selected tag to inclusive hierarchy results. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth FROM tag WHERE LOWER(name) = LOWER(:name) + UNION ALL + SELECT t.id, d.depth + 1 FROM tag t + JOIN descendants d ON t.parent_id = d.id + WHERE d.depth < 50 + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIdsByName(@Param("name") String name); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java index 336ed624..a4e47e12 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -16,6 +17,7 @@ import org.raddatz.familienarchiv.repository.TagRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; @@ -71,6 +73,18 @@ public class TagService { tagRepository.delete(getById(id)); } + /** + * For each tag name, returns the set of that tag's ID plus all descendant IDs. + * Used by DocumentService to expand selected filter tags before applying AND/OR logic. + */ + public List> expandTagNamesToDescendantIdSets(List tagNames) { + if (tagNames == null || tagNames.isEmpty()) return List.of(); + return tagNames.stream() + .filter(StringUtils::hasText) + .map(name -> (Set) new HashSet<>(tagRepository.findDescendantIdsByName(name.trim()))) + .toList(); + } + /** * Returns all tags assembled into a tree. Document counts are not included here * (they are populated by the controller layer if needed, or set to 0). diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 78eaeca8..7a2decc8 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -1,10 +1,13 @@ @@ -79,7 +121,15 @@ function handleKeydown(e: KeyboardEvent) { {#each tags as tag, i (i)} - {tag} + {#if tag.color} + + {/if} + {tag.name} + + + {/if} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index c3f8a454..86075e09 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import SearchFilterBar from './SearchFilterBar.svelte'; +afterEach(() => cleanup()); + const defaultProps = { onSearch: vi.fn() }; @@ -41,6 +43,68 @@ describe('SearchFilterBar – loading spinner', () => { }); }); +describe('SearchFilterBar – AND/OR tag operator toggle', () => { + async function openAdvanced() { + const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); + await filterBtn.click(); + } + + it('hides AND/OR toggle when fewer than 2 tags are selected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }] + }); + await openAdvanced(); + await expect.element(page.getByRole('button', { name: 'AND' })).not.toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + + it('shows AND/OR toggle when 2+ tags are selected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }] + }); + await openAdvanced(); + const toggle = page.getByTestId('and-or-toggle'); + await expect.element(toggle).toBeInTheDocument(); + await expect.element(toggle.getByRole('button', { name: 'AND' })).toBeInTheDocument(); + await expect.element(toggle.getByRole('button', { name: 'OR' })).toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + + it('calls onSearch when operator is toggled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + const onSearch = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + onSearch, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }] + }); + await openAdvanced(); + const toggle = page.getByTestId('and-or-toggle'); + await toggle.getByRole('button', { name: 'OR' }).click(); + await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0); + vi.unstubAllGlobals(); + }); +}); + describe('SearchFilterBar – tagQ live filter', () => { it('calls onSearch when tag text changes in TagInput', async () => { vi.stubGlobal( -- 2.49.1 From abba85a451cfd1b71ec4e8d6e1f1a0e62a1c3640 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 16:25:27 +0200 Subject: [PATCH 09/51] feat(#221): wire tagOp URL param from server to SearchFilterBar Reads ?tagOp=OR from URL in +page.server.ts, passes it to the backend search endpoint, and surfaces it via the filters return. +page.svelte initialises tagOperator state from filters, writes it back to the URL in triggerSearch(), and binds it to SearchFilterBar. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.server.ts | 6 ++++-- frontend/src/routes/+page.svelte | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 6a430477..258c8213 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -29,6 +29,7 @@ export async function load({ url, fetch }) { ? (rawDir as ValidDir) : 'desc'; const tagQ = url.searchParams.get('tagQ') || ''; + const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ; @@ -48,6 +49,7 @@ export async function load({ url, fetch }) { receiverId: receiverId || undefined, tag: tags.length ? tags : undefined, tagQ: tagQ || undefined, + tagOp: tagOp === 'OR' ? 'OR' : undefined, sort, dir: dir || undefined } @@ -147,7 +149,7 @@ export async function load({ url, fetch }) { senderName: senderObj?.displayName ?? '', receiverName: receiverObj?.displayName ?? '' }, - filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ }, + filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp }, error: null as string | null }; } catch (e) { @@ -166,7 +168,7 @@ export async function load({ url, fetch }) { readyDocs: [], weeklyStats: null, initialValues: { senderName: '', receiverName: '' }, - filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ }, + filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp }, error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 1ca31d88..801b2280 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -26,6 +26,9 @@ let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: st let sort = $state(untrack(() => data.filters?.sort || 'DATE')); let dir = $state(untrack(() => data.filters?.dir || 'desc')); let tagQ = $state(untrack(() => data.filters?.tagQ || '')); +let tagOperator = $state<'AND' | 'OR'>( + untrack(() => (data.filters?.tagOp as 'AND' | 'OR') || 'AND') +); const hasAdvancedFilters = (filters: typeof data.filters) => (filters?.tags?.length ?? 0) > 0 || @@ -49,6 +52,7 @@ function triggerSearch() { if (sort) params.set('sort', sort); if (dir) params.set('dir', dir); if (tagQ) params.set('tagQ', tagQ); + if (tagOperator === 'OR') params.set('tagOp', 'OR'); goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true }); } @@ -79,6 +83,7 @@ $effect(() => { sort = data.filters?.sort || 'DATE'; dir = data.filters?.dir || 'desc'; tagQ = data.filters?.tagQ || ''; + tagOperator = (data.filters?.tagOp as 'AND' | 'OR') || 'AND'; if (hasAdvancedFilters(data.filters)) showAdvanced = true; }); @@ -104,6 +109,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ? bind:sort={sort} bind:dir={dir} bind:tagQ={tagQ} + bind:tagOperator={tagOperator} initialSenderName={data.initialValues?.senderName} initialReceiverName={data.initialValues?.receiverName} isLoading={navigating.to !== null} -- 2.49.1 From d9004809200eba24b9f506a823ac51be64cdfd8d Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 16:39:02 +0200 Subject: [PATCH 10/51] feat(#221): add parent selector and color picker to admin tag edit form Tag edit form gains a parent + + {#each data.tags.filter((t) => t.id !== data.tag.id) as tag (tag.id)} + + {/each} + + + + + {#if parentId === ''} +

+

Farbe

+
+ {#each colors as colorName (colorName)} + + {/each} + +
+ +
+ {:else} + + {/if} diff --git a/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts index e327f6b8..e6909200 100644 --- a/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts @@ -9,7 +9,10 @@ vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); import { beforeNavigate, goto } from '$app/navigation'; const baseTag = { id: 't1', name: 'Familie' }; -const baseData = { tag: baseTag }; +const baseData = { + tag: baseTag, + tags: [] as { id: string; name: string; parentId?: string; color?: string }[] +}; afterEach(cleanup); @@ -91,3 +94,74 @@ describe('Admin edit tag page – unsaved-changes guard', () => { expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2'); }); }); + +// ─── Parent selector ────────────────────────────────────────────────────────── + +describe('Admin edit tag page – parent selector', () => { + it('renders a parent selector', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByRole('combobox', { name: /übergeordnet/i })).toBeInTheDocument(); + }); + + it('shows other tags in the parent selector', async () => { + render(Page, { + data: { + tag: { id: 't1', name: 'Familie' }, + tags: [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Reise' } + ] + }, + form: null + }); + await expect.element(page.getByRole('option', { name: 'Reise' })).toBeInTheDocument(); + }); + + it('does not show self in the parent selector', async () => { + render(Page, { + data: { + tag: { id: 't1', name: 'Familie' }, + tags: [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Reise' } + ] + }, + form: null + }); + const options = document.querySelectorAll('select[name="parentId"] option'); + const values = Array.from(options).map((o) => o.value); + expect(values).not.toContain('t1'); + }); +}); + +// ─── Color picker ───────────────────────────────────────────────────────────── + +describe('Admin edit tag page – color picker', () => { + it('renders color picker when tag has no parent', async () => { + render(Page, { + data: { tag: { id: 't1', name: 'Familie', parentId: undefined }, tags: [] }, + form: null + }); + await expect.element(page.getByTestId('color-picker')).toBeInTheDocument(); + }); + + it('hides color picker when tag already has a parent', async () => { + render(Page, { + data: { + tag: { id: 't1', name: 'Familie', parentId: 't2' }, + tags: [{ id: 't2', name: 'Reise' }] + }, + form: null + }); + await expect.element(page.getByTestId('color-picker')).not.toBeInTheDocument(); + }); + + it('pre-selects the current tag color in the color picker', async () => { + render(Page, { + data: { tag: { id: 't1', name: 'Familie', color: 'sage' }, tags: [] }, + form: null + }); + const selected = page.getByTestId('color-swatch-sage'); + await expect.element(selected).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index a31a7e4b..9c1f8890 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -31,7 +31,8 @@ const emptyData = { tags: [], sort: 'DATE' as const, dir: 'desc' as const, - tagQ: '' + tagQ: '', + tagOp: 'AND' }, documents: [], total: 0, -- 2.49.1 From 7f53651f13a892979b0d15f09f384eb4a678f0fc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 16:46:55 +0200 Subject: [PATCH 11/51] feat(#221): render tag list hierarchically with indentation and color dots TagsListPanel now accepts optional parentId/color on each Tag. A $derived.by walk produces an ordered flat list with depth annotations. Child tags are indented with pl-5; root-level tags with a color get a colored dot before their name. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin/tags/TagsListPanel.svelte | 46 +++++++++++++++++-- .../routes/admin/tags/layout.svelte.spec.ts | 43 +++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/admin/tags/TagsListPanel.svelte b/frontend/src/routes/admin/tags/TagsListPanel.svelte index efce7b76..a8bd95bc 100644 --- a/frontend/src/routes/admin/tags/TagsListPanel.svelte +++ b/frontend/src/routes/admin/tags/TagsListPanel.svelte @@ -1,12 +1,17 @@
- +
diff --git a/frontend/src/routes/admin/tags/TagTreeNode.svelte b/frontend/src/routes/admin/tags/TagTreeNode.svelte new file mode 100644 index 00000000..e6e9c28d --- /dev/null +++ b/frontend/src/routes/admin/tags/TagTreeNode.svelte @@ -0,0 +1,84 @@ + + +
  • +
    + {#if hasChildren} + + {:else} + + {/if} + + {#if depth === 0 && node.color} + + {/if} + + + {node.name} + {#if (node.documentCount ?? 0) > 0} + ({node.documentCount}) + {/if} + +
    + + {#if hasChildren && !isCollapsed} +
      + {#each node.children! as child (child.id)} + + {/each} +
    + {/if} +
  • diff --git a/frontend/src/routes/admin/tags/TagsListPanel.svelte b/frontend/src/routes/admin/tags/TagsListPanel.svelte index a8bd95bc..c7dcc164 100644 --- a/frontend/src/routes/admin/tags/TagsListPanel.svelte +++ b/frontend/src/routes/admin/tags/TagsListPanel.svelte @@ -1,48 +1,42 @@ -
    (showDropdown = false)}> +
    typeahead.close()}>