feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249
@@ -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 ---
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.UUID;
|
||||
|
||||
public record MergeTagDTO(@NotNull UUID targetId) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagUpdateDTO(String name, UUID parentId, String color) {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) -> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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('.');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
160
frontend/src/lib/components/TagParentPicker.svelte
Normal file
160
frontend/src/lib/components/TagParentPicker.svelte
Normal 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>
|
||||
202
frontend/src/lib/components/TagParentPicker.svelte.spec.ts
Normal file
202
frontend/src/lib/components/TagParentPicker.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
87
frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts
Normal file
87
frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
frontend/src/lib/hooks/useTypeahead.svelte.ts
Normal file
77
frontend/src/lib/hooks/useTypeahead.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
85
frontend/src/routes/admin/tags/TagTreeNode.svelte
Normal file
85
frontend/src/routes/admin/tags/TagTreeNode.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
43
frontend/src/routes/admin/tags/[id]/TagAncestry.svelte
Normal file
43
frontend/src/routes/admin/tags/[id]/TagAncestry.svelte
Normal 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}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
111
frontend/src/routes/admin/tags/[id]/TagDeleteGuard.svelte
Normal file
111
frontend/src/routes/admin/tags/[id]/TagDeleteGuard.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
125
frontend/src/routes/admin/tags/[id]/TagMergeZone.svelte
Normal file
125
frontend/src/routes/admin/tags/[id]/TagMergeZone.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> ──── */
|
||||
|
||||
@@ -31,7 +31,8 @@ const emptyData = {
|
||||
tags: [],
|
||||
sort: 'DATE' as const,
|
||||
dir: 'desc' as const,
|
||||
tagQ: ''
|
||||
tagQ: '',
|
||||
tagOp: 'AND'
|
||||
},
|
||||
documents: [],
|
||||
total: 0,
|
||||
|
||||
Reference in New Issue
Block a user