feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249

Merged
marcel merged 51 commits from feat/issue-221-tag-hierarchy into main 2026-04-17 10:24:10 +02:00
63 changed files with 3762 additions and 321 deletions

View File

@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -204,11 +205,15 @@ 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));
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
}
// --- TRAINING LABELS ---

View File

@@ -1,23 +1,29 @@
package org.raddatz.familienarchiv.controller;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.MergeTagDTO;
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;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TagService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -31,8 +37,8 @@ public class TagController {
@PutMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) {
return ResponseEntity.ok(tagService.update(id, dto));
}
@DeleteMapping("/{id}")
@@ -46,4 +52,22 @@ public class TagController {
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
return tagService.search(query);
}
@GetMapping("/tree")
public List<TagTreeNodeDTO> getTagTree() {
return tagService.getTagTree();
}
@PostMapping("/{id}/merge")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> mergeTag(@PathVariable UUID id, @Valid @RequestBody MergeTagDTO dto) {
return ResponseEntity.ok(tagService.mergeTags(id, dto.targetId()));
}
@DeleteMapping("/{id}/subtree")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.ADMIN_TAG)
public void deleteSubtree(@PathVariable UUID id) {
tagService.deleteWithDescendants(id);
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public record MergeTagDTO(@NotNull UUID targetId) {}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
/** Determines how multiple selected tag filters are combined in a document search. */
public enum TagOperator {
/** Every tag set must match (default). */
AND,
/** At least one tag set must match. */
OR
}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record TagTreeNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
String color,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
List<TagTreeNodeDTO> children,
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
public record TagUpdateDTO(String name, UUID parentId, String color) {}

View File

@@ -78,6 +78,18 @@ public enum ErrorCode {
/** A training run is already in progress. 409 */
TRAINING_ALREADY_RUNNING,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
TAG_NOT_FOUND,
/** 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,
/** Merge source and target are the same tag. 400 */
TAG_MERGE_SELF,
/** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

View File

@@ -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;
}

View File

@@ -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<Document> hasTags(List<String> tags) {
/**
* Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik.
*
* <p>AND (useOr=false): Das Dokument muss mindestens einen Tag aus <em>jedem</em> Set besitzen.
* <p>OR (useOr=true): Das Dokument muss mindestens einen Tag aus der Vereinigung aller Sets besitzen.
*
* <p>Jedes Set repräsentiert einen ausgewählten Tag inklusive aller seiner Nachkommen
* (vorausgeweitet durch {@code TagRepository.findDescendantIdsByName}).
*/
public static Specification<Document> hasTags(List<Set<UUID>> tagIdSets, boolean useOr) {
return (root, query, cb) -> {
if (tags == null || tags.isEmpty())
if (tagIdSets == null || tagIdSets.isEmpty())
return null;
List<Predicate> predicates = new ArrayList<>();
for (String tagName : tags) {
if (!StringUtils.hasText(tagName)) continue;
Subquery<Long> subquery = query.subquery(Long.class);
Root<Document> subRoot = subquery.from(Document.class);
Join<Document, Tag> 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<Set<UUID>> nonEmpty = tagIdSets.stream()
.filter(s -> s != null && !s.isEmpty())
.toList();
if (nonEmpty.isEmpty()) return null;
if (useOr) {
Set<UUID> union = new java.util.HashSet<>();
nonEmpty.forEach(union::addAll);
return documentHasTagIn(root, query, cb, union);
}
// AND: one EXISTS subquery per set
List<Predicate> predicates = new ArrayList<>();
for (Set<UUID> ids : nonEmpty) {
predicates.add(documentHasTagIn(root, query, cb, ids));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
private static Predicate documentHasTagIn(
Root<Document> root,
jakarta.persistence.criteria.CriteriaQuery<?> query,
jakarta.persistence.criteria.CriteriaBuilder cb,
Set<UUID> tagIds) {
Subquery<UUID> subquery = query.subquery(UUID.class);
Root<Document> subRoot = subquery.from(Document.class);
Join<Document, Tag> 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<Document> hasTagPartial(String tagQ) {
return (root, query, cb) -> {

View File

@@ -1,13 +1,126 @@
package org.raddatz.familienarchiv.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface TagRepository extends JpaRepository<Tag, UUID> {
/** Typed projection for document-count aggregation results. */
interface TagCount {
UUID getTagId();
Long getCount();
}
Optional<Tag> findByNameIgnoreCase(String name);
List<Tag> findByNameContainingIgnoreCase(String name);
}
/**
* 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<UUID> 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<UUID> findDescendantIdsByName(@Param("name") String name);
/**
* Returns the IDs of the tag with the given ID AND all of its descendants
* via a recursive CTE. Used for merge validation and subtree delete.
* 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 id = :tagId
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<UUID> findDescendantIds(@Param("tagId") UUID tagId);
/**
* Reassigns document_tags rows from source to target, skipping rows where
* the target tag is already present (to avoid PK conflicts).
*/
@Modifying(clearAutomatically = true)
@Query(value = """
UPDATE document_tags
SET tag_id = :targetId
WHERE tag_id = :sourceId
AND NOT EXISTS (
SELECT 1 FROM document_tags d2
WHERE d2.document_id = document_tags.document_id
AND d2.tag_id = :targetId
)
""", nativeQuery = true)
void reassignDocumentTags(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId);
/**
* Removes all document_tags rows for the given tag.
*/
@Modifying(clearAutomatically = true)
@Query(value = "DELETE FROM document_tags WHERE tag_id = :tagId", nativeQuery = true)
void deleteDocumentTagsByTagId(@Param("tagId") UUID tagId);
/**
* Removes all document_tags rows for the given collection of tag IDs.
* Caller must guard against an empty collection — PostgreSQL rejects IN ().
*/
@Modifying(clearAutomatically = true)
@Query(value = "DELETE FROM document_tags WHERE tag_id IN :ids", nativeQuery = true)
void deleteDocumentTagsByTagIds(@Param("ids") Collection<UUID> ids);
/**
* Re-parents all direct children of sourceId to targetId.
*/
@Modifying(clearAutomatically = true)
@Query(value = "UPDATE tag SET parent_id = :targetId WHERE parent_id = :sourceId", nativeQuery = true)
void reparentChildren(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId);
/**
* Returns (tagId, count) pairs for all tags that appear in document_tags.
* Used to populate documentCount in the tag tree without N+1 queries.
*/
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<TagCount> findDocumentCountsPerTag();
}

View File

@@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ScriptType;
@@ -293,7 +294,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<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
@@ -302,12 +303,15 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> 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));
@@ -316,12 +320,12 @@ public class DocumentService {
if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
// RELEVANCE: default when text present and no explicit sort given
@@ -334,12 +338,12 @@ public class DocumentService {
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
Sort springSort = resolveSort(sort, dir);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text));
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -430,8 +434,10 @@ public class DocumentService {
}
public Document getDocumentById(UUID id) {
return documentRepository.findById(id)
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
tagService.resolveEffectiveColors(doc.getTags());
return doc;
}
public List<Document> getDocumentsWithoutVersions() {
@@ -510,6 +516,12 @@ public class DocumentService {
// ─── private helpers ──────────────────────────────────────────────────────
private List<Document> resolveDocumentTagColors(List<Document> docs) {
List<Tag> allTags = docs.stream().flatMap(d -> d.getTags().stream()).toList();
tagService.resolveEffectiveColors(allTags);
return docs;
}
private static String stripExtension(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');

View File

@@ -1,21 +1,41 @@
package org.raddatz.familienarchiv.service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
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;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class TagService {
// These 10 color tokens are the fixed palette.
// Keep in sync with the --c-tag-* tokens defined in frontend/src/routes/layout.css.
static final Set<String> ALLOWED_TAG_COLORS = Set.of(
"sage", "sienna", "amber", "slate", "violet",
"rose", "cobalt", "moss", "sand", "coral"
);
private final TagRepository tagRepository;
public List<Tag> search(String query) {
@@ -24,7 +44,7 @@ public class TagService {
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tag nicht gefunden"));
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
}
public Tag findOrCreate(String name) {
@@ -34,9 +54,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 +77,152 @@ public class TagService {
public void delete(UUID id) {
tagRepository.delete(getById(id));
}
@Transactional
public Tag mergeTags(UUID sourceId, UUID targetId) {
validateNotSelf(sourceId, targetId);
Tag source = getById(sourceId);
Tag target = getById(targetId);
log.info("Merging tag '{}' ({}) into '{}' ({})", source.getName(), sourceId, target.getName(), targetId);
validateNotDescendant(sourceId, targetId);
transferDocuments(sourceId, targetId);
tagRepository.reparentChildren(sourceId, targetId);
tagRepository.deleteById(sourceId);
return target;
}
@Transactional
public void deleteWithDescendants(UUID id) {
log.info("Deleting subtree rooted at {}", id);
getById(id);
List<UUID> ids = tagRepository.findDescendantIds(id);
if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids);
tagRepository.deleteAllById(ids);
log.info("Deleted subtree rooted at {}, {} nodes", id, ids.size());
}
/**
* Sets the effective (inherited) color on child tags that have no color of their own.
* Colors are stored only on root-level tags; children inherit the parent's color.
* Parent tags are batch-loaded in a single query. Safe to call on detached entities.
*/
public void resolveEffectiveColors(Collection<Tag> tags) {
if (tags == null || tags.isEmpty()) return;
Set<UUID> parentIdsNeeded = tags.stream()
.filter(t -> t.getColor() == null && t.getParentId() != null)
.map(Tag::getParentId)
.collect(Collectors.toSet());
if (parentIdsNeeded.isEmpty()) return;
Map<UUID, String> parentColors = tagRepository.findAllById(parentIdsNeeded)
.stream()
.filter(p -> p.getColor() != null)
.collect(Collectors.toMap(Tag::getId, Tag::getColor));
tags.forEach(tag -> {
if (tag.getColor() == null && tag.getParentId() != null) {
String resolved = parentColors.get(tag.getParentId());
if (resolved != null) {
tag.setColor(resolved);
}
}
});
}
/**
* 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<Set<UUID>> expandTagNamesToDescendantIdSets(List<String> tagNames) {
if (tagNames == null || tagNames.isEmpty()) return List.of();
return tagNames.stream()
.filter(StringUtils::hasText)
.map(name -> (Set<UUID>) new HashSet<>(tagRepository.findDescendantIdsByName(name.trim())))
.toList();
}
/**
* Returns all tags assembled into a tree with document counts per node.
* Uses a single aggregate query to avoid N+1 behaviour.
* NOTE: document counts are global per tag, not scoped to any search filter.
* The tree endpoint is only used for the admin sidebar, so this is intentional.
*/
public List<TagTreeNodeDTO> getTagTree() {
List<Tag> all = tagRepository.findAll();
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
.collect(Collectors.toMap(
TagRepository.TagCount::getTagId,
TagRepository.TagCount::getCount
));
return buildTree(all, counts);
}
// ─── private helpers ─────────────────────────────────────────────────────
private void validateNotSelf(UUID sourceId, UUID targetId) {
if (sourceId.equals(targetId)) {
throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF,
"Source and target must not be the same tag: " + sourceId);
}
}
private void validateNotDescendant(UUID sourceId, UUID targetId) {
List<UUID> descendants = tagRepository.findDescendantIds(sourceId);
if (descendants.contains(targetId)) {
throw DomainException.badRequest(ErrorCode.TAG_MERGE_INVALID_TARGET,
"Target " + targetId + " is a descendant of source " + sourceId);
}
}
private void transferDocuments(UUID sourceId, UUID targetId) {
tagRepository.reassignDocumentTags(sourceId, targetId);
tagRepository.deleteDocumentTagsByTagId(sourceId);
}
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 note: concurrent admin writes could both pass this check and create a
// multi-node cycle. This is intentionally not locked because: (a) the endpoint
// requires ADMIN_TAG permission so concurrency is rare, (b) the DB-level
// CHECK (parent_id != id) prevents infinite self-loops as a hard backstop,
// and (c) the window is microseconds. Do NOT add a pessimistic lock here.
List<UUID> 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<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
for (Tag tag : tags) {
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
nodeById.put(tag.getId(), new TagTreeNodeDTO(
tag.getId(), tag.getName(), tag.getColor(), documentCount,
new ArrayList<>(), tag.getParentId()
));
}
for (TagTreeNodeDTO node : nodeById.values()) {
if (node.parentId() != null) {
TagTreeNodeDTO parent = nodeById.get(node.parentId());
if (parent != null) parent.children().add(node);
}
}
return nodeById.values().stream().filter(n -> n.parentId() == null).toList();
}
}

View File

@@ -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);

View File

@@ -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"))

View File

@@ -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,15 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import org.raddatz.familienarchiv.dto.MergeTagDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doThrow;
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 +88,107 @@ 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(), parentId);
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null);
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"));
}
// ─── POST /api/tags/{id}/merge ────────────────────────────────────────────
@Test
void mergeTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void mergeTag_returns404_whenSourceTagNotFound() throws Exception {
when(tagService.mergeTags(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void mergeTag_returns200_withTargetTag_onSuccess() throws Exception {
UUID targetId = UUID.randomUUID();
Tag target = Tag.builder().id(targetId).name("Target").build();
when(tagService.mergeTags(any(), any())).thenReturn(target);
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + targetId + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(targetId.toString()))
.andExpect(jsonPath("$.name").value("Target"));
}
// ─── DELETE /api/tags/{id}/subtree ────────────────────────────────────────
@Test
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isNoContent());
}
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
@Test

View File

@@ -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<UUID> setA = tagRepository.findDescendantIdsByName("TagA").stream().toList();
List<UUID> setB = tagRepository.findDescendantIdsByName("TagB").stream().toList();
List<UUID> setC = tagRepository.findDescendantIdsByName("TagC").stream().toList();
var spec = DocumentSpecifications.hasTags(
List.of(new HashSet<>(setA), new HashSet<>(setB)), false);
List<Document> 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<UUID> setA = tagRepository.findDescendantIdsByName("OrTagA").stream().toList();
List<UUID> setB = tagRepository.findDescendantIdsByName("OrTagB").stream().toList();
var spec = DocumentSpecifications.hasTags(
List.of(new HashSet<>(setA), new HashSet<>(setB)), true);
List<Document> 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<UUID> 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<Document> 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<UUID> 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<UUID> ids = tagRepository.findDescendantIdsByName("Grandparent")
.stream().toList();
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
}
// ─── seeding helpers ─────────────────────────────────────────────────────
private Document uploaded(String title) {

View File

@@ -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<Document> result = documentRepository.findAll(Specification.where(hasTags(null)));
void hasTags_returnsAllDocuments_whenTagSetListIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(null, false)));
assertThat(result).hasSize(3);
}
@Test
void hasTags_returnsAllDocuments_whenTagListIsEmpty() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of())));
void hasTags_returnsAllDocuments_whenTagSetListIsEmpty() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(), false)));
assertThat(result).hasSize(3);
}
@Test
void hasTags_filtersDocumentsByTag() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie"))));
void hasTags_and_filtersDocumentsByTag() {
Set<UUID> familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie"));
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(familieIds), false)));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_isCaseInsensitive() {
List<Document> 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<UUID> familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie"));
Set<UUID> urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub"));
List<Document> 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<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
void hasTags_or_findsDocumentWithEitherTag() {
Set<UUID> familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie"));
Set<UUID> urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub"));
List<Document> 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<Document> result = documentRepository.findAll(
Specification.where(hasTags(List.of(new HashSet<>()), false)));
assertThat(result).isEmpty();
}
@Test
void hasTags_returnsEmpty_whenTagDoesNotExist() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
// Non-existent tag → findDescendantIdsByName returns empty list → hasTags returns no results
Set<UUID> unknownIds = new HashSet<>(tagRepository.findDescendantIdsByName("Unbekannt"));
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(unknownIds), false)));
assertThat(result).isEmpty();
}

View File

@@ -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")

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -5,15 +5,20 @@ 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.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@@ -31,9 +36,9 @@ class TagServiceTest {
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.getById(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
@Test
@@ -89,20 +94,383 @@ 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"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null)))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
// ─── 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());
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
assertThat(tagService.getTagTree()).isEmpty();
}
@Test
void getTagTree_returnsFlatRootTags_whenNoParentRelationships() {
UUID idA = UUID.randomUUID();
UUID idB = UUID.randomUUID();
List<Tag> tags = List.of(
Tag.builder().id(idA).name("Alpha").build(),
Tag.builder().id(idB).name("Beta").build()
);
when(tagRepository.findAll()).thenReturn(tags);
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
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));
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
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);
}
// ─── getTagTree (enriched) ────────────────────────────────────────────────
@Test
void getTagTree_populatesParentId_onChildNode() {
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));
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
var tree = tagService.getTagTree();
assertThat(tree.get(0).children().get(0).parentId()).isEqualTo(parentId);
}
@Test
void getTagTree_populatesDocumentCount_fromAggregateQuery() {
UUID tagId = UUID.randomUUID();
Tag tag = Tag.builder().id(tagId).name("Tag").build();
TagRepository.TagCount countEntry = mock(TagRepository.TagCount.class);
when(countEntry.getTagId()).thenReturn(tagId);
when(countEntry.getCount()).thenReturn(5L);
when(tagRepository.findAll()).thenReturn(List.of(tag));
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
var tree = tagService.getTagTree();
assertThat(tree.get(0).documentCount()).isEqualTo(5);
}
@Test
void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() {
when(tagRepository.findAll()).thenReturn(List.of());
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
tagService.getTagTree();
verify(tagRepository, times(1)).findDocumentCountsPerTag();
}
// ─── resolveEffectiveColors ───────────────────────────────────────────────
@Test
void resolveEffectiveColors_doesNothing_whenCollectionIsEmpty() {
tagService.resolveEffectiveColors(List.of());
verifyNoInteractions(tagRepository);
}
@Test
void resolveEffectiveColors_doesNothing_whenAllTagsHaveOwnColor() {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").color("sage").build();
tagService.resolveEffectiveColors(List.of(tag));
verify(tagRepository, never()).findAllById(any());
assertThat(tag.getColor()).isEqualTo("sage");
}
@Test
void resolveEffectiveColors_doesNothing_whenTagHasNoParentAndNoColor() {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").build();
tagService.resolveEffectiveColors(List.of(tag));
verify(tagRepository, never()).findAllById(any());
assertThat(tag.getColor()).isNull();
}
@Test
void resolveEffectiveColors_setsParentColor_onChildTagWithNoColor() {
UUID parentId = UUID.randomUUID();
Tag parent = Tag.builder().id(parentId).name("Parent").color("sage").build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build();
when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent));
tagService.resolveEffectiveColors(List.of(child));
assertThat(child.getColor()).isEqualTo("sage");
}
@Test
void resolveEffectiveColors_leavesChildUnchanged_whenParentHasNoColor() {
UUID parentId = UUID.randomUUID();
Tag parent = Tag.builder().id(parentId).name("Parent").build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build();
when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent));
tagService.resolveEffectiveColors(List.of(child));
assertThat(child.getColor()).isNull();
}
@Test
void resolveEffectiveColors_batchLoadsParents_inOneQuery() {
UUID parentId1 = UUID.randomUUID();
UUID parentId2 = UUID.randomUUID();
Tag parent1 = Tag.builder().id(parentId1).name("P1").color("sage").build();
Tag parent2 = Tag.builder().id(parentId2).name("P2").color("sienna").build();
Tag child1 = Tag.builder().id(UUID.randomUUID()).name("C1").parentId(parentId1).build();
Tag child2 = Tag.builder().id(UUID.randomUUID()).name("C2").parentId(parentId2).build();
when(tagRepository.findAllById(Set.of(parentId1, parentId2))).thenReturn(List.of(parent1, parent2));
tagService.resolveEffectiveColors(List.of(child1, child2));
verify(tagRepository, times(1)).findAllById(any());
assertThat(child1.getColor()).isEqualTo("sage");
assertThat(child2.getColor()).isEqualTo("sienna");
}
// ─── mergeTags ────────────────────────────────────────────────────────────
@Test
void mergeTags_throwsBadRequest_whenSelfMerge() {
UUID id = UUID.randomUUID();
assertThatThrownBy(() -> tagService.mergeTags(id, id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_MERGE_SELF);
}
@Test
void mergeTags_throwsNotFound_whenSourceMissing() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
when(tagRepository.findById(sourceId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
@Test
void mergeTags_throwsNotFound_whenTargetMissing() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
Tag source = Tag.builder().id(sourceId).name("Source").build();
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
when(tagRepository.findById(targetId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
@Test
void mergeTags_throwsBadRequest_whenTargetIsDescendantOfSource() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
Tag source = Tag.builder().id(sourceId).name("Parent").build();
Tag target = Tag.builder().id(targetId).name("Child").parentId(sourceId).build();
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId, targetId));
assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_MERGE_INVALID_TARGET);
}
@Test
void mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
Tag source = Tag.builder().id(sourceId).name("Source").build();
Tag target = Tag.builder().id(targetId).name("Target").build();
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source));
when(tagRepository.findById(targetId)).thenReturn(Optional.of(target));
when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId));
Tag result = tagService.mergeTags(sourceId, targetId);
verify(tagRepository).reassignDocumentTags(sourceId, targetId);
verify(tagRepository).deleteDocumentTagsByTagId(sourceId);
verify(tagRepository).reparentChildren(sourceId, targetId);
verify(tagRepository).deleteById(sourceId);
assertThat(result).isEqualTo(target);
}
// ─── deleteWithDescendants ────────────────────────────────────────────────
@Test
void deleteWithDescendants_throwsNotFound_whenTagMissing() {
UUID id = UUID.randomUUID();
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.deleteWithDescendants(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
@Test
void deleteWithDescendants_deletesSubtreeDocTagsAndAllTags() {
UUID id = UUID.randomUUID();
UUID childId = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Root").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id, childId));
tagService.deleteWithDescendants(id);
verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id, childId));
verify(tagRepository).deleteAllById(List.of(id, childId));
}
@Test
void deleteWithDescendants_whenLeafTag_deletesTagAndItsOwnDocTags() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Leaf").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id));
tagService.deleteWithDescendants(id);
verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id));
verify(tagRepository).deleteAllById(List.of(id));
}
@Test
void deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Tag").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.findDescendantIds(id)).thenReturn(List.of());
tagService.deleteWithDescendants(id);
verify(tagRepository, never()).deleteDocumentTagsByTagIds(any());
verify(tagRepository).deleteAllById(List.of());
}
// ─── delete ───────────────────────────────────────────────────────────────
@@ -124,8 +492,8 @@ class TagServiceTest {
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.delete(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
}
}

View File

@@ -180,7 +180,7 @@
"admin_tags_empty": "Keine Schlagworte vorhanden.",
"admin_tags_select_prompt": "Wähle ein Schlagwort aus der Liste.",
"admin_tag_edit_heading": "Schlagwort: {name}",
"admin_tag_updated": "Schlagwort umbenannt.",
"admin_tag_updated": "Schlagwort gespeichert.",
"admin_unsaved_warning": "Du hast ungespeicherte Änderungen speichere oder verwerfe, bevor du wechselst.",
"admin_btn_collapse_list": "Liste einklappen",
"admin_btn_expand_list": "Liste ausklappen",
@@ -501,6 +501,8 @@
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
"error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.",
"error_training_already_running": "Es läuft bereits ein Trainings-Vorgang.",
"error_invalid_tag_color": "Die gewählte Farbe ist ungültig.",
"error_tag_cycle_detected": "Dieses übergeordnete Schlagwort würde einen Kreis erzeugen.",
"ocr_script_type_typewriter": "Schreibmaschine",
"ocr_script_type_handwriting_latin": "Handschrift (lateinisch)",
"ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)",
@@ -574,5 +576,40 @@
"mission_control_ready_empty_cta": "Jetzt mitmachen",
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
"mission_control_blocks_progress": "{texted} / {total} Blöcke",
"mission_control_reviewed_pct": "{pct}% geprüft"
"mission_control_reviewed_pct": "{pct}% geprüft",
"error_tag_not_found": "Dieses Schlagwort wurde nicht gefunden.",
"error_tag_merge_self": "Ein Schlagwort kann nicht mit sich selbst zusammengeführt werden.",
"error_tag_merge_invalid_target": "Das Ziel-Schlagwort ist ein Untergeordnetes des Quell-Schlagworts.",
"admin_tag_tree_label": "Schlagwörter",
"admin_tag_collapse_node": "Einklappen",
"admin_tag_expand_node": "Ausklappen",
"admin_tag_parent_placeholder": "Übergeordnetes Schlagwort suchen …",
"admin_tag_inherited_color": "Farbe wird von {parent} vererbt",
"admin_tag_ancestry_label": "Pfad",
"admin_tag_children_label": "Untergeordnete Schlagwörter",
"admin_tag_children_more": "… und {count} weitere",
"admin_tag_merge_heading": "Zusammenführen",
"admin_tag_merge_description": "Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen.",
"admin_tag_merge_btn": "Mit anderem Schlagwort zusammenführen …",
"admin_tag_merge_target_label": "Ziel-Schlagwort",
"admin_tag_merge_preview_docs": "{count} Dokumente",
"admin_tag_merge_preview_children": "{count} Untergeordnete",
"admin_tag_merge_deleted_after": "wird danach gelöscht",
"admin_tag_merge_confirm_btn": "Jetzt zusammenführen",
"admin_tag_merge_step1": "Schritt 1 von 2",
"admin_tag_merge_step2": "Schritt 2 von 2",
"admin_tag_merge_target_placeholder": "Ziel-Schlagwort suchen …",
"admin_tag_merge_success": "Erfolgreich zusammengeführt.",
"admin_tag_delete_impact": "{docs} Dokument(e) · {descendants} Untergeordnete",
"admin_tag_delete_only_this": "Nur dieses Schlagwort löschen",
"admin_tag_delete_only_this_sub": "Untergeordnete werden zu {parent} verschoben",
"admin_tag_delete_only_this_sub_root": "Untergeordnete werden zu Root-Schlagwörtern",
"admin_tag_delete_subtree": "Gesamten Teilbaum löschen",
"admin_tag_delete_subtree_warn": "Löscht auch {count} untergeordnete Schlagwörter",
"admin_tag_delete_subtree_confirm_btn": "Teilbaum löschen",
"admin_tag_delete_confirm_heading": "Gib «{name}» zur Bestätigung ein:",
"filter_operator_and": "UND",
"filter_operator_or": "ODER",
"filter_operator_and_label": "Alle gewählten Schlagworte müssen zutreffen (UND)",
"filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)"
}

View File

@@ -180,7 +180,7 @@
"admin_tags_empty": "No tags found.",
"admin_tags_select_prompt": "Select a tag from the list.",
"admin_tag_edit_heading": "Tag: {name}",
"admin_tag_updated": "Tag renamed.",
"admin_tag_updated": "Tag saved.",
"admin_unsaved_warning": "You have unsaved changes — save or discard before switching.",
"admin_btn_collapse_list": "Collapse list",
"admin_btn_expand_list": "Expand list",
@@ -501,6 +501,8 @@
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
"error_ocr_processing_failed": "OCR processing failed.",
"error_training_already_running": "A training run is already in progress.",
"error_invalid_tag_color": "The chosen color is not valid.",
"error_tag_cycle_detected": "This parent tag would create a cycle.",
"ocr_script_type_typewriter": "Typewriter",
"ocr_script_type_handwriting_latin": "Handwriting (Latin)",
"ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)",
@@ -574,5 +576,40 @@
"mission_control_ready_empty_cta": "Start contributing",
"mission_control_weekly_pulse": "↑ +{count} this week",
"mission_control_blocks_progress": "{texted} / {total} blocks",
"mission_control_reviewed_pct": "{pct}% reviewed"
"mission_control_reviewed_pct": "{pct}% reviewed",
"error_tag_not_found": "This tag was not found.",
"error_tag_merge_self": "A tag cannot be merged with itself.",
"error_tag_merge_invalid_target": "The target tag is a descendant of the source tag.",
"admin_tag_tree_label": "Tags",
"admin_tag_collapse_node": "Collapse",
"admin_tag_expand_node": "Expand",
"admin_tag_parent_placeholder": "Search parent tag …",
"admin_tag_inherited_color": "Color inherited from {parent}",
"admin_tag_ancestry_label": "Path",
"admin_tag_children_label": "Child Tags",
"admin_tag_children_more": "… and {count} more",
"admin_tag_merge_heading": "Merge",
"admin_tag_merge_description": "Transfer all documents and child tags to another tag, then delete this one.",
"admin_tag_merge_btn": "Merge with another tag …",
"admin_tag_merge_target_label": "Target Tag",
"admin_tag_merge_preview_docs": "{count} documents",
"admin_tag_merge_preview_children": "{count} children",
"admin_tag_merge_deleted_after": "will be deleted after",
"admin_tag_merge_confirm_btn": "Merge now",
"admin_tag_merge_step1": "Step 1 of 2",
"admin_tag_merge_step2": "Step 2 of 2",
"admin_tag_merge_target_placeholder": "Search target tag …",
"admin_tag_merge_success": "Merged successfully.",
"admin_tag_delete_impact": "{docs} document(s) · {descendants} child tags",
"admin_tag_delete_only_this": "Delete only this tag",
"admin_tag_delete_only_this_sub": "Children will be moved to {parent}",
"admin_tag_delete_only_this_sub_root": "Children will become root tags",
"admin_tag_delete_subtree": "Delete entire subtree",
"admin_tag_delete_subtree_warn": "Also deletes {count} child tags",
"admin_tag_delete_subtree_confirm_btn": "Delete subtree",
"admin_tag_delete_confirm_heading": "Type «{name}» to confirm:",
"filter_operator_and": "AND",
"filter_operator_or": "OR",
"filter_operator_and_label": "All selected tags must match (AND)",
"filter_operator_or_label": "At least one tag must match (OR)"
}

View File

@@ -180,7 +180,7 @@
"admin_tags_empty": "No hay etiquetas.",
"admin_tags_select_prompt": "Selecciona una etiqueta de la lista.",
"admin_tag_edit_heading": "Etiqueta: {name}",
"admin_tag_updated": "Etiqueta renombrada.",
"admin_tag_updated": "Etiqueta guardada.",
"admin_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.",
"admin_btn_collapse_list": "Contraer lista",
"admin_btn_expand_list": "Expandir lista",
@@ -501,6 +501,8 @@
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
"error_ocr_processing_failed": "El procesamiento OCR ha fallado.",
"error_training_already_running": "Ya hay un proceso de entrenamiento en curso.",
"error_invalid_tag_color": "El color elegido no es válido.",
"error_tag_cycle_detected": "Esta etiqueta padre crearía un ciclo.",
"ocr_script_type_typewriter": "Máquina de escribir",
"ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)",
"ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)",
@@ -574,5 +576,40 @@
"mission_control_ready_empty_cta": "Empezar a colaborar",
"mission_control_weekly_pulse": "↑ +{count} esta semana",
"mission_control_blocks_progress": "{texted} / {total} bloques",
"mission_control_reviewed_pct": "{pct}% revisado"
"mission_control_reviewed_pct": "{pct}% revisado",
"error_tag_not_found": "Esta etiqueta no fue encontrada.",
"error_tag_merge_self": "Una etiqueta no puede fusionarse consigo misma.",
"error_tag_merge_invalid_target": "La etiqueta de destino es descendiente de la etiqueta de origen.",
"admin_tag_tree_label": "Etiquetas",
"admin_tag_collapse_node": "Colapsar",
"admin_tag_expand_node": "Expandir",
"admin_tag_parent_placeholder": "Buscar etiqueta superior …",
"admin_tag_inherited_color": "Color heredado de {parent}",
"admin_tag_ancestry_label": "Ruta",
"admin_tag_children_label": "Etiquetas subordinadas",
"admin_tag_children_more": "… y {count} más",
"admin_tag_merge_heading": "Fusionar",
"admin_tag_merge_description": "Transferir todos los documentos y etiquetas subordinadas a otra etiqueta y luego eliminar esta.",
"admin_tag_merge_btn": "Fusionar con otra etiqueta …",
"admin_tag_merge_target_label": "Etiqueta de destino",
"admin_tag_merge_preview_docs": "{count} documentos",
"admin_tag_merge_preview_children": "{count} subordinados",
"admin_tag_merge_deleted_after": "se eliminará después",
"admin_tag_merge_confirm_btn": "Fusionar ahora",
"admin_tag_merge_step1": "Paso 1 de 2",
"admin_tag_merge_step2": "Paso 2 de 2",
"admin_tag_merge_target_placeholder": "Buscar etiqueta de destino …",
"admin_tag_merge_success": "Fusionado con éxito.",
"admin_tag_delete_impact": "{docs} documento(s) · {descendants} subordinados",
"admin_tag_delete_only_this": "Eliminar solo esta etiqueta",
"admin_tag_delete_only_this_sub": "Las subordinadas se moverán a {parent}",
"admin_tag_delete_only_this_sub_root": "Las subordinadas se convertirán en etiquetas raíz",
"admin_tag_delete_subtree": "Eliminar todo el subárbol",
"admin_tag_delete_subtree_warn": "También elimina {count} etiquetas subordinadas",
"admin_tag_delete_subtree_confirm_btn": "Eliminar subárbol",
"admin_tag_delete_confirm_heading": "Escribe «{name}» para confirmar:",
"filter_operator_and": "Y",
"filter_operator_or": "O",
"filter_operator_and_label": "Todas las etiquetas seleccionadas deben coincidir (Y)",
"filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)"
}

View File

@@ -3,6 +3,7 @@ import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
type Person = components['schemas']['Person'];
interface Props {
@@ -50,10 +51,23 @@ $effect(() => {
}
});
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
const typeahead = createTypeahead<Person>({
fetchUrl: async (term) => {
const personId = restrictToCorrespondentsOf;
if (personId) {
const url =
term.length >= 1
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
: `/api/persons/${personId}/correspondents`;
const res = await fetch(url);
return res.ok ? await res.json() : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? await res.json() : [];
},
debounceMs: 300
});
function handleInput() {
if (value && searchTerm !== initialName) {
@@ -61,69 +75,38 @@ function handleInput() {
onchange?.('');
}
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const term = untrack(() => searchTerm);
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
loading = true;
try {
let url: string;
if (correspondentsOf) {
if (term.length >= 1) {
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
} else {
url = `/api/persons/${correspondentsOf}/correspondents`;
}
} else {
if (term.length < 1) {
results = [];
loading = false;
return;
}
url = `/api/persons?q=${encodeURIComponent(term)}`;
}
const res = await fetch(url);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
}, 300);
const term = untrack(() => searchTerm);
typeahead.setQuery(term);
}
function handleFocus() {
onfocused?.();
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
(async () => {
try {
const res = await fetch(`/api/persons/${personId}/correspondents`);
results = res.ok ? await res.json() : [];
const persons: Person[] = res.ok ? await res.json() : [];
typeahead.openWith(persons);
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
typeahead.openWith([]);
}
})();
} else {
typeahead.openWith(typeahead.results);
}
}
function selectPerson(person: Person) {
value = person.id!;
searchTerm = person.displayName;
showDropdown = false;
typeahead.close();
onchange?.(person.id!);
}
</script>
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<label
for={name}
class={compact
@@ -149,14 +132,14 @@ function selectPerson(person: Person) {
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/>
{#if showDropdown && (results.length > 0 || loading)}
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if loading}
{#if typeahead.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else}
{#each results as person (person.id)}
{#each typeahead.results as person (person.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectPerson(person)}

View File

@@ -1,10 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
interface Props {
tags?: string[];
tags?: Tag[];
allowCreation?: boolean;
onTextInput?: (text: string) => void;
}
@@ -12,10 +15,41 @@ interface Props {
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
let inputVal = $state('');
let suggestions: string[] = $state([]);
let suggestions: Tag[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
const suggestionsById = $derived(
new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]))
);
const orderedSuggestions = $derived.by(() => {
const roots: Tag[] = [];
const childrenMap = new SvelteMap<string, Tag[]>();
const orphans: Tag[] = [];
for (const s of suggestions) {
if (!s.parentId) {
roots.push(s);
} else if (suggestionsById.has(s.parentId)) {
const children = childrenMap.get(s.parentId) ?? [];
children.push(s);
childrenMap.set(s.parentId, children);
} else {
orphans.push(s);
}
}
const result: Tag[] = [];
for (const root of roots) {
result.push(root);
const children = childrenMap.get(root.id!) ?? [];
result.push(...children);
}
result.push(...orphans);
return result;
});
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
@@ -24,10 +58,10 @@ async function fetchSuggestions(query: string) {
try {
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
const names: string[] = data.map((t: { name: string }) => t.name);
const data: Tag[] = await res.json();
const currentTags = untrack(() => tags);
suggestions = names.filter((t) => !currentTags.includes(t));
const currentNames = new Set(currentTags.map((t) => t.name));
suggestions = data.filter((t) => !currentNames.has(t.name));
showSuggestions = true;
}
} catch (e) {
@@ -35,11 +69,19 @@ async function fetchSuggestions(query: string) {
}
}
function addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
function addTag(tag: Tag | string) {
const newTag: Tag = typeof tag === 'string' ? { name: tag.trim() } : tag;
if (!newTag.name) return;
const currentTags = untrack(() => tags);
if (currentTags.some((t) => t.name === newTag.name)) {
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
onTextInput?.('');
return;
}
tags = [...tags, newTag];
inputVal = '';
suggestions = [];
showSuggestions = false;
@@ -54,8 +96,8 @@ function removeTag(index: number) {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
addTag(orderedSuggestions[activeIndex]);
} else if (allowCreation) {
addTag(inputVal);
}
@@ -63,10 +105,10 @@ function handleKeydown(e: KeyboardEvent) {
removeTag(tags.length - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
activeIndex = (activeIndex + 1) % orderedSuggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
activeIndex = (activeIndex - 1 + orderedSuggestions.length) % orderedSuggestions.length;
}
}
</script>
@@ -77,9 +119,17 @@ function handleKeydown(e: KeyboardEvent) {
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
>
<!-- Render Selected Tags -->
{#each tags as tag, i (i)}
{#each tags as tag, i (tag.id ?? tag.name)}
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
{tag}
{#if tag.color}
<span
data-testid="tag-color-dot"
data-color={tag.color}
style="background-color: var(--c-tag-{tag.color})"
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
{tag.name}
<button
type="button"
onclick={() => removeTag(i)}
@@ -115,22 +165,22 @@ function handleKeydown(e: KeyboardEvent) {
/>
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
{#if showSuggestions && orderedSuggestions.length > 0}
<ul
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
>
{#each suggestions as suggestion, i (i)}
{#each orderedSuggestions as suggestion, i (suggestion.id ?? suggestion.name)}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
? 'bg-muted font-bold text-ink'
: 'text-ink-2'}"
: 'text-ink-2'} {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}"
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
{suggestion.name}
</li>
{/each}
</ul>

View File

@@ -43,14 +43,14 @@ describe('TagInput rendering', () => {
});
it('renders existing tags as chips', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
});
it('hides input placeholder once tags exist', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', '');
});
@@ -64,6 +64,18 @@ describe('TagInput rendering', () => {
render(TagInput, { tags: [], allowCreation: false });
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
});
it('renders a color dot on chips that have a color', async () => {
render(TagInput, { tags: [{ name: 'Familie', color: 'sage' }], allowCreation: true });
const dot = page.getByTestId('tag-color-dot');
await expect.element(dot).toBeInTheDocument();
await expect.element(dot).toHaveAttribute('data-color', 'sage');
});
it('does not render a color dot on chips without a color', async () => {
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument();
});
});
// ─── Adding tags ──────────────────────────────────────────────────────────────
@@ -90,7 +102,7 @@ describe('TagInput adding tags', () => {
it('does not add a duplicate tag', async () => {
mockFetchEmpty();
render(TagInput, { tags: ['Familie'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Familie');
await userEvent.keyboard('{Enter}');
@@ -112,7 +124,7 @@ describe('TagInput adding tags', () => {
describe('TagInput removing tags', () => {
it('removes a chip when its × button is clicked', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
// The × buttons have aria-label="Schlagwort entfernen"
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
await tick();
@@ -122,7 +134,7 @@ describe('TagInput removing tags', () => {
});
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
@@ -130,7 +142,7 @@ describe('TagInput removing tags', () => {
});
it('does not remove a tag on Backspace when the input has text', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('x');
await userEvent.keyboard('{Backspace}');
@@ -163,7 +175,7 @@ describe('TagInput autocomplete', () => {
it('filters already-selected tags out of suggestions', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: ['Familie'], allowCreation: true });
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fr');
await waitForDebounce();
@@ -207,6 +219,30 @@ describe('TagInput autocomplete', () => {
await tick();
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
});
it('shows child suggestion after its parent when both are in results', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'p1', name: 'Eltern' },
{ id: 'c1', name: 'Kind', parentId: 'p1' }
])
})
);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('El');
await waitForDebounce();
const options = document.querySelectorAll('[role="option"]');
const names = Array.from(options).map((el) => el.textContent?.trim());
const elternIdx = names.indexOf('Eltern');
const kindIdx = names.indexOf('Kind');
expect(elternIdx).toBeGreaterThanOrEqual(0);
expect(kindIdx).toBeGreaterThanOrEqual(0);
expect(elternIdx).toBeLessThan(kindIdx);
});
});
// ─── onTextInput callback ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
type Tag = components['schemas']['Tag'];
interface FlatTagRef {
id: string;
name: string;
parentId?: string;
}
interface Props {
name: string;
value?: string;
excludeIds?: string[];
initialName?: string;
allTags?: FlatTagRef[];
placeholder?: string;
}
let {
name,
value = $bindable(''),
excludeIds = [],
initialName = '',
allTags = [],
placeholder = m.admin_tag_parent_placeholder()
}: Props = $props();
// displayName must be both prop-derived AND locally writable (user typing), so $state +
// $effect is the correct pattern here — writable $derived is read-only and won't work.
// eslint-disable-next-line svelte/prefer-writable-derived
let displayName = $state(initialName);
$effect(() => {
displayName = initialName;
});
// Uses fetch directly (not the typed api client) because this component runs in the browser
// where the typed api client is not available, and the tags endpoint needs no auth cookie.
const typeahead = createTypeahead<Tag>({
fetchUrl: async (q) => {
const res = await fetch(`/api/tags?query=${encodeURIComponent(q)}`);
return res.ok ? await res.json() : [];
},
debounceMs: 300
});
const filteredResults = $derived(typeahead.results.filter((t) => !excludeIds.includes(t.id)));
function handleInput() {
const term = untrack(() => displayName);
typeahead.setQuery(term);
// Reset active index whenever results are re-fetched
typeahead.setActiveIndex(-1);
}
function selectTag(tag: Tag) {
value = tag.id;
displayName = tag.name;
typeahead.close();
}
function clearSelection() {
value = '';
displayName = '';
typeahead.close();
}
function handleKeydown(e: KeyboardEvent) {
if (!typeahead.isOpen) return;
const len = filteredResults.length;
if (len === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
typeahead.setActiveIndex((typeahead.activeIndex + 1) % len);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
typeahead.setActiveIndex((typeahead.activeIndex - 1 + len) % len);
} else if (e.key === 'Enter' && typeahead.activeIndex >= 0) {
e.preventDefault();
selectTag(filteredResults[typeahead.activeIndex]);
} else if (e.key === 'Escape') {
typeahead.close();
}
}
</script>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<input type="hidden" name={name} bind:value={value} />
<div class="relative">
<input
type="text"
id="{name}-search"
autocomplete="off"
role="combobox"
aria-expanded={typeahead.isOpen}
aria-controls="{name}-listbox"
aria-autocomplete="list"
aria-activedescendant={typeahead.activeIndex >= 0
? `${name}-option-${typeahead.activeIndex}`
: undefined}
bind:value={displayName}
oninput={handleInput}
onkeydown={handleKeydown}
placeholder={placeholder}
class="mt-1 block w-full rounded-md border border-line bg-surface p-2 pr-8 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if value}
<button
type="button"
onclick={clearSelection}
aria-label="Auswahl entfernen"
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm text-ink-3 hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if typeahead.isOpen && filteredResults.length > 0}
<div
id="{name}-listbox"
role="listbox"
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#each filteredResults as tag, i (tag.id)}
<div
id="{name}-option-{i}"
role="option"
tabindex="-1"
aria-selected={i === typeahead.activeIndex}
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === typeahead.activeIndex ? 'bg-accent-bg' : ''}"
onclick={() => selectTag(tag)}
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
>
<span class="block truncate font-medium">{tag.name}</span>
{#if tag.parentId}
{@const parentName = allTags.find((t) => t.id === tag.parentId)?.name ?? tag.parentId}
<span class="block truncate text-xs text-ink-3">{parentName}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,202 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagParentPicker from './TagParentPicker.svelte';
function hiddenInput(name: string) {
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
}
function mockFetchWithTags(tags: { id: string; name: string; parentId?: string }[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(tags)
})
);
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('TagParentPicker rendering', () => {
it('renders the text input', async () => {
render(TagParentPicker, { name: 'parentId' });
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('renders hidden input with correct name', async () => {
render(TagParentPicker, { name: 'parentId' });
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')).toBeTruthy();
});
it('uses custom placeholder text when provided', async () => {
render(TagParentPicker, { name: 'target', placeholder: 'Ziel-Schlagwort suchen …' });
const input = await page.getByRole('combobox').element();
expect(input.getAttribute('placeholder')).toBe('Ziel-Schlagwort suchen …');
});
});
// ─── Search ───────────────────────────────────────────────────────────────────
describe('TagParentPicker search', () => {
it('typing shows dropdown results', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByText('Haus')).toBeInTheDocument();
});
it('filters excludeIds from results', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Garten' }
]);
render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByText('Haus')).not.toBeInTheDocument();
await expect.element(page.getByText('Garten')).toBeInTheDocument();
});
});
// ─── Selection ────────────────────────────────────────────────────────────────
describe('TagParentPicker selection', () => {
it('selecting an option sets the hidden input value', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await page.getByRole('option', { name: 'Haus' }).click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('t1');
});
it('clear button appears when value is set', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await page.getByRole('option', { name: 'Haus' }).click();
await vi.advanceTimersByTimeAsync(0);
await expect
.element(page.getByRole('button', { name: 'Auswahl entfernen' }))
.toBeInTheDocument();
});
it('clear button resets value', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await page.getByRole('option', { name: 'Haus' }).click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('t1');
await page.getByRole('button', { name: 'Auswahl entfernen' }).click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('');
});
});
// ─── ARIA combobox ────────────────────────────────────────────────────────────
describe('TagParentPicker ARIA combobox', () => {
it('ArrowDown moves aria-activedescendant to first option', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Garten' }
]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
// Dropdown is open — arrow down should highlight first option
const el = await input.element();
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
);
await vi.advanceTimersByTimeAsync(0);
expect(el.getAttribute('aria-activedescendant')).toBe('parentId-option-0');
});
it('listbox items have role="option" with ids', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument();
const option = await page.getByRole('option', { name: 'Haus' }).element();
expect(option.id).toBe('parentId-option-0');
});
});
// ─── Parent name resolution ───────────────────────────────────────────────────
describe('TagParentPicker parent name subtitle', () => {
it('shows parent name instead of UUID when allTags is provided', async () => {
mockFetchWithTags([{ id: 't2', name: 'Keller', parentId: 't1' }]);
const allTags = [
{ id: 't1', name: 'Haus', documentCount: 5 },
{ id: 't2', name: 'Keller', parentId: 't1', documentCount: 2 }
];
render(TagParentPicker, { name: 'parentId', allTags });
const input = page.getByRole('combobox');
await input.fill('K');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByText('Haus')).toBeInTheDocument();
});
it('shows nothing as subtitle when tag has no parentId', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
const allTags = [{ id: 't1', name: 'Haus', documentCount: 5 }];
render(TagParentPicker, { name: 'parentId', allTags });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
// Only the tag name should appear (no subtitle)
await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte';
import TagInput from '$lib/components/TagInput.svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable<string[]>([]),
tags = $bindable<Tag[]>([]),
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
@@ -12,7 +12,7 @@ let {
suggestedTitle = '',
hideTitle = false
}: {
tags?: string[];
tags?: Tag[];
initialTitle?: string;
initialDocumentLocation?: string;
initialSummary?: string;
@@ -74,7 +74,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.join(',')} />
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
</div>
<!-- Inhalt -->

View File

@@ -27,6 +27,11 @@ export type ErrorCode =
| 'OCR_DOCUMENT_NOT_UPLOADED'
| 'OCR_PROCESSING_FAILED'
| 'TRAINING_ALREADY_RUNNING'
| 'INVALID_TAG_COLOR'
| 'TAG_CYCLE_DETECTED'
| 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
@@ -100,6 +105,16 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_ocr_processing_failed();
case 'TRAINING_ALREADY_RUNNING':
return m.error_training_already_running();
case 'INVALID_TAG_COLOR':
return m.error_invalid_tag_color();
case 'TAG_CYCLE_DETECTED':
return m.error_tag_cycle_detected();
case 'TAG_NOT_FOUND':
return m.error_tag_not_found();
case 'TAG_MERGE_SELF':
return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target();
case 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':

View File

@@ -180,6 +180,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tags/{id}/merge": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["mergeTag"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons": {
parameters: {
query?: never;
@@ -740,6 +756,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;
@@ -1124,6 +1156,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tags/{id}/subtree": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteSubtree"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/aliases/{aliasId}": {
parameters: {
query?: never;
@@ -1190,10 +1238,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;
@@ -1315,6 +1372,10 @@ export interface components {
currentPassword?: string;
newPassword?: string;
};
MergeTagDTO: {
/** Format: uuid */
targetId?: string;
};
PersonNameAliasDTO: {
lastName: string;
firstName?: string;
@@ -1533,6 +1594,20 @@ export interface components {
/** Format: int32 */
reviewedBlockCount: number;
};
TagTreeNodeDTO: {
/** Format: uuid */
id?: string;
name?: string;
color?: string;
/** Format: int32 */
documentCount?: number;
children?: components["schemas"]["TagTreeNodeDTO"][];
/**
* Format: uuid
* @description Parent tag ID, null for root tags
*/
parentId?: string;
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
@@ -1544,17 +1619,17 @@ export interface components {
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
/** Format: int64 */
documentCount?: number;
personType?: string;
};
TrainingInfoResponse: {
/** Format: int32 */
@@ -1875,9 +1950,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["TagUpdateDTO"];
};
};
responses: {
@@ -2214,6 +2287,32 @@ export interface operations {
};
};
};
mergeTag: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MergeTagDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Tag"];
};
};
};
};
getPersons: {
parameters: {
query?: {
@@ -3279,6 +3378,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 +3787,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;
@@ -3821,6 +3942,26 @@ export interface operations {
};
};
};
deleteSubtree: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeAlias: {
parameters: {
query?: never;

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { createTypeahead } = await import('../useTypeahead.svelte');
describe('createTypeahead', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('starts with empty query and closed dropdown', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.query).toBe('');
expect(ta.isOpen).toBe(false);
expect(ta.results).toEqual([]);
expect(ta.loading).toBe(false);
});
it('setQuery updates query and opens dropdown', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
const ta = createTypeahead({ fetchUrl });
ta.setQuery('foo');
expect(ta.query).toBe('foo');
expect(ta.isOpen).toBe(true);
});
it('setQuery triggers debounced fetch and populates results', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
expect(fetchUrl).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).toHaveBeenCalledWith('foo');
expect(ta.results).toEqual([{ id: '1', name: 'Foo' }]);
});
it('close() resets isOpen', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
ta.setQuery('foo');
expect(ta.isOpen).toBe(true);
ta.close();
expect(ta.isOpen).toBe(false);
});
it('select(item) calls onSelect and closes dropdown', () => {
const onSelect = vi.fn();
const ta = createTypeahead({
fetchUrl: vi.fn().mockResolvedValue([]),
onSelect
});
ta.setQuery('foo');
ta.select({ id: '1', name: 'Foo' });
expect(onSelect).toHaveBeenCalledWith({ id: '1', name: 'Foo' });
expect(ta.isOpen).toBe(false);
});
it('debounce coalesces rapid setQuery calls', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('f');
ta.setQuery('fo');
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).toHaveBeenCalledTimes(1);
expect(fetchUrl).toHaveBeenCalledWith('foo');
});
it('fetch error logs to console.error and sets results to empty', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('network error'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(errorSpy).toHaveBeenCalled();
expect(ta.results).toEqual([]);
errorSpy.mockRestore();
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);
ta.setActiveIndex(2);
expect(ta.activeIndex).toBe(2);
});
});

View File

@@ -0,0 +1,77 @@
type Options<T> = {
fetchUrl: (query: string) => Promise<T[]>;
onSelect?: (item: T) => void;
debounceMs?: number;
};
export function createTypeahead<T>(options: Options<T>) {
const { fetchUrl, onSelect, debounceMs = 300 } = options;
let query = $state('');
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function setQuery(q: string) {
query = q;
isOpen = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
} finally {
loading = false;
}
}, debounceMs);
}
function close() {
isOpen = false;
activeIndex = -1;
}
function setActiveIndex(idx: number) {
activeIndex = idx;
}
function select(item: T) {
onSelect?.(item);
close();
}
/** Directly populate results without going through the debounce (e.g. on-focus preload). */
function openWith(items: T[]) {
results = items;
isOpen = true;
}
return {
get query() {
return query;
},
get results() {
return results;
},
get isOpen() {
return isOpen;
},
get loading() {
return loading;
},
get activeIndex() {
return activeIndex;
},
setQuery,
setActiveIndex,
close,
select,
openWith
};
}

View File

@@ -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;
@@ -47,7 +48,8 @@ export async function load({ url, fetch }) {
senderId: senderId || undefined,
receiverId: receiverId || undefined,
tag: tags.length ? tags : undefined,
tagQ: tagQ || undefined,
tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined,
sort,
dir: dir || undefined
}
@@ -62,7 +64,6 @@ export async function load({ url, fetch }) {
if (docsResult && docsResult.response.status === 401) {
throw redirect(302, '/login');
}
const searchResult = docsResult?.data as {
documents?: Document[];
total?: number;
@@ -147,7 +148,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 +167,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
};
}

View File

@@ -20,10 +20,15 @@ let from = $state(untrack(() => data.filters?.from || ''));
let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.filters?.tags || []).map((name: string) => ({ name })))
);
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 ||
@@ -43,10 +48,11 @@ function triggerSearch() {
if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag.name));
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 });
}
@@ -55,10 +61,15 @@ function handleTextSearch() {
searchTimer = setTimeout(() => triggerSearch(), 500);
}
function handleImmediateSearch() {
clearTimeout(searchTimer);
triggerSearch();
}
// Trigger search when tags change
let prevTagStr = untrack(() => tagNames.join(','));
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => {
const cur = tagNames.join(',');
const cur = tagNames.map((t) => t.name).join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
@@ -73,10 +84,11 @@ $effect(() => {
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
tagNames = data.filters?.tags || [];
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
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;
});
@@ -102,10 +114,12 @@ 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}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>

View File

@@ -29,7 +29,7 @@ let {
displayName: string;
} | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
tags?: { id: string; name: string; color?: string | null }[];
}[];
canWrite: boolean;
error?: string | null;
@@ -224,13 +224,22 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
class="relative z-10 inline-flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
title={tag.name}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{#if tag.color}
<span
data-testid="tag-color-dot"
data-color={tag.color}
style="background-color: var(--c-tag-{tag.color})"
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
{#if matchedTagIds.has(tag.id)}
<span data-testid="tag-match">{tag.name}</span>
{:else}

View File

@@ -342,6 +342,26 @@ describe('DocumentList match snippets and highlights', () => {
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
});
it('renders a color dot on tag chips that have a color', async () => {
const doc = makeDoc({
id: 'doc1',
tags: [{ id: 'tag-1', name: 'Familie', color: 'sage' }]
});
render(DocumentList, { ...baseProps, documents: [doc], total: 1 });
const dot = page.getByTestId('tag-color-dot');
await expect.element(dot).toBeInTheDocument();
await expect.element(dot).toHaveAttribute('data-color', 'sage');
});
it('does not render a color dot on tag chips without a color', async () => {
const doc = makeDoc({
id: 'doc1',
tags: [{ id: 'tag-1', name: 'Familie' }]
});
render(DocumentList, { ...baseProps, documents: [doc], total: 1 });
await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument();
});
it('visually marks matched tag when its id is in matchedTagIds', async () => {
const doc = makeDoc({
id: 'doc1',

View File

@@ -12,8 +12,9 @@ let {
to = $bindable(''),
senderId = $bindable(''),
receiverId = $bindable(''),
tagNames = $bindable<string[]>([]),
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
tagQ = $bindable(''),
tagOperator = $bindable<'AND' | 'OR'>('AND'),
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
@@ -21,6 +22,7 @@ let {
initialReceiverName = '',
isLoading = false,
onSearch,
onSearchImmediate,
onfocus,
onblur
}: {
@@ -29,8 +31,9 @@ let {
to?: string;
senderId?: string;
receiverId?: string;
tagNames?: string[];
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
tagQ?: string;
tagOperator?: 'AND' | 'OR';
sort?: string;
dir?: string;
showAdvanced?: boolean;
@@ -38,6 +41,7 @@ let {
initialReceiverName?: string;
isLoading?: boolean;
onSearch: () => void;
onSearchImmediate?: () => void;
onfocus?: () => void;
onblur?: () => void;
} = $props();
@@ -153,6 +157,34 @@ $effect(() => {
onSearch();
}}
/>
{#if tagNames.length >= 2}
<div data-testid="and-or-toggle" class="mt-2 flex items-center gap-1">
<button
type="button"
data-testid="operator-and"
aria-label={m.filter_operator_and_label()}
aria-pressed={tagOperator === 'AND'}
class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'AND' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}"
onclick={() => {
tagOperator = 'AND';
(onSearchImmediate ?? onSearch)();
}}
>{m.filter_operator_and()}</button
>
<button
type="button"
data-testid="operator-or"
aria-label={m.filter_operator_or_label()}
aria-pressed={tagOperator === 'OR'}
class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'OR' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}"
onclick={() => {
tagOperator = 'OR';
(onSearchImmediate ?? onSearch)();
}}
>{m.filter_operator_or()}</button
>
</div>
{/if}
</div>
<!-- Sender -->

View File

@@ -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,91 @@ 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.getByTestId('operator-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.getByTestId('operator-and')).toBeInTheDocument();
await expect.element(toggle.getByTestId('operator-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.getByTestId('operator-or').click();
await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0);
vi.unstubAllGlobals();
});
it('calls onSearchImmediate instead of onSearch when operator is toggled and onSearchImmediate is provided', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
const onSearch = vi.fn();
const onSearchImmediate = vi.fn();
render(SearchFilterBar, {
...defaultProps,
onSearch,
onSearchImmediate,
sort: 'DATE',
dir: 'desc',
tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }]
});
await openAdvanced();
const toggle = page.getByTestId('and-or-toggle');
await toggle.getByTestId('operator-or').click();
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
expect(onSearch).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
describe('SearchFilterBar tagQ live filter', () => {
it('calls onSearch when tag text changes in TagInput', async () => {
vi.stubGlobal(

View File

@@ -1,8 +1,34 @@
import { createApiClient } from '$lib/api.server';
import type { components } from '$lib/generated/api';
import type { LayoutServerLoad } from './$types';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
export type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
function flattenTree(nodes: TagTreeNodeDTO[], result: FlatTag[] = []): FlatTag[] {
for (const node of nodes) {
result.push({
id: node.id!,
name: node.name!,
color: node.color ?? undefined,
parentId: node.parentId ?? undefined,
documentCount: node.documentCount ?? 0
});
if (node.children?.length) flattenTree(node.children, result);
}
return result;
}
export const load: LayoutServerLoad = async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/tags');
return { tags: result.data ?? [] };
const result = await api.GET('/api/tags/tree');
const tree = result.data ?? [];
return { tree, tags: flattenTree(tree) };
};

View File

@@ -8,7 +8,7 @@ const isAtListRoot = $derived(page.url.pathname === '/admin/tags');
</script>
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
<TagsListPanel tags={data.tags} />
<TagsListPanel tree={data.tree} />
</div>
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import TagTreeNode from './TagTreeNode.svelte';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
let {
node,
depth,
collapseMap
}: {
node: TagTreeNodeDTO;
depth: number;
collapseMap: SvelteMap<string, boolean>;
} = $props();
const hasChildren = $derived((node.children?.length ?? 0) > 0);
const isCollapsed = $derived(collapseMap.get(node.id!) ?? false);
const isActive = $derived(page.url.pathname.startsWith('/admin/tags/' + node.id));
function toggleCollapse() {
collapseMap.set(node.id!, !isCollapsed);
}
</script>
<li role="treeitem" aria-selected={isActive} aria-expanded={hasChildren ? !isCollapsed : undefined}>
<!-- 16px per level matches standard tree-view spacing and is large enough to be scannable -->
<div class="flex items-center" style="padding-left: {depth * 16}px">
{#if hasChildren}
<button
onclick={toggleCollapse}
aria-label={isCollapsed ? m.admin_tag_expand_node() : m.admin_tag_collapse_node()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center text-ink-3 transition-colors hover:text-ink"
>
<svg
class="h-3 w-3 transition-transform {isCollapsed ? '' : 'rotate-90'}"
viewBox="0 0 12 12"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 2l4 4-4 4V2z" />
</svg>
</button>
{:else}
<button
class="invisible flex min-h-[44px] min-w-[44px] items-center justify-center"
tabindex="-1"
aria-hidden="true"
></button>
{/if}
{#if depth === 0 && node.color}
<span
data-testid="tag-list-color-dot"
data-color={node.color}
style="background-color: var(--c-tag-{node.color})"
class="mr-1.5 inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
<a
href="/admin/tags/{node.id}"
aria-current={isActive ? 'page' : undefined}
class="flex-1 truncate py-1 text-sm font-bold text-ink transition-colors hover:text-primary {isActive
? 'text-primary'
: ''}"
>
{node.name}
{#if (node.documentCount ?? 0) > 0}
<span class="ml-1 text-xs font-normal text-ink-3">({node.documentCount})</span>
{/if}
</a>
</div>
{#if hasChildren && !isCollapsed}
<ul role="group">
{#each node.children! as child (child.id)}
<TagTreeNode node={child} depth={depth + 1} collapseMap={collapseMap} />
{/each}
</ul>
{/if}
</li>

View File

@@ -1,20 +1,44 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import TagTreeNode from './TagTreeNode.svelte';
type Tag = {
id: string;
name: string;
};
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
let {
tags,
tree,
autocollapse = false
}: {
tags: Tag[];
tree: TagTreeNodeDTO[];
autocollapse?: boolean;
} = $props();
function loadCollapseMap(): SvelteMap<string, boolean> {
if (typeof localStorage !== 'undefined') {
try {
const stored = localStorage.getItem('admin_tags_tree_state');
const parsed: Record<string, boolean> = stored ? JSON.parse(stored) : {};
return new SvelteMap(Object.entries(parsed));
} catch {
// ignore parse errors
}
}
return new SvelteMap();
}
const collapseMap = loadCollapseMap();
$effect(() => {
if (typeof localStorage !== 'undefined') {
const obj: Record<string, boolean> = {};
for (const [k, v] of collapseMap) {
obj[k] = v;
}
localStorage.setItem('admin_tags_tree_state', JSON.stringify(obj));
}
});
let manualCollapse = $state(
typeof localStorage !== 'undefined' &&
localStorage.getItem('admin_tags_list_collapsed') === 'true'
@@ -44,13 +68,11 @@ $effect(() => {
</span>
</button>
{:else}
<div
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
>
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tags_list_title()}
{m.admin_tag_tree_label()}
</span>
<button
onclick={() => (manualCollapse = true)}
@@ -61,25 +83,18 @@ $effect(() => {
</button>
</div>
<!-- Scrollable tag list -->
<!-- Scrollable tag tree -->
<div class="flex-1 overflow-y-auto">
{#if tags.length === 0}
{#if tree.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_tags_empty()}
</p>
{:else}
{#each tags as tag (tag.id)}
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
<a
href="/admin/tags/{tag.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{tag.name}</div>
</a>
{/each}
<ul role="tree" aria-label={m.admin_tag_tree_label()}>
{#each tree as node (node.id)}
<TagTreeNode node={node} depth={0} collapseMap={collapseMap} />
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -3,11 +3,11 @@ import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ params, parent }) => {
export const load: PageServerLoad = async ({ params, parent, url }) => {
const { tags } = await parent();
const tag = tags.find((t: { id: string }) => t.id === params.id);
if (!tag) throw error(404, getErrorMessage('TAG_NOT_FOUND'));
return { tag };
return { tag, mergeSuccess: url.searchParams.has('merged') };
};
export const actions: Actions = {
@@ -15,9 +15,13 @@ export const actions: Actions = {
const data = await request.formData();
const api = createApiClient(fetch);
const name = data.get('name') as string;
const parentId = (data.get('parentId') as string) || null;
const color = (data.get('color') as string) || null;
const result = await api.PUT('/api/tags/{id}', {
params: { path: { id: params.id } },
body: { name: data.get('name') as string }
body: { name, parentId: parentId ?? undefined, color: color ?? undefined }
});
if (!result.response.ok) {
@@ -28,10 +32,14 @@ export const actions: Actions = {
return { success: true };
},
delete: async ({ params, fetch }) => {
merge: async ({ params, request, fetch }) => {
const data = await request.formData();
const targetId = data.get('targetId') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/tags/{id}', {
params: { path: { id: params.id } }
const result = await api.POST('/api/tags/{id}/merge', {
params: { path: { id: params.id } },
body: { targetId }
});
if (!result.response.ok) {
@@ -39,6 +47,28 @@ export const actions: Actions = {
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
},
delete: async ({ params, request, fetch }) => {
const data = await request.formData();
const deleteMode = (data.get('deleteMode') as string) || 'single';
const api = createApiClient(fetch);
const result =
deleteMode === 'subtree'
? await api.DELETE('/api/tags/{id}/subtree', {
params: { path: { id: params.id } }
})
: await api.DELETE('/api/tags/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/tags');
}
};

View File

@@ -1,26 +1,73 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { replaceState } from '$app/navigation';
import { page } from '$app/stores';
import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
import TagParentPicker from '$lib/components/TagParentPicker.svelte';
import TagAncestry from './TagAncestry.svelte';
import TagChildrenPreview from './TagChildrenPreview.svelte';
import TagMergeZone from './TagMergeZone.svelte';
import TagDeleteGuard from './TagDeleteGuard.svelte';
let { data, form } = $props();
let deleteConfirmName = $state('');
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
const unsaved = createUnsavedWarning();
function getInitialParentId() {
return data.tag.parentId ?? '';
}
function getInitialColor() {
return data.tag.color ?? '';
}
function getInitialParentName() {
if (!data.tag.parentId) return '';
return data.tags.find((t: { id: string }) => t.id === data.tag.parentId)?.name ?? '';
}
let parentId = $state(getInitialParentId());
let selectedColor = $state(getInitialColor());
let parentName = $state(getInitialParentName());
// Reset state when navigating between tags client-side
$effect(() => {
void data.tag.id;
parentId = data.tag.parentId ?? '';
selectedColor = data.tag.color ?? '';
parentName = getInitialParentName();
});
$effect(() => {
if (form?.success) unsaved.clearOnSuccess();
});
$effect(() => {
if (data.mergeSuccess) {
replaceState($page.url.pathname, {});
}
});
const colors = [
'sage',
'sienna',
'amber',
'slate',
'violet',
'rose',
'cobalt',
'moss',
'sand',
'coral'
];
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Detail panel header -->
<!-- Header -->
<div class="flex items-center border-b border-line px-5 py-3">
<a
href="/admin/tags"
aria-label="Zurück zur Tag-Übersicht"
class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden"
>
<svg
@@ -41,9 +88,21 @@ $effect(() => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- TagAncestry breadcrumb -->
<TagAncestry tag={data.tag} allTags={data.tags} />
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if data.mergeSuccess}
<div
class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"
role="status"
aria-live="polite"
>
{m.admin_tag_merge_success()}
</div>
{/if}
{#if form?.success}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.admin_tag_updated()}
@@ -60,11 +119,12 @@ $effect(() => {
id="edit-tag-form"
method="POST"
action="?/update"
use:enhance
use:enhance={() => async ({ update }) => { await update({ reset: false }); }}
oninput={unsaved.markDirty}
class="mb-5"
>
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<!-- Name card -->
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_col_name()}
</h3>
@@ -77,40 +137,65 @@ $effect(() => {
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Parent selector (TagParentPicker) -->
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
Übergeordnetes Schlagwort
</h3>
<TagParentPicker
name="parentId"
bind:value={parentId}
excludeIds={[data.tag.id]}
initialName={parentName}
allTags={data.tags}
/>
</div>
<!-- Color picker (only when no parent) -->
{#if parentId === ''}
<div
data-testid="color-picker"
class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm"
>
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">Farbe</h3>
<div class="mb-3 flex flex-wrap gap-2" role="group" aria-label="Farbe auswählen">
{#each colors as colorName (colorName)}
<button
type="button"
data-testid="color-swatch-{colorName}"
aria-pressed={selectedColor === colorName ? 'true' : 'false'}
aria-label={colorName}
style="background-color: var(--c-tag-{colorName})"
class="h-8 w-8 rounded-full {selectedColor === colorName ? 'ring-2 ring-current ring-offset-2' : ''}"
onclick={() => { selectedColor = selectedColor === colorName ? '' : colorName; }}
></button>
{/each}
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-surface text-sm text-ink-3 hover:text-ink"
onclick={() => { selectedColor = ''; }}
aria-label="Farbe zurücksetzen">×</button
>
</div>
<input type="hidden" name="color" value={selectedColor} />
</div>
{:else}
<input type="hidden" name="color" value="" />
{/if}
</form>
<!-- Danger zone -->
<div
class="rounded-sm border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30"
>
<h3 class="mb-3 text-xs font-bold tracking-widest text-red-700 uppercase dark:text-red-400">
{m.btn_delete()}
</h3>
<p class="mb-3 text-xs text-red-700 dark:text-red-400">
{m.admin_tag_delete_confirm()}
</p>
<p class="mb-2 text-xs font-bold text-ink-2">
Gib <span class="font-mono">{data.tag.name}</span> zur Bestätigung ein:
</p>
<input
type="text"
bind:value={deleteConfirmName}
placeholder={data.tag.name}
class="mb-3 w-full rounded-sm border border-red-200 bg-white px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-red-400 focus:outline-none"
/>
<form method="POST" action="?/delete" use:enhance>
<button
type="submit"
disabled={!deleteEnabled}
class="rounded-sm bg-red-600 px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.btn_delete()}
</button>
</form>
</div>
<!-- Children preview -->
<TagChildrenPreview tag={data.tag} allTags={data.tags} />
<!-- Merge zone -->
<TagMergeZone tag={data.tag} allTags={data.tags} form={form} />
<!-- Delete guard -->
<TagDeleteGuard tag={data.tag} allTags={data.tags} />
</div>
<!-- Docked footer -->
<!-- Footer -->
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
<a
href="/admin/tags"

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { name: string; parentId?: string };
allTags: FlatTag[];
}
let { tag, allTags }: Props = $props();
const ancestors = $derived.by(() => {
const chain: FlatTag[] = [];
let current: FlatTag | undefined = allTags.find((t) => t.id === tag.parentId);
while (current) {
chain.push(current);
const parentId = current.parentId;
current = parentId ? allTags.find((t) => t.id === parentId) : undefined;
}
return chain.reverse();
});
</script>
{#if ancestors.length > 0}
<nav aria-label={m.admin_tag_ancestry_label()}>
<ol class="flex items-center gap-1 text-xs text-ink-3">
{#each ancestors as ancestor (ancestor.id)}
<li>
<a href="/admin/tags/{ancestor.id}" class="hover:text-ink">{ancestor.name}</a>
</li>
<li aria-hidden="true"></li>
{/each}
<li class="text-ink">{tag.name}</li>
</ol>
</nav>
{/if}

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagAncestry from './TagAncestry.svelte';
afterEach(cleanup);
const allTags = [
{ id: 't1', name: 'Root', documentCount: 0 },
{ id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
{ id: 't3', name: 'Grandchild', parentId: 't2', documentCount: 0 }
];
describe('TagAncestry', () => {
it('renders nothing for a root tag', async () => {
const { container } = render(TagAncestry, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
expect(container.querySelector('nav')).toBeFalsy();
});
it('renders a nav element for a child tag', async () => {
const { container } = render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
expect(container.querySelector('nav')).toBeTruthy();
});
it('shows parent name as a link', async () => {
render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
await expect.element(page.getByRole('link', { name: 'Root' })).toBeInTheDocument();
await expect
.element(page.getByRole('link', { name: 'Root' }))
.toHaveAttribute('href', '/admin/tags/t1');
});
it('shows full ancestor chain for deeply nested tag', async () => {
render(TagAncestry, {
tag: { id: 't3', name: 'Grandchild', parentId: 't2', documentCount: 0 },
allTags
});
await expect.element(page.getByRole('link', { name: 'Root' })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Child' })).toBeInTheDocument();
});
it('does not render current tag as a link', async () => {
render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
const links = document.querySelectorAll('nav a');
const linkTexts = Array.from(links).map((a) => a.textContent?.trim());
expect(linkTexts).not.toContain('Child');
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { id: string };
allTags: FlatTag[];
}
let { tag, allTags }: Props = $props();
let showAll = $state(false);
const children = $derived(allTags.filter((t) => t.parentId === tag.id));
const visibleChildren = $derived(showAll ? children : children.slice(0, 5));
</script>
{#if children.length > 0}
<div
data-testid="children-preview"
class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm"
>
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tag_children_label()}
</h3>
<div class="flex flex-wrap gap-2">
{#each visibleChildren as child (child.id)}
<a
href="/admin/tags/{child.id}"
class="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink hover:bg-accent-bg"
>{child.name}</a
>
{/each}
{#if !showAll && children.length > 5}
<button
type="button"
data-testid="expand-children"
onclick={() => showAll = true}
class="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-3 hover:text-ink"
>{m.admin_tag_children_more({ count: children.length - 5 })}</button
>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagChildrenPreview from './TagChildrenPreview.svelte';
afterEach(cleanup);
const allTags = [
{ id: 't1', name: 'Root', documentCount: 0 },
{ id: 't2', name: 'Alpha', parentId: 't1', documentCount: 0 },
{ id: 't3', name: 'Beta', parentId: 't1', documentCount: 0 },
{ id: 't4', name: 'Gamma', parentId: 't1', documentCount: 0 },
{ id: 't5', name: 'Delta', parentId: 't1', documentCount: 0 },
{ id: 't6', name: 'Epsilon', parentId: 't1', documentCount: 0 },
{ id: 't7', name: 'Zeta', parentId: 't1', documentCount: 0 }
];
describe('TagChildrenPreview', () => {
it('renders nothing for a leaf tag', async () => {
const { container } = render(TagChildrenPreview, {
tag: { id: 't2', name: 'Alpha', parentId: 't1', documentCount: 0 },
allTags
});
expect(container.querySelector('[data-testid="children-preview"]')).toBeFalsy();
});
it('renders section heading for a tag with children', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
await expect.element(page.getByTestId('children-preview')).toBeInTheDocument();
});
it('shows up to 5 children as chips', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// Alpha through Epsilon should be visible (5 chips)
await expect.element(page.getByText('Alpha')).toBeInTheDocument();
await expect.element(page.getByText('Epsilon')).toBeInTheDocument();
});
it('shows expand button when there are more than 5 children', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// There are 6 children — should show "und 1 weitere" expand button
const expandBtn = document.querySelector<HTMLButtonElement>(
'button[data-testid="expand-children"]'
);
expect(expandBtn).toBeTruthy();
});
it('expand button reveals hidden children inline', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// Zeta is hidden behind the expand button
await expect.element(page.getByText('Zeta')).not.toBeInTheDocument();
await page.getByTestId('expand-children').click();
await expect.element(page.getByText('Zeta')).toBeInTheDocument();
});
it('does not show expand button when children count <= 5', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags: allTags.slice(0, 4) // Root + 3 children
});
expect(document.querySelector('[data-testid="expand-children"]')).toBeFalsy();
});
});

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { id: string; documentCount: number; parentId?: string };
allTags: FlatTag[];
}
let { tag, allTags }: Props = $props();
let deleteForm: HTMLFormElement;
const { confirm } = getConfirmService();
let selectedMode: 'single' | 'subtree' | null = $state(null);
const canDelete = $derived(selectedMode !== null);
async function handleDelete() {
const ok = await confirm({ title: m.admin_tag_delete_confirm(), destructive: true });
if (ok) deleteForm.requestSubmit();
}
// Count all descendants (recursive walk through allTags)
const descendantCount = $derived.by(() => {
let count = 0;
const queue = [tag.id];
while (queue.length) {
const cur = queue.shift()!;
for (const t of allTags) {
if (t.parentId === cur) {
count++;
queue.push(t.id);
}
}
}
return count;
});
</script>
<div class="rounded-sm border border-danger bg-danger/5 p-5">
<h3 class="mb-3 text-xs font-bold tracking-widest text-danger uppercase">{m.btn_delete()}</h3>
<!-- Impact summary -->
<p class="mb-4 text-xs text-ink-2">
{m.admin_tag_delete_impact({ docs: tag.documentCount, descendants: descendantCount })}
</p>
<!-- Radios -->
<div class="mb-4 flex flex-col gap-3">
<label class="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="deleteMode"
value="single"
bind:group={selectedMode}
class="mt-0.5"
/>
<div>
<span class="text-sm font-medium text-ink">{m.admin_tag_delete_only_this()}</span>
<p class="text-xs text-ink-3">
{#if tag.parentId}
{m.admin_tag_delete_only_this_sub({ parent: tag.parentId })}
{:else}
{m.admin_tag_delete_only_this_sub_root()}
{/if}
</p>
</div>
</label>
<label class="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="deleteMode"
value="subtree"
bind:group={selectedMode}
class="mt-0.5"
/>
<div>
<span class="text-sm font-medium text-ink">{m.admin_tag_delete_subtree()}</span>
{#if descendantCount > 0}
<p class="text-xs font-medium text-warning">
{m.admin_tag_delete_subtree_warn({ count: descendantCount })}
</p>
{/if}
</div>
</label>
</div>
<!-- Confirm form -->
<form bind:this={deleteForm} method="POST" action="?/delete" use:enhance>
<input type="hidden" name="deleteMode" value={selectedMode ?? ''} />
<button
type="button"
data-testid="delete-submit-btn"
disabled={!canDelete}
onclick={handleDelete}
class="rounded-sm bg-danger px-4 py-2 font-sans text-xs font-bold tracking-widest text-danger-fg uppercase transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
>
{selectedMode === 'subtree' ? m.admin_tag_delete_subtree_confirm_btn() : m.btn_delete()}
</button>
</form>
</div>

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagDeleteGuard from './TagDeleteGuard.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
afterEach(cleanup);
function renderWithConfirm(props = { tag, allTags }) {
const service = createConfirmService();
const result = render(TagDeleteGuard, {
props,
context: new Map([[CONFIRM_KEY, service]])
});
return { ...result, service };
}
const tag = { id: 't1', name: 'Familie', documentCount: 3 };
const allTags = [
{ id: 't1', name: 'Familie', documentCount: 3 },
{ id: 't2', name: 'Eltern', parentId: 't1', documentCount: 1 },
{ id: 't3', name: 'Kinder', parentId: 't1', documentCount: 0 }
];
describe('TagDeleteGuard', () => {
it('renders two radio options (single and subtree)', async () => {
renderWithConfirm();
await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument();
await expect
.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i }))
.toBeInTheDocument();
});
it('delete button is disabled initially', async () => {
renderWithConfirm();
await expect.element(page.getByTestId('delete-submit-btn')).toBeDisabled();
});
it('delete button is enabled after selecting single radio', async () => {
renderWithConfirm();
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
});
it('delete button is enabled after selecting subtree radio', async () => {
renderWithConfirm();
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
});
it('delete button shows subtree-specific text when subtree mode is selected', async () => {
renderWithConfirm();
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
await expect
.element(page.getByTestId('delete-submit-btn'))
.toHaveTextContent(/Teilbaum löschen/i);
});
it('shows descendant count in impact summary', async () => {
renderWithConfirm();
// tag has 2 descendants (t2 and t3)
await expect.element(page.getByText(/2 Untergeordnete/)).toBeInTheDocument();
});
it('shows document count in impact summary', async () => {
renderWithConfirm();
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
});
});
describe('TagDeleteGuard confirmation dialog', () => {
it('opens confirm dialog when delete button is clicked', async () => {
const { service } = renderWithConfirm();
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
expect(service.options?.destructive).toBe(true);
service.settle(false);
});
it('submits the form when user confirms', async () => {
const { service, container } = renderWithConfirm();
const form = container.querySelector('form')!;
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(requestSubmit).toHaveBeenCalledOnce();
});
it('does not submit when user cancels the dialog', async () => {
const { service, container } = renderWithConfirm();
const form = container.querySelector('form')!;
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
await page.getByRole('radio', { name: /Nur dieses/i }).click();
await page.getByTestId('delete-submit-btn').click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(requestSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import TagParentPicker from '$lib/components/TagParentPicker.svelte';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { id: string; name: string; documentCount: number };
allTags: FlatTag[];
form: { error?: string } | null;
}
let { tag, allTags, form }: Props = $props();
let targetId = $state('');
$effect(() => {
void tag.id;
targetId = '';
});
const step = $derived(targetId ? 2 : 1);
// All descendants of tag.id (to exclude from picker)
const descendantIds = $derived.by(() => {
const ids: string[] = [];
const queue = [tag.id];
while (queue.length) {
const cur = queue.shift()!;
for (const t of allTags) {
if (t.parentId === cur) {
ids.push(t.id);
queue.push(t.id);
}
}
}
return ids;
});
const excludeIds = $derived([tag.id, ...descendantIds]);
// Find target tag for step 2 preview
const targetTag = $derived(allTags.find((t) => t.id === targetId));
</script>
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
<h3 class="mb-1 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tag_merge_heading()}
</h3>
<p class="mb-4 text-xs text-ink-3">{m.admin_tag_merge_description()}</p>
<!-- Step indicator (aria-live announces step changes to screen reader users) -->
<p class="mb-3 text-xs font-medium text-ink-3" aria-live="polite" role="status">
{step === 1 ? m.admin_tag_merge_step1() : m.admin_tag_merge_step2()}
</p>
<!-- Step 1: pick target -->
<div>
<label for="mergePickerTarget-search" class="mb-1 block text-xs font-medium text-ink-2">
{m.admin_tag_merge_target_label()}
</label>
<TagParentPicker
name="mergePickerTarget"
bind:value={targetId}
excludeIds={excludeIds}
placeholder={m.admin_tag_merge_target_placeholder()}
/>
</div>
<!-- Step 2: confirm -->
{#if step === 2}
<div data-testid="merge-step2" class="mt-4">
<!-- From/to summary -->
<div
class="mb-4 flex items-center gap-3 rounded border border-line bg-surface/50 p-3 text-sm"
>
<div class="flex-1">
<div class="font-medium text-ink">{tag.name}</div>
<div class="text-xs text-ink-3">
{m.admin_tag_merge_preview_docs({ count: tag.documentCount })}
</div>
</div>
<svg
class="h-4 w-4 flex-shrink-0 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<div class="flex-1">
<div class="font-medium text-ink">{targetTag?.name ?? ''}</div>
</div>
</div>
<p class="mb-3 text-xs text-ink-3">{m.admin_tag_merge_deleted_after()}</p>
<form method="POST" action="?/merge" use:enhance>
<input type="hidden" name="targetId" value={targetId} />
<button
type="submit"
class="rounded-sm bg-amber-600 px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.admin_tag_merge_confirm_btn()}
</button>
</form>
</div>
{/if}
{#if form?.error}
<p class="mt-3 text-xs text-red-600">{form.error}</p>
{/if}
</div>

View File

@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagMergeZone from './TagMergeZone.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
const tag = { id: 't1', name: 'Familie', documentCount: 3 };
const allTags = [
{ id: 't1', name: 'Familie', documentCount: 3 },
{ id: 't2', name: 'Reise', documentCount: 1 },
{ id: 't3', name: 'Urlaub', documentCount: 0, parentId: 't1' }
];
describe('TagMergeZone rendering', () => {
it('renders the merge heading', async () => {
render(TagMergeZone, { tag, allTags, form: null });
await expect.element(page.getByText(/Zusammenführen/i)).toBeInTheDocument();
});
it('renders a TagParentPicker (combobox) for target selection', async () => {
render(TagMergeZone, { tag, allTags, form: null });
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('combobox has merge-specific placeholder text', async () => {
render(TagMergeZone, { tag, allTags, form: null });
const input = await page.getByRole('combobox').element();
expect(input.getAttribute('placeholder')).toBe('Ziel-Schlagwort suchen …');
});
});
describe('TagMergeZone step flow', () => {
it('step 2 is not shown before a target is selected', async () => {
const { container } = render(TagMergeZone, { tag, allTags, form: null });
expect(container.querySelector('[data-testid="merge-step2"]')).toBeFalsy();
});
it('shows step 2 confirm button after target is selected', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([{ id: 't2', name: 'Reise' }])
})
);
render(TagMergeZone, { tag, allTags, form: null });
const input = page.getByRole('combobox');
await input.fill('R');
await vi.advanceTimersByTimeAsync(300);
await page.getByRole('option', { name: 'Reise' }).click();
await vi.advanceTimersByTimeAsync(0);
await expect.element(page.getByTestId('merge-step2')).toBeInTheDocument();
});
});
describe('TagMergeZone stale state reset', () => {
it('resets target selection when tag prop changes', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([{ id: 't2', name: 'Reise' }])
})
);
const { rerender } = render(TagMergeZone, { tag, allTags, form: null });
const input = page.getByRole('combobox');
await input.fill('R');
await vi.advanceTimersByTimeAsync(300);
await page.getByRole('option', { name: 'Reise' }).click();
await vi.advanceTimersByTimeAsync(0);
await expect.element(page.getByTestId('merge-step2')).toBeInTheDocument();
// Navigate to a different tag
await rerender({ tag: { id: 't2', name: 'Reise', documentCount: 1 }, allTags, form: null });
await vi.advanceTimersByTimeAsync(0);
// step 2 should be gone — targetId was reset
expect(document.querySelector('[data-testid="merge-step2"]')).toBeFalsy();
});
});

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { actions } from './+page.server';
import { actions, load } from './+page.server';
const mockApi = {
PUT: vi.fn(),
POST: vi.fn(),
DELETE: vi.fn()
};
@@ -12,6 +13,30 @@ vi.mock('$lib/api.server', () => ({
beforeEach(() => vi.clearAllMocks());
// ─── load function ─────────────────────────────────────────────────────────────
describe('tags/[id] — load function', () => {
it('returns mergeSuccess: true when url has ?merged param', async () => {
const url = new URL('http://localhost/admin/tags/t1?merged=1');
const result = await load({
params: { id: 't1' },
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
url
} as never);
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
});
it('returns mergeSuccess: false when url has no ?merged param', async () => {
const url = new URL('http://localhost/admin/tags/t1');
const result = await load({
params: { id: 't1' },
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
url
} as never);
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
});
});
// ─── update action ─────────────────────────────────────────────────────────────
describe('tags/[id] — update action', () => {
@@ -49,25 +74,121 @@ describe('tags/[id] — update action', () => {
});
});
// ─── merge action ─────────────────────────────────────────────────────────────
describe('tags/[id] — merge action', () => {
it('redirects to target tag with ?merged=1 on successful merge', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: true },
data: { id: 't2', name: 'Reise' }
});
const formData = new FormData();
formData.set('targetId', 't2');
let redirectUrl: string | null = null;
try {
await actions.merge({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch (e: unknown) {
const r = e as { location?: string };
redirectUrl = r.location ?? null;
}
expect(redirectUrl).toBe('/admin/tags/t2?merged=1');
});
it('returns fail when merge API responds not ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 400 },
error: { code: 'TAG_MERGE_SELF' }
});
const formData = new FormData();
formData.set('targetId', 't1');
const result = await actions.merge({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
expect((result as { status: number }).status).toBe(400);
});
});
// ─── delete action ─────────────────────────────────────────────────────────────
describe('tags/[id] — delete action', () => {
it('redirects to /admin/tags on successful delete', async () => {
describe('tags/[id] — delete action (single)', () => {
it('calls DELETE /api/tags/{id} when deleteMode=single', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'single');
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch {
// redirect expected
}
expect(mockApi.DELETE).toHaveBeenCalledWith(
'/api/tags/{id}',
expect.objectContaining({ params: { path: { id: 't1' } } })
);
});
it('redirects to /admin/tags on successful single delete', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'single');
let redirectUrl: string | null = null;
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch (e: unknown) {
const r = e as { location?: string; status?: number };
const r = e as { location?: string };
redirectUrl = r.location ?? null;
}
expect(redirectUrl).toBe('/admin/tags');
});
});
describe('tags/[id] — delete action (subtree)', () => {
it('calls DELETE /api/tags/{id}/subtree when deleteMode=subtree', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'subtree');
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch {
// redirect expected
}
expect(mockApi.DELETE).toHaveBeenCalledWith(
'/api/tags/{id}/subtree',
expect.objectContaining({ params: { path: { id: 't1' } } })
);
});
it('returns fail with error message when delete API responds not ok', async () => {
mockApi.DELETE.mockResolvedValue({
@@ -75,8 +196,12 @@ describe('tags/[id] — delete action', () => {
error: { code: 'FORBIDDEN' }
});
const formData = new FormData();
formData.set('deleteMode', 'single');
const result = await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);

View File

@@ -2,14 +2,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn(),
goto: vi.fn(),
replaceState: vi.fn()
}));
vi.mock('$app/stores', () => ({
page: {
subscribe: (fn: (v: { url: URL }) => void) => {
fn({ url: new URL('http://localhost/admin/tags/t1') });
return () => {};
}
}
}));
import { beforeNavigate, goto } from '$app/navigation';
const baseTag = { id: 't1', name: 'Familie' };
const baseData = { tag: baseTag };
const baseTag = { id: 't1', name: 'Familie', documentCount: 0 };
const baseData = {
tag: baseTag,
tags: [] as {
id: string;
name: string;
parentId?: string;
color?: string;
documentCount: number;
}[]
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function renderPage(props: { data: any; form: null }) {
const service = createConfirmService();
return render(Page, { props, context: new Map([[CONFIRM_KEY, service]]) });
}
afterEach(cleanup);
@@ -17,28 +45,22 @@ afterEach(cleanup);
describe('Admin edit tag page rendering', () => {
it('renders the heading with tag name', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Schlagwort: Familie/i)).toBeInTheDocument();
});
it('pre-fills the name input', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="name"]');
expect(input?.value).toBe('Familie');
});
it('renders the cancel link pointing to /admin/tags', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin/tags');
});
it('delete button is disabled until tag name is typed in confirm field', async () => {
render(Page, { data: baseData, form: null });
const deleteBtn = document.querySelector<HTMLButtonElement>('button[type="submit"]');
expect(deleteBtn?.disabled).toBe(true);
});
});
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
@@ -47,12 +69,12 @@ describe('Admin edit tag page unsaved-changes guard', () => {
beforeEach(() => vi.clearAllMocks());
it('does not show unsaved warning initially', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows warning when rename form is dirty', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
@@ -67,7 +89,7 @@ describe('Admin edit tag page unsaved-changes guard', () => {
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
@@ -77,7 +99,7 @@ describe('Admin edit tag page unsaved-changes guard', () => {
});
it('discard button calls goto with the target URL', async () => {
render(Page, { data: baseData, form: null });
renderPage({ data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
@@ -91,3 +113,94 @@ describe('Admin edit tag page unsaved-changes guard', () => {
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2');
});
});
// ─── Parent selector (TagParentPicker combobox) ───────────────────────────────
describe('Admin edit tag page parent selector', () => {
it('renders a TagParentPicker combobox', async () => {
renderPage({ data: baseData, form: null });
await expect
.element(page.getByRole('combobox', { name: /Übergeordnetes Schlagwort/i }))
.toBeInTheDocument();
});
});
// ─── Color picker ─────────────────────────────────────────────────────────────
describe('Admin edit tag page color picker', () => {
it('renders color picker when tag has no parent', async () => {
renderPage({
data: { tag: { id: 't1', name: 'Familie', parentId: undefined, documentCount: 0 }, tags: [] },
form: null
});
await expect.element(page.getByTestId('color-picker')).toBeInTheDocument();
});
it('hides color picker when tag already has a parent', async () => {
renderPage({
data: {
tag: { id: 't1', name: 'Familie', parentId: 't2', documentCount: 0 },
tags: [{ id: 't2', name: 'Reise', documentCount: 0 }]
},
form: null
});
await expect.element(page.getByTestId('color-picker')).not.toBeInTheDocument();
});
it('pre-selects the current tag color in the color picker', async () => {
renderPage({
data: { tag: { id: 't1', name: 'Familie', color: 'sage', documentCount: 0 }, tags: [] },
form: null
});
const selected = page.getByTestId('color-swatch-sage');
await expect.element(selected).toHaveAttribute('aria-pressed', 'true');
});
});
// ─── Merge success banner ─────────────────────────────────────────────────────
describe('Admin edit tag page merge success banner', () => {
it('shows merge success banner when data.mergeSuccess is true', async () => {
renderPage({ data: { ...baseData, mergeSuccess: true }, form: null });
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).toBeInTheDocument();
});
it('does not show merge success banner when data.mergeSuccess is false', async () => {
renderPage({ data: { ...baseData, mergeSuccess: false }, form: null });
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).not.toBeInTheDocument();
});
});
// ─── New components present ───────────────────────────────────────────────────
describe('Admin edit tag page new components', () => {
it('renders TagAncestry nav when tag has a parent', async () => {
const { container } = renderPage({
data: {
tag: { id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 },
tags: [
{ id: 't1', name: 'Eltern', documentCount: 0 },
{ id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 }
]
},
form: null
});
expect(container.querySelector('nav')).toBeTruthy();
});
it('does not render TagAncestry nav for root tag', async () => {
const { container } = renderPage({ data: baseData, form: null });
expect(container.querySelector('nav')).toBeFalsy();
});
it('renders TagMergeZone with merge heading', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Zusammenführen/i)).toBeInTheDocument();
});
it('renders TagDeleteGuard with two radio options', async () => {
renderPage({ data: baseData, form: null });
const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios.length).toBe(2);
});
});

View File

@@ -5,37 +5,84 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/api.server';
function mockApi(tags: unknown[]) {
function mockTreeApi(tree: unknown[]) {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: tags })
GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: tree })
} as ReturnType<typeof createApiClient>);
}
beforeEach(() => vi.clearAllMocks());
const sampleTree = [
{
id: 'parent1',
name: 'Familie',
color: 'teal',
documentCount: 3,
parentId: null,
children: [
{
id: 'child1',
name: 'Eltern',
color: null,
documentCount: 2,
parentId: 'parent1',
children: []
}
]
},
{
id: 'root2',
name: 'Urlaub',
color: null,
documentCount: 1,
parentId: null,
children: []
}
];
describe('admin/tags layout load', () => {
it('returns the tags list', async () => {
mockApi([
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Urlaub' }
]);
it('returns the tree list', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tags).toHaveLength(2);
expect(result.tags[0].name).toBe('Familie');
expect(result.tree).toHaveLength(2);
expect(result.tree[0].name).toBe('Familie');
});
it('returns an empty array when the API returns nothing', async () => {
mockApi([]);
it('returns an empty tree when the API returns nothing', async () => {
mockTreeApi([]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tags).toEqual([]);
expect(result.tree).toEqual([]);
});
it('calls GET /api/tags', async () => {
it('calls GET /api/tags/tree', async () => {
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true }, data: [] });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledWith('/api/tags');
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
});
it('flattens the tree into a flat tags array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
// Both parent and child should be in the flat array
expect(result.tags).toHaveLength(3);
expect(result.tags.map((t) => t.name)).toContain('Eltern');
});
it('preserves parentId on child tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const child = result.tags.find((t) => t.name === 'Eltern');
expect(child?.parentId).toBe('parent1');
});
it('sets parentId to undefined on root tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const root = result.tags.find((t) => t.name === 'Familie');
expect(root?.parentId).toBeUndefined();
});
});

View File

@@ -9,44 +9,97 @@ vi.mock('$app/state', () => ({
afterEach(cleanup);
const tags = [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Urlaub' },
{ id: 't3', name: 'Schule' }
const tree = [
{
id: 't1',
name: 'Familie',
color: undefined,
documentCount: 3,
parentId: undefined,
children: [
{ id: 't2', name: 'Eltern', color: undefined, documentCount: 2, parentId: 't1', children: [] }
]
},
{
id: 't3',
name: 'Urlaub',
color: 'teal',
documentCount: 0,
parentId: undefined,
children: []
}
];
describe('TagsListPanel — header', () => {
it('renders the panel title', async () => {
render(TagsListPanel, { tags });
await expect.element(page.getByText(/Alle Schlagworte/i)).toBeInTheDocument();
render(TagsListPanel, { tree });
await expect.element(page.getByText(/Schlagwörter/i)).toBeInTheDocument();
});
});
describe('TagsListPanel — ARIA tree', () => {
it('renders a role="tree" container', async () => {
const { container } = render(TagsListPanel, { tree });
expect(container.querySelector('[role="tree"]')).toBeTruthy();
});
it('renders role="treeitem" on each item', async () => {
const { container } = render(TagsListPanel, { tree });
const items = container.querySelectorAll('[role="treeitem"]');
// Both parent and child treeitem
expect(items.length).toBeGreaterThanOrEqual(2);
});
it('sets aria-expanded on nodes with children', async () => {
const { container } = render(TagsListPanel, { tree });
const familieItem = container.querySelector<HTMLElement>('[role="treeitem"]');
expect(familieItem?.hasAttribute('aria-expanded')).toBe(true);
});
it('does NOT set aria-expanded on leaf nodes', async () => {
const { container } = render(TagsListPanel, { tree });
const items = container.querySelectorAll<HTMLElement>('[role="treeitem"]');
const urlaubItem = Array.from(items).find((el) => el.textContent?.includes('Urlaub'));
expect(urlaubItem?.hasAttribute('aria-expanded')).toBe(false);
});
});
describe('TagsListPanel — tag items', () => {
it('renders each tag name', async () => {
render(TagsListPanel, { tags });
it('renders each root tag name as a link', async () => {
render(TagsListPanel, { tree });
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
});
it('each tag links to /admin/tags/[id]', async () => {
const { container } = render(TagsListPanel, { tags });
const links = container.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/tags/t"]');
expect(links.length).toBe(3);
expect(links[0].getAttribute('href')).toBe('/admin/tags/t1');
const { container } = render(TagsListPanel, { tree });
const link = container.querySelector<HTMLAnchorElement>('a[href="/admin/tags/t1"]');
expect(link).toBeTruthy();
});
it('renders document count badge when documentCount > 0', async () => {
const { container } = render(TagsListPanel, { tree });
// Familie has count 3 — should show "(3)"
expect(container.textContent).toContain('(3)');
});
it('does not render count badge when documentCount is 0', async () => {
const { container } = render(TagsListPanel, { tree });
// Urlaub has count 0 — should NOT show "(0)"
expect(container.textContent).not.toContain('(0)');
});
});
describe('TagsListPanel — active state', () => {
it('marks the active tag link with aria-current=page', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('link', { name: /familie/i }))
.toHaveAttribute('aria-current', 'page');
});
it('does not mark inactive tag links with aria-current', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('link', { name: /urlaub/i }))
.not.toHaveAttribute('aria-current');
@@ -54,26 +107,52 @@ describe('TagsListPanel — active state', () => {
});
describe('TagsListPanel — empty state', () => {
it('shows empty state when tags array is empty', async () => {
render(TagsListPanel, { tags: [] });
it('shows empty state when tree is empty', async () => {
render(TagsListPanel, { tree: [] });
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('TagsListPanel — color dot', () => {
it('renders color dot on root tags that have a color', async () => {
render(TagsListPanel, { tree });
const dot = page.getByTestId('tag-list-color-dot');
await expect.element(dot).toBeInTheDocument();
await expect.element(dot).toHaveAttribute('data-color', 'teal');
});
it('does not render color dot on tags without a color', async () => {
render(TagsListPanel, {
tree: [
{
id: 't1',
name: 'Familie',
documentCount: 0,
parentId: undefined,
children: [],
color: undefined
}
]
});
await expect.element(page.getByTestId('tag-list-color-dot')).not.toBeInTheDocument();
});
});
describe('TagsListPanel — collapse toggle', () => {
beforeEach(() => localStorage.removeItem('admin_tags_list_collapsed'));
beforeEach(() => {
localStorage.removeItem('admin_tags_list_collapsed');
localStorage.removeItem('admin_tags_tree_state');
});
it('renders a collapse button with aria-label', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('clicking collapse shows the expand handle', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await page.getByRole('button', { name: /Liste einklappen/i }).click();
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
@@ -81,14 +160,14 @@ describe('TagsListPanel — collapse toggle', () => {
});
it('autocollapse prop starts the panel in collapsed state', async () => {
render(TagsListPanel, { tags, autocollapse: true });
render(TagsListPanel, { tree, autocollapse: true });
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
it('persists collapse state using the tags-specific localStorage key', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await vi.waitFor(() =>
@@ -97,3 +176,29 @@ describe('TagsListPanel — collapse toggle', () => {
setSpy.mockRestore();
});
});
describe('TagsListPanel — chevron collapse', () => {
beforeEach(() => {
localStorage.removeItem('admin_tags_tree_state');
localStorage.removeItem('admin_tags_list_collapsed');
});
it('child items are visible by default', async () => {
render(TagsListPanel, { tree });
await expect.element(page.getByRole('link', { name: /eltern/i })).toBeInTheDocument();
});
it('clicking the chevron collapses children', async () => {
const { container } = render(TagsListPanel, { tree });
// Find the chevron button inside the Familie treeitem
const familieItem = Array.from(container.querySelectorAll('[role="treeitem"]')).find((el) =>
el.textContent?.includes('Familie')
);
const chevron = familieItem?.querySelector<HTMLButtonElement>('button[aria-label]');
chevron?.click();
await vi.waitFor(() => {
const eltern = container.querySelector('a[href="/admin/tags/t2"]');
expect(eltern).toBeFalsy();
});
});
});

View File

@@ -11,7 +11,7 @@ import SaveBar from './SaveBar.svelte';
let { data, form } = $props();
let { document: doc } = untrack(() => data);
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let tags = $state(doc.tags ?? []);
let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []);
</script>

View File

@@ -10,7 +10,7 @@ import { type FilenameParseResult } from '$lib/utils/filename';
let { data, form } = $props();
let tags: string[] = $state([]);
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
let senderId = $state(untrack(() => data.initialSenderId));
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
$state(untrack(() => data.initialReceivers));

View File

@@ -33,7 +33,7 @@ $effect(() => {
onDestroy(() => fileLoader.destroy());
// Form state
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
let tags = $state(untrack(() => doc.tags ?? []));
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
</script>

View File

@@ -70,6 +70,10 @@
--color-danger: var(--c-danger);
--color-danger-fg: var(--c-danger-fg);
/* Warning — amber, WCAG AA on white */
--color-warning: #b45309;
--color-warning-fg: #ffffff;
/* Static brand tokens (not themed) */
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
@@ -115,6 +119,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 +199,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 +263,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 <img> ──── */

View File

@@ -31,7 +31,8 @@ const emptyData = {
tags: [],
sort: 'DATE' as const,
dir: 'desc' as const,
tagQ: ''
tagQ: '',
tagOp: 'AND'
},
documents: [],
total: 0,