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 io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -204,11 +205,15 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) String tagQ,
|
@RequestParam(required = false) String tagQ,
|
||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@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)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
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 ---
|
// --- TRAINING LABELS ---
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
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.model.Tag;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.TagService;
|
import org.raddatz.familienarchiv.service.TagService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@@ -31,8 +37,8 @@ public class TagController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_TAG)
|
@RequirePermission(Permission.ADMIN_TAG)
|
||||||
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
|
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) {
|
||||||
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
|
return ResponseEntity.ok(tagService.update(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -46,4 +52,22 @@ public class TagController {
|
|||||||
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
|
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
|
||||||
return tagService.search(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 */
|
/** A training run is already in progress. 409 */
|
||||||
TRAINING_ALREADY_RUNNING,
|
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 ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -20,4 +20,11 @@ public class Tag {
|
|||||||
@Column(unique = true, nullable = false)
|
@Column(unique = true, nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String name;
|
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.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
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);
|
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) -> {
|
return (root, query, cb) -> {
|
||||||
if (tags == null || tags.isEmpty())
|
if (tagIdSets == null || tagIdSets.isEmpty())
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
List<Predicate> predicates = new ArrayList<>();
|
if (!useOr) {
|
||||||
|
// AND mode: an empty set means the tag resolved to no IDs (doesn't exist) —
|
||||||
for (String tagName : tags) {
|
// no document can satisfy the condition, so return no results immediately.
|
||||||
if (!StringUtils.hasText(tagName)) continue;
|
boolean hasEmptySet = tagIdSets.stream().anyMatch(s -> s == null || s.isEmpty());
|
||||||
|
if (hasEmptySet) return cb.disjunction();
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]));
|
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
|
// Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche
|
||||||
public static Specification<Document> hasTagPartial(String tagQ) {
|
public static Specification<Document> hasTagPartial(String tagQ) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
|
|||||||
@@ -1,13 +1,126 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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> {
|
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);
|
Optional<Tag> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
List<Tag> findByNameContainingIgnoreCase(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.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
@@ -293,7 +294,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 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);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
@@ -302,12 +303,15 @@ public class DocumentService {
|
|||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
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> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||||
Specification<Document> spec = Specification.where(textSpec)
|
Specification<Document> spec = Specification.where(textSpec)
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(from, to))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(sender))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(receiver))
|
||||||
.and(hasTags(tags))
|
.and(hasTags(expandedTagSets, useOrLogic))
|
||||||
.and(hasTagPartial(tagQ))
|
.and(hasTagPartial(tagQ))
|
||||||
.and(hasStatus(status));
|
.and(hasStatus(status));
|
||||||
|
|
||||||
@@ -316,12 +320,12 @@ public class DocumentService {
|
|||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
List<Document> sorted = sortByFirstReceiver(results, dir);
|
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) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
List<Document> sorted = sortBySender(results, dir);
|
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
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
@@ -334,12 +338,12 @@ public class DocumentService {
|
|||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
|
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
Sort springSort = resolveSort(sort, dir);
|
Sort springSort = resolveSort(sort, dir);
|
||||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
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) {
|
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||||
@@ -430,8 +434,10 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Document getDocumentById(UUID id) {
|
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));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
tagService.resolveEffectiveColors(doc.getTags());
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsWithoutVersions() {
|
public List<Document> getDocumentsWithoutVersions() {
|
||||||
@@ -510,6 +516,12 @@ public class DocumentService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── 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) {
|
private static String stripExtension(String filename) {
|
||||||
if (filename == null) return null;
|
if (filename == null) return null;
|
||||||
int dot = filename.lastIndexOf('.');
|
int dot = filename.lastIndexOf('.');
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
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.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class TagService {
|
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;
|
private final TagRepository tagRepository;
|
||||||
|
|
||||||
public List<Tag> search(String query) {
|
public List<Tag> search(String query) {
|
||||||
@@ -24,7 +44,7 @@ public class TagService {
|
|||||||
|
|
||||||
public Tag getById(UUID id) {
|
public Tag getById(UUID id) {
|
||||||
return tagRepository.findById(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) {
|
public Tag findOrCreate(String name) {
|
||||||
@@ -34,9 +54,22 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Tag update(UUID id, String newName) {
|
public Tag update(UUID id, TagUpdateDTO dto) {
|
||||||
Tag tag = getById(id);
|
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);
|
return tagRepository.save(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,4 +77,152 @@ public class TagService {
|
|||||||
public void delete(UUID id) {
|
public void delete(UUID id) {
|
||||||
tagRepository.delete(getById(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
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -72,13 +72,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.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
|
@Test
|
||||||
@@ -105,7 +105,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -126,7 +126,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
|
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());
|
"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)));
|
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagTreeNodeDTO;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
@@ -19,10 +20,15 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
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.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.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
@WebMvcTest(TagController.class)
|
@WebMvcTest(TagController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -82,6 +88,107 @@ class TagControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.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} ────────────────────────────────────────────────
|
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.model.Document;
|
|||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
@@ -37,6 +38,9 @@ class DocumentRepositoryTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PersonRepository personRepository;
|
private PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TagRepository tagRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AnnotationRepository annotationRepository;
|
private AnnotationRepository annotationRepository;
|
||||||
|
|
||||||
@@ -345,6 +349,105 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(stats.getTranscriptionCount()).isEqualTo(0L);
|
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 ─────────────────────────────────────────────────────
|
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Document uploaded(String title) {
|
private Document uploaded(String title) {
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import org.springframework.context.annotation.Import;
|
|||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
||||||
@@ -156,47 +158,57 @@ class DocumentSpecificationsTest {
|
|||||||
// ─── hasTags ──────────────────────────────────────────────────────────────
|
// ─── hasTags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hasTags_returnsAllDocuments_whenTagListIsNull() {
|
void hasTags_returnsAllDocuments_whenTagSetListIsNull() {
|
||||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(null)));
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(null, false)));
|
||||||
assertThat(result).hasSize(3);
|
assertThat(result).hasSize(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hasTags_returnsAllDocuments_whenTagListIsEmpty() {
|
void hasTags_returnsAllDocuments_whenTagSetListIsEmpty() {
|
||||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of())));
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(), false)));
|
||||||
assertThat(result).hasSize(3);
|
assertThat(result).hasSize(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hasTags_filtersDocumentsByTag() {
|
void hasTags_and_filtersDocumentsByTag() {
|
||||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie"))));
|
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");
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hasTags_isCaseInsensitive() {
|
void hasTags_and_requiresAllTagsToBePresent() {
|
||||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("familie"))));
|
// briefEarly has "Familie" but not "Urlaub" — AND should return empty
|
||||||
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
Set<UUID> familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie"));
|
||||||
}
|
Set<UUID> urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub"));
|
||||||
|
|
||||||
@Test
|
|
||||||
void hasTags_requiresAllTagsToBePresent_andLogic() {
|
|
||||||
// briefEarly has "Familie" but not "Urlaub" — should be excluded
|
|
||||||
List<Document> result = documentRepository.findAll(
|
List<Document> result = documentRepository.findAll(
|
||||||
Specification.where(hasTags(List.of("Familie", "Urlaub"))));
|
Specification.where(hasTags(List.of(familieIds, urlaubIds), false)));
|
||||||
assertThat(result).isEmpty();
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hasTags_skipsEmptyTagNames() {
|
void hasTags_or_findsDocumentWithEitherTag() {
|
||||||
// An empty string in the tag list should be ignored
|
Set<UUID> familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie"));
|
||||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie"))));
|
Set<UUID> urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub"));
|
||||||
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
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
|
@Test
|
||||||
void hasTags_returnsEmpty_whenTagDoesNotExist() {
|
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();
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,8 +168,63 @@ class MigrationIntegrationTest {
|
|||||||
assertThat(rows).isEqualTo(1);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── 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() {
|
private UUID createDocument() {
|
||||||
Document doc = documentRepository.save(Document.builder()
|
Document doc = documentRepository.save(Document.builder()
|
||||||
.title("Testdokument")
|
.title("Testdokument")
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(newer, older));
|
.thenReturn(List.of(newer, older));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.documents()).hasSize(2);
|
assertThat(result.documents()).hasSize(2);
|
||||||
@@ -75,7 +75,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||||
@@ -94,7 +94,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
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)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(List.of());
|
.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));
|
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)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(List.of());
|
.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));
|
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));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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()).hasSize(2);
|
||||||
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1312,7 +1312,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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)
|
assertThat(result.documents()).extracting(Document::getTitle)
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1334,7 +1334,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.documents()).extracting(Document::getTitle)
|
assertThat(result.documents()).extracting(Document::getTitle)
|
||||||
@@ -1356,7 +1356,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
assertThat(result.matchData()).containsKey(docId);
|
||||||
SearchMatchData md = result.matchData().get(docId);
|
SearchMatchData md = result.matchData().get(docId);
|
||||||
@@ -1370,7 +1370,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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();
|
assertThat(result.matchData()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1389,7 +1389,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
SearchMatchData md = result.matchData().get(docId);
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
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.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@@ -31,9 +36,9 @@ class TagServiceTest {
|
|||||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> tagService.getById(id))
|
assertThatThrownBy(() -> tagService.getById(id))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
.isEqualTo(404);
|
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -89,20 +94,383 @@ class TagServiceTest {
|
|||||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||||
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
|
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");
|
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
|
@Test
|
||||||
void update_throwsNotFound_whenTagMissing() {
|
void update_throwsNotFound_whenTagMissing() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> tagService.update(id, "New"))
|
assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null)))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
.isEqualTo(404);
|
.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 ───────────────────────────────────────────────────────────────
|
// ─── delete ───────────────────────────────────────────────────────────────
|
||||||
@@ -124,8 +492,8 @@ class TagServiceTest {
|
|||||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> tagService.delete(id))
|
assertThatThrownBy(() -> tagService.delete(id))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
.isEqualTo(404);
|
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
"admin_tags_empty": "Keine Schlagworte vorhanden.",
|
"admin_tags_empty": "Keine Schlagworte vorhanden.",
|
||||||
"admin_tags_select_prompt": "Wähle ein Schlagwort aus der Liste.",
|
"admin_tags_select_prompt": "Wähle ein Schlagwort aus der Liste.",
|
||||||
"admin_tag_edit_heading": "Schlagwort: {name}",
|
"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_unsaved_warning": "Du hast ungespeicherte Änderungen – speichere oder verwerfe, bevor du wechselst.",
|
||||||
"admin_btn_collapse_list": "Liste einklappen",
|
"admin_btn_collapse_list": "Liste einklappen",
|
||||||
"admin_btn_expand_list": "Liste ausklappen",
|
"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_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||||
"error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.",
|
"error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.",
|
||||||
"error_training_already_running": "Es läuft bereits ein Trainings-Vorgang.",
|
"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_typewriter": "Schreibmaschine",
|
||||||
"ocr_script_type_handwriting_latin": "Handschrift (lateinisch)",
|
"ocr_script_type_handwriting_latin": "Handschrift (lateinisch)",
|
||||||
"ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)",
|
"ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)",
|
||||||
@@ -574,5 +576,40 @@
|
|||||||
"mission_control_ready_empty_cta": "Jetzt mitmachen",
|
"mission_control_ready_empty_cta": "Jetzt mitmachen",
|
||||||
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
|
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
|
||||||
"mission_control_blocks_progress": "{texted} / {total} Blöcke",
|
"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_empty": "No tags found.",
|
||||||
"admin_tags_select_prompt": "Select a tag from the list.",
|
"admin_tags_select_prompt": "Select a tag from the list.",
|
||||||
"admin_tag_edit_heading": "Tag: {name}",
|
"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_unsaved_warning": "You have unsaved changes — save or discard before switching.",
|
||||||
"admin_btn_collapse_list": "Collapse list",
|
"admin_btn_collapse_list": "Collapse list",
|
||||||
"admin_btn_expand_list": "Expand 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_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||||
"error_ocr_processing_failed": "OCR processing failed.",
|
"error_ocr_processing_failed": "OCR processing failed.",
|
||||||
"error_training_already_running": "A training run is already in progress.",
|
"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_typewriter": "Typewriter",
|
||||||
"ocr_script_type_handwriting_latin": "Handwriting (Latin)",
|
"ocr_script_type_handwriting_latin": "Handwriting (Latin)",
|
||||||
"ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)",
|
"ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)",
|
||||||
@@ -574,5 +576,40 @@
|
|||||||
"mission_control_ready_empty_cta": "Start contributing",
|
"mission_control_ready_empty_cta": "Start contributing",
|
||||||
"mission_control_weekly_pulse": "↑ +{count} this week",
|
"mission_control_weekly_pulse": "↑ +{count} this week",
|
||||||
"mission_control_blocks_progress": "{texted} / {total} blocks",
|
"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_empty": "No hay etiquetas.",
|
||||||
"admin_tags_select_prompt": "Selecciona una etiqueta de la lista.",
|
"admin_tags_select_prompt": "Selecciona una etiqueta de la lista.",
|
||||||
"admin_tag_edit_heading": "Etiqueta: {name}",
|
"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_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.",
|
||||||
"admin_btn_collapse_list": "Contraer lista",
|
"admin_btn_collapse_list": "Contraer lista",
|
||||||
"admin_btn_expand_list": "Expandir 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_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||||
"error_ocr_processing_failed": "El procesamiento OCR ha fallado.",
|
"error_ocr_processing_failed": "El procesamiento OCR ha fallado.",
|
||||||
"error_training_already_running": "Ya hay un proceso de entrenamiento en curso.",
|
"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_typewriter": "Máquina de escribir",
|
||||||
"ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)",
|
"ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)",
|
||||||
"ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)",
|
"ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)",
|
||||||
@@ -574,5 +576,40 @@
|
|||||||
"mission_control_ready_empty_cta": "Empezar a colaborar",
|
"mission_control_ready_empty_cta": "Empezar a colaborar",
|
||||||
"mission_control_weekly_pulse": "↑ +{count} esta semana",
|
"mission_control_weekly_pulse": "↑ +{count} esta semana",
|
||||||
"mission_control_blocks_progress": "{texted} / {total} bloques",
|
"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 type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,10 +51,23 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let results: Person[] = $state([]);
|
const typeahead = createTypeahead<Person>({
|
||||||
let showDropdown = $state(false);
|
fetchUrl: async (term) => {
|
||||||
let loading = $state(false);
|
const personId = restrictToCorrespondentsOf;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
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() {
|
function handleInput() {
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
@@ -61,69 +75,38 @@ function handleInput() {
|
|||||||
onchange?.('');
|
onchange?.('');
|
||||||
}
|
}
|
||||||
|
|
||||||
showDropdown = true;
|
const term = untrack(() => searchTerm);
|
||||||
clearTimeout(debounceTimer);
|
typeahead.setQuery(term);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
showDropdown = true;
|
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
loading = true;
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/persons/${personId}/correspondents`);
|
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) {
|
} catch (e) {
|
||||||
console.error('Suche fehlgeschlagen', e);
|
console.error('Suche fehlgeschlagen', e);
|
||||||
results = [];
|
typeahead.openWith([]);
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
} else {
|
||||||
|
typeahead.openWith(typeahead.results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPerson(person: Person) {
|
function selectPerson(person: Person) {
|
||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = person.displayName;
|
searchTerm = person.displayName;
|
||||||
showDropdown = false;
|
typeahead.close();
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for={name}
|
||||||
class={compact
|
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'}
|
: '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
|
<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"
|
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>
|
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person (person.id)}
|
{#each typeahead.results as person (person.id)}
|
||||||
<div
|
<div
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
|
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: Tag[];
|
||||||
allowCreation?: boolean;
|
allowCreation?: boolean;
|
||||||
onTextInput?: (text: string) => void;
|
onTextInput?: (text: string) => void;
|
||||||
}
|
}
|
||||||
@@ -12,10 +15,41 @@ interface Props {
|
|||||||
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
||||||
|
|
||||||
let inputVal = $state('');
|
let inputVal = $state('');
|
||||||
let suggestions: string[] = $state([]);
|
let suggestions: Tag[] = $state([]);
|
||||||
let activeIndex = $state(-1);
|
let activeIndex = $state(-1);
|
||||||
let showSuggestions = $state(false);
|
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) {
|
async function fetchSuggestions(query: string) {
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
@@ -24,10 +58,10 @@ async function fetchSuggestions(query: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data: Tag[] = await res.json();
|
||||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
|
||||||
const currentTags = untrack(() => tags);
|
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;
|
showSuggestions = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -35,11 +69,19 @@ async function fetchSuggestions(query: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(tag: string) {
|
function addTag(tag: Tag | string) {
|
||||||
const trimmed = tag.trim();
|
const newTag: Tag = typeof tag === 'string' ? { name: tag.trim() } : tag;
|
||||||
if (trimmed && !tags.includes(trimmed)) {
|
if (!newTag.name) return;
|
||||||
tags = [...tags, trimmed];
|
const currentTags = untrack(() => tags);
|
||||||
|
if (currentTags.some((t) => t.name === newTag.name)) {
|
||||||
|
inputVal = '';
|
||||||
|
suggestions = [];
|
||||||
|
showSuggestions = false;
|
||||||
|
activeIndex = -1;
|
||||||
|
onTextInput?.('');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
tags = [...tags, newTag];
|
||||||
inputVal = '';
|
inputVal = '';
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
@@ -54,8 +96,8 @@ function removeTag(index: number) {
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
|
||||||
addTag(suggestions[activeIndex]);
|
addTag(orderedSuggestions[activeIndex]);
|
||||||
} else if (allowCreation) {
|
} else if (allowCreation) {
|
||||||
addTag(inputVal);
|
addTag(inputVal);
|
||||||
}
|
}
|
||||||
@@ -63,10 +105,10 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
removeTag(tags.length - 1);
|
removeTag(tags.length - 1);
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
activeIndex = (activeIndex + 1) % orderedSuggestions.length;
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
activeIndex = (activeIndex - 1 + orderedSuggestions.length) % orderedSuggestions.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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"
|
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 -->
|
<!-- 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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeTag(i)}
|
onclick={() => removeTag(i)}
|
||||||
@@ -115,22 +165,22 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && suggestions.length > 0}
|
{#if showSuggestions && orderedSuggestions.length > 0}
|
||||||
<ul
|
<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"
|
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
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
||||||
? 'bg-muted font-bold text-ink'
|
? 'bg-muted font-bold text-ink'
|
||||||
: 'text-ink-2'}"
|
: 'text-ink-2'} {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}"
|
||||||
onclick={() => addTag(suggestion)}
|
onclick={() => addTag(suggestion)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion.name}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ describe('TagInput – rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders existing tags as chips', async () => {
|
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('Familie')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides input placeholder once tags exist', async () => {
|
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');
|
const input = page.getByRole('textbox');
|
||||||
await expect.element(input).toHaveAttribute('placeholder', '');
|
await expect.element(input).toHaveAttribute('placeholder', '');
|
||||||
});
|
});
|
||||||
@@ -64,6 +64,18 @@ describe('TagInput – rendering', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: false });
|
render(TagInput, { tags: [], allowCreation: false });
|
||||||
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
||||||
@@ -90,7 +102,7 @@ describe('TagInput – adding tags', () => {
|
|||||||
|
|
||||||
it('does not add a duplicate tag', async () => {
|
it('does not add a duplicate tag', async () => {
|
||||||
mockFetchEmpty();
|
mockFetchEmpty();
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Familie');
|
await input.fill('Familie');
|
||||||
await userEvent.keyboard('{Enter}');
|
await userEvent.keyboard('{Enter}');
|
||||||
@@ -112,7 +124,7 @@ describe('TagInput – adding tags', () => {
|
|||||||
|
|
||||||
describe('TagInput – removing tags', () => {
|
describe('TagInput – removing tags', () => {
|
||||||
it('removes a chip when its × button is clicked', async () => {
|
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"
|
// The × buttons have aria-label="Schlagwort entfernen"
|
||||||
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
@@ -122,7 +134,7 @@ describe('TagInput – removing tags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
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();
|
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
||||||
await userEvent.keyboard('{Backspace}');
|
await userEvent.keyboard('{Backspace}');
|
||||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
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 () => {
|
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');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('x');
|
await input.fill('x');
|
||||||
await userEvent.keyboard('{Backspace}');
|
await userEvent.keyboard('{Backspace}');
|
||||||
@@ -163,7 +175,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
|
|
||||||
it('filters already-selected tags out of suggestions', async () => {
|
it('filters already-selected tags out of suggestions', async () => {
|
||||||
mockFetchWithTags(['Familie', 'Freunde']);
|
mockFetchWithTags(['Familie', 'Freunde']);
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fr');
|
await input.fill('Fr');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
@@ -207,6 +219,30 @@ describe('TagInput – autocomplete', () => {
|
|||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
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 ──────────────────────────────────────────────────────
|
// ─── 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">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
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';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags = $bindable<string[]>([]),
|
tags = $bindable<Tag[]>([]),
|
||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDocumentLocation = '',
|
initialDocumentLocation = '',
|
||||||
initialSummary = '',
|
initialSummary = '',
|
||||||
@@ -12,7 +12,7 @@ let {
|
|||||||
suggestedTitle = '',
|
suggestedTitle = '',
|
||||||
hideTitle = false
|
hideTitle = false
|
||||||
}: {
|
}: {
|
||||||
tags?: string[];
|
tags?: Tag[];
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDocumentLocation?: string;
|
initialDocumentLocation?: string;
|
||||||
initialSummary?: string;
|
initialSummary?: string;
|
||||||
@@ -74,7 +74,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
|||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||||
<TagInput bind:tags={tags} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export type ErrorCode =
|
|||||||
| 'OCR_DOCUMENT_NOT_UPLOADED'
|
| 'OCR_DOCUMENT_NOT_UPLOADED'
|
||||||
| 'OCR_PROCESSING_FAILED'
|
| 'OCR_PROCESSING_FAILED'
|
||||||
| 'TRAINING_ALREADY_RUNNING'
|
| 'TRAINING_ALREADY_RUNNING'
|
||||||
|
| 'INVALID_TAG_COLOR'
|
||||||
|
| 'TAG_CYCLE_DETECTED'
|
||||||
|
| 'TAG_NOT_FOUND'
|
||||||
|
| 'TAG_MERGE_SELF'
|
||||||
|
| 'TAG_MERGE_INVALID_TARGET'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
@@ -100,6 +105,16 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_ocr_processing_failed();
|
return m.error_ocr_processing_failed();
|
||||||
case 'TRAINING_ALREADY_RUNNING':
|
case 'TRAINING_ALREADY_RUNNING':
|
||||||
return m.error_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':
|
case 'UNAUTHORIZED':
|
||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
|
|||||||
@@ -180,6 +180,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/persons": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -740,6 +756,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1124,6 +1156,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/persons/{id}/aliases/{aliasId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1190,10 +1238,19 @@ export interface components {
|
|||||||
notifyOnReply?: boolean;
|
notifyOnReply?: boolean;
|
||||||
notifyOnMention?: boolean;
|
notifyOnMention?: boolean;
|
||||||
};
|
};
|
||||||
|
TagUpdateDTO: {
|
||||||
|
name?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
parentId?: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
Tag: {
|
Tag: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
parentId?: string;
|
||||||
|
color?: string;
|
||||||
};
|
};
|
||||||
PersonUpdateDTO: {
|
PersonUpdateDTO: {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -1315,6 +1372,10 @@ export interface components {
|
|||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
};
|
};
|
||||||
|
MergeTagDTO: {
|
||||||
|
/** Format: uuid */
|
||||||
|
targetId?: string;
|
||||||
|
};
|
||||||
PersonNameAliasDTO: {
|
PersonNameAliasDTO: {
|
||||||
lastName: string;
|
lastName: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -1533,6 +1594,20 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
reviewedBlockCount: number;
|
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: {
|
StatsDTO: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
totalPersons?: number;
|
totalPersons?: number;
|
||||||
@@ -1544,17 +1619,17 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id?: string;
|
id?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
personType?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
documentCount?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
/** Format: int64 */
|
|
||||||
documentCount?: number;
|
|
||||||
personType?: string;
|
|
||||||
};
|
};
|
||||||
TrainingInfoResponse: {
|
TrainingInfoResponse: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -1875,9 +1950,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": components["schemas"]["TagUpdateDTO"];
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
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: {
|
getPersons: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
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: {
|
getStats: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3668,6 +3787,8 @@ export interface operations {
|
|||||||
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
|
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
|
||||||
/** @description Sort direction: ASC or DESC */
|
/** @description Sort direction: ASC or DESC */
|
||||||
dir?: string;
|
dir?: string;
|
||||||
|
/** @description Tag operator: AND (default) or OR */
|
||||||
|
tagOp?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: 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: {
|
removeAlias: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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)
|
? (rawDir as ValidDir)
|
||||||
: 'desc';
|
: 'desc';
|
||||||
const tagQ = url.searchParams.get('tagQ') || '';
|
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;
|
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
||||||
|
|
||||||
@@ -47,7 +48,8 @@ export async function load({ url, fetch }) {
|
|||||||
senderId: senderId || undefined,
|
senderId: senderId || undefined,
|
||||||
receiverId: receiverId || undefined,
|
receiverId: receiverId || undefined,
|
||||||
tag: tags.length ? tags : undefined,
|
tag: tags.length ? tags : undefined,
|
||||||
tagQ: tagQ || undefined,
|
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||||
|
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||||
sort,
|
sort,
|
||||||
dir: dir || undefined
|
dir: dir || undefined
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,6 @@ export async function load({ url, fetch }) {
|
|||||||
if (docsResult && docsResult.response.status === 401) {
|
if (docsResult && docsResult.response.status === 401) {
|
||||||
throw redirect(302, '/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResult = docsResult?.data as {
|
const searchResult = docsResult?.data as {
|
||||||
documents?: Document[];
|
documents?: Document[];
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -147,7 +148,7 @@ export async function load({ url, fetch }) {
|
|||||||
senderName: senderObj?.displayName ?? '',
|
senderName: senderObj?.displayName ?? '',
|
||||||
receiverName: receiverObj?.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
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -166,7 +167,7 @@ export async function load({ url, fetch }) {
|
|||||||
readyDocs: [],
|
readyDocs: [],
|
||||||
weeklyStats: null,
|
weeklyStats: null,
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
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
|
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 to = $state(untrack(() => data.filters?.to || ''));
|
||||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
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 sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
||||||
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
||||||
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
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) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
(filters?.tags?.length ?? 0) > 0 ||
|
(filters?.tags?.length ?? 0) > 0 ||
|
||||||
@@ -43,10 +48,11 @@ function triggerSearch() {
|
|||||||
if (to) params.set('to', to);
|
if (to) params.set('to', to);
|
||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
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 (sort) params.set('sort', sort);
|
||||||
if (dir) params.set('dir', dir);
|
if (dir) params.set('dir', dir);
|
||||||
if (tagQ) params.set('tagQ', tagQ);
|
if (tagQ) params.set('tagQ', tagQ);
|
||||||
|
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
||||||
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +61,15 @@ function handleTextSearch() {
|
|||||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleImmediateSearch() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger search when tags change
|
// Trigger search when tags change
|
||||||
let prevTagStr = untrack(() => tagNames.join(','));
|
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const cur = tagNames.join(',');
|
const cur = tagNames.map((t) => t.name).join(',');
|
||||||
if (cur !== prevTagStr) {
|
if (cur !== prevTagStr) {
|
||||||
prevTagStr = cur;
|
prevTagStr = cur;
|
||||||
triggerSearch();
|
triggerSearch();
|
||||||
@@ -73,10 +84,11 @@ $effect(() => {
|
|||||||
to = data.filters?.to || '';
|
to = data.filters?.to || '';
|
||||||
senderId = data.filters?.senderId || '';
|
senderId = data.filters?.senderId || '';
|
||||||
receiverId = data.filters?.receiverId || '';
|
receiverId = data.filters?.receiverId || '';
|
||||||
tagNames = data.filters?.tags || [];
|
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
|
||||||
sort = data.filters?.sort || 'DATE';
|
sort = data.filters?.sort || 'DATE';
|
||||||
dir = data.filters?.dir || 'desc';
|
dir = data.filters?.dir || 'desc';
|
||||||
tagQ = data.filters?.tagQ || '';
|
tagQ = data.filters?.tagQ || '';
|
||||||
|
tagOperator = (data.filters?.tagOp as 'AND' | 'OR') || 'AND';
|
||||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,10 +114,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
bind:sort={sort}
|
bind:sort={sort}
|
||||||
bind:dir={dir}
|
bind:dir={dir}
|
||||||
bind:tagQ={tagQ}
|
bind:tagQ={tagQ}
|
||||||
|
bind:tagOperator={tagOperator}
|
||||||
initialSenderName={data.initialValues?.senderName}
|
initialSenderName={data.initialValues?.senderName}
|
||||||
initialReceiverName={data.initialValues?.receiverName}
|
initialReceiverName={data.initialValues?.receiverName}
|
||||||
isLoading={navigating.to !== null}
|
isLoading={navigating.to !== null}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
|
onSearchImmediate={handleImmediateSearch}
|
||||||
onfocus={() => (qFocused = true)}
|
onfocus={() => (qFocused = true)}
|
||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ let {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
} | null;
|
} | null;
|
||||||
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
|
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;
|
canWrite: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
@@ -224,13 +224,22 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
|||||||
{#each doc.tags as tag (tag.id)}
|
{#each doc.tags as tag (tag.id)}
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onclick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goto(`/?tag=${encodeURIComponent(tag.name)}`);
|
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)}
|
{#if matchedTagIds.has(tag.id)}
|
||||||
<span data-testid="tag-match">{tag.name}</span>
|
<span data-testid="tag-match">{tag.name}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -342,6 +342,26 @@ describe('DocumentList – match snippets and highlights', () => {
|
|||||||
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
|
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 () => {
|
it('visually marks matched tag when its id is in matchedTagIds', async () => {
|
||||||
const doc = makeDoc({
|
const doc = makeDoc({
|
||||||
id: 'doc1',
|
id: 'doc1',
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ let {
|
|||||||
to = $bindable(''),
|
to = $bindable(''),
|
||||||
senderId = $bindable(''),
|
senderId = $bindable(''),
|
||||||
receiverId = $bindable(''),
|
receiverId = $bindable(''),
|
||||||
tagNames = $bindable<string[]>([]),
|
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||||
tagQ = $bindable(''),
|
tagQ = $bindable(''),
|
||||||
|
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||||
sort = $bindable('DATE'),
|
sort = $bindable('DATE'),
|
||||||
dir = $bindable('desc'),
|
dir = $bindable('desc'),
|
||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
@@ -21,6 +22,7 @@ let {
|
|||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onSearchImmediate,
|
||||||
onfocus,
|
onfocus,
|
||||||
onblur
|
onblur
|
||||||
}: {
|
}: {
|
||||||
@@ -29,8 +31,9 @@ let {
|
|||||||
to?: string;
|
to?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tagNames?: string[];
|
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||||
tagQ?: string;
|
tagQ?: string;
|
||||||
|
tagOperator?: 'AND' | 'OR';
|
||||||
sort?: string;
|
sort?: string;
|
||||||
dir?: string;
|
dir?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
@@ -38,6 +41,7 @@ let {
|
|||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
|
onSearchImmediate?: () => void;
|
||||||
onfocus?: () => void;
|
onfocus?: () => void;
|
||||||
onblur?: () => void;
|
onblur?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -153,6 +157,34 @@ $effect(() => {
|
|||||||
onSearch();
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
onSearch: vi.fn()
|
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', () => {
|
describe('SearchFilterBar – tagQ live filter', () => {
|
||||||
it('calls onSearch when tag text changes in TagInput', async () => {
|
it('calls onSearch when tag text changes in TagInput', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -1,8 +1,34 @@
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import type { LayoutServerLoad } from './$types';
|
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 }) => {
|
export const load: LayoutServerLoad = async ({ fetch }) => {
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.GET('/api/tags');
|
const result = await api.GET('/api/tags/tree');
|
||||||
return { tags: result.data ?? [] };
|
const tree = result.data ?? [];
|
||||||
|
return { tree, tags: flattenTree(tree) };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const isAtListRoot = $derived(page.url.pathname === '/admin/tags');
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
|
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
|
||||||
<TagsListPanel tags={data.tags} />
|
<TagsListPanel tree={data.tree} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">
|
<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">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import TagTreeNode from './TagTreeNode.svelte';
|
||||||
|
|
||||||
type Tag = {
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags,
|
tree,
|
||||||
autocollapse = false
|
autocollapse = false
|
||||||
}: {
|
}: {
|
||||||
tags: Tag[];
|
tree: TagTreeNodeDTO[];
|
||||||
autocollapse?: boolean;
|
autocollapse?: boolean;
|
||||||
} = $props();
|
} = $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(
|
let manualCollapse = $state(
|
||||||
typeof localStorage !== 'undefined' &&
|
typeof localStorage !== 'undefined' &&
|
||||||
localStorage.getItem('admin_tags_list_collapsed') === 'true'
|
localStorage.getItem('admin_tags_list_collapsed') === 'true'
|
||||||
@@ -44,13 +68,11 @@ $effect(() => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
|
||||||
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
|
|
||||||
>
|
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="flex items-center justify-between border-b border-line px-3 py-2">
|
<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">
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_tags_list_title()}
|
{m.admin_tag_tree_label()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => (manualCollapse = true)}
|
onclick={() => (manualCollapse = true)}
|
||||||
@@ -61,25 +83,18 @@ $effect(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable tag list -->
|
<!-- Scrollable tag tree -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<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">
|
<p class="px-4 py-6 text-center text-xs text-ink-3">
|
||||||
{m.admin_tags_empty()}
|
{m.admin_tags_empty()}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each tags as tag (tag.id)}
|
<ul role="tree" aria-label={m.admin_tag_tree_label()}>
|
||||||
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
|
{#each tree as node (node.id)}
|
||||||
<a
|
<TagTreeNode node={node} depth={0} collapseMap={collapseMap} />
|
||||||
href="/admin/tags/{tag.id}"
|
{/each}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
</ul>
|
||||||
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}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { PageServerLoad, Actions } from './$types';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
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 { tags } = await parent();
|
||||||
const tag = tags.find((t: { id: string }) => t.id === params.id);
|
const tag = tags.find((t: { id: string }) => t.id === params.id);
|
||||||
if (!tag) throw error(404, getErrorMessage('TAG_NOT_FOUND'));
|
if (!tag) throw error(404, getErrorMessage('TAG_NOT_FOUND'));
|
||||||
return { tag };
|
return { tag, mergeSuccess: url.searchParams.has('merged') };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -15,9 +15,13 @@ export const actions: Actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const api = createApiClient(fetch);
|
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}', {
|
const result = await api.PUT('/api/tags/{id}', {
|
||||||
params: { path: { id: params.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) {
|
if (!result.response.ok) {
|
||||||
@@ -28,10 +32,14 @@ export const actions: Actions = {
|
|||||||
return { success: true };
|
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 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) {
|
if (!result.response.ok) {
|
||||||
@@ -39,6 +47,28 @@ export const actions: Actions = {
|
|||||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
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');
|
throw redirect(303, '/admin/tags');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,73 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.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 { data, form } = $props();
|
||||||
|
|
||||||
let deleteConfirmName = $state('');
|
|
||||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
|
||||||
|
|
||||||
const unsaved = createUnsavedWarning();
|
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(() => {
|
$effect(() => {
|
||||||
if (form?.success) unsaved.clearOnSuccess();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<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">
|
<div class="flex items-center border-b border-line px-5 py-3">
|
||||||
<a
|
<a
|
||||||
href="/admin/tags"
|
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"
|
class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -41,9 +88,21 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<!-- TagAncestry breadcrumb -->
|
||||||
|
<TagAncestry tag={data.tag} allTags={data.tags} />
|
||||||
|
|
||||||
{#if unsaved.showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
{/if}
|
{/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}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
{m.admin_tag_updated()}
|
{m.admin_tag_updated()}
|
||||||
@@ -60,11 +119,12 @@ $effect(() => {
|
|||||||
id="edit-tag-form"
|
id="edit-tag-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance={() => async ({ update }) => { await update({ reset: false }); }}
|
||||||
oninput={unsaved.markDirty}
|
oninput={unsaved.markDirty}
|
||||||
class="mb-5"
|
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">
|
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_col_name()}
|
{m.admin_col_name()}
|
||||||
</h3>
|
</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"
|
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>
|
</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>
|
</form>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Children preview -->
|
||||||
<div
|
<TagChildrenPreview tag={data.tag} allTags={data.tags} />
|
||||||
class="rounded-sm border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30"
|
|
||||||
>
|
<!-- Merge zone -->
|
||||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-red-700 uppercase dark:text-red-400">
|
<TagMergeZone tag={data.tag} allTags={data.tags} form={form} />
|
||||||
{m.btn_delete()}
|
|
||||||
</h3>
|
<!-- Delete guard -->
|
||||||
<p class="mb-3 text-xs text-red-700 dark:text-red-400">
|
<TagDeleteGuard tag={data.tag} allTags={data.tags} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Docked footer -->
|
<!-- Footer -->
|
||||||
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
|
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
|
||||||
<a
|
<a
|
||||||
href="/admin/tags"
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { actions } from './+page.server';
|
import { actions, load } from './+page.server';
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
PUT: vi.fn(),
|
PUT: vi.fn(),
|
||||||
|
POST: vi.fn(),
|
||||||
DELETE: vi.fn()
|
DELETE: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,6 +13,30 @@ vi.mock('$lib/api.server', () => ({
|
|||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
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 ─────────────────────────────────────────────────────────────
|
// ─── update action ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('tags/[id] — 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 ─────────────────────────────────────────────────────────────
|
// ─── delete action ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('tags/[id] — delete action', () => {
|
describe('tags/[id] — delete action (single)', () => {
|
||||||
it('redirects to /admin/tags on successful delete', async () => {
|
it('calls DELETE /api/tags/{id} when deleteMode=single', async () => {
|
||||||
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
|
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;
|
let redirectUrl: string | null = null;
|
||||||
try {
|
try {
|
||||||
await actions.delete({
|
await actions.delete({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
|
request: { formData: async () => formData },
|
||||||
fetch
|
fetch
|
||||||
} as never);
|
} as never);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const r = e as { location?: string; status?: number };
|
const r = e as { location?: string };
|
||||||
redirectUrl = r.location ?? null;
|
redirectUrl = r.location ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(redirectUrl).toBe('/admin/tags');
|
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 () => {
|
it('returns fail with error message when delete API responds not ok', async () => {
|
||||||
mockApi.DELETE.mockResolvedValue({
|
mockApi.DELETE.mockResolvedValue({
|
||||||
@@ -75,8 +196,12 @@ describe('tags/[id] — delete action', () => {
|
|||||||
error: { code: 'FORBIDDEN' }
|
error: { code: 'FORBIDDEN' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('deleteMode', 'single');
|
||||||
|
|
||||||
const result = await actions.delete({
|
const result = await actions.delete({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
|
request: { formData: async () => formData },
|
||||||
fetch
|
fetch
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
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';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
const baseTag = { id: 't1', name: 'Familie' };
|
const baseTag = { id: 't1', name: 'Familie', documentCount: 0 };
|
||||||
const baseData = { tag: baseTag };
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -17,28 +45,22 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
describe('Admin edit tag page – rendering', () => {
|
describe('Admin edit tag page – rendering', () => {
|
||||||
it('renders the heading with tag name', async () => {
|
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();
|
await expect.element(page.getByText(/Schlagwort: Familie/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pre-fills the name input', async () => {
|
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"]');
|
const input = document.querySelector<HTMLInputElement>('input[name="name"]');
|
||||||
expect(input?.value).toBe('Familie');
|
expect(input?.value).toBe('Familie');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the cancel link pointing to /admin/tags', async () => {
|
it('renders the cancel link pointing to /admin/tags', async () => {
|
||||||
render(Page, { data: baseData, form: null });
|
renderPage({ data: baseData, form: null });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /Abbrechen/i }))
|
.element(page.getByRole('link', { name: /Abbrechen/i }))
|
||||||
.toHaveAttribute('href', '/admin/tags');
|
.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 ────────────────────────────────────────────────────
|
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
|
||||||
@@ -47,12 +69,12 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
|||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
it('does not show unsaved warning initially', async () => {
|
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();
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels navigation and shows warning when rename form is dirty', async () => {
|
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];
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
document
|
document
|
||||||
@@ -67,7 +89,7 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not cancel navigation when form is clean', async () => {
|
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 [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
const cancel = vi.fn();
|
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 () => {
|
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];
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
document
|
document
|
||||||
@@ -91,3 +113,94 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
|||||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2');
|
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';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
function mockApi(tags: unknown[]) {
|
function mockTreeApi(tree: unknown[]) {
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
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>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
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', () => {
|
describe('admin/tags layout load', () => {
|
||||||
it('returns the tags list', async () => {
|
it('returns the tree list', async () => {
|
||||||
mockApi([
|
mockTreeApi(sampleTree);
|
||||||
{ id: 't1', name: 'Familie' },
|
|
||||||
{ id: 't2', name: 'Urlaub' }
|
|
||||||
]);
|
|
||||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
expect(result.tags).toHaveLength(2);
|
expect(result.tree).toHaveLength(2);
|
||||||
expect(result.tags[0].name).toBe('Familie');
|
expect(result.tree[0].name).toBe('Familie');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty array when the API returns nothing', async () => {
|
it('returns an empty tree when the API returns nothing', async () => {
|
||||||
mockApi([]);
|
mockTreeApi([]);
|
||||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
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: [] });
|
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true }, data: [] });
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const tags = [
|
const tree = [
|
||||||
{ id: 't1', name: 'Familie' },
|
{
|
||||||
{ id: 't2', name: 'Urlaub' },
|
id: 't1',
|
||||||
{ id: 't3', name: 'Schule' }
|
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', () => {
|
describe('TagsListPanel — header', () => {
|
||||||
it('renders the panel title', async () => {
|
it('renders the panel title', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect.element(page.getByText(/Alle Schlagworte/i)).toBeInTheDocument();
|
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', () => {
|
describe('TagsListPanel — tag items', () => {
|
||||||
it('renders each tag name', async () => {
|
it('renders each root tag name as a link', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('each tag links to /admin/tags/[id]', async () => {
|
it('each tag links to /admin/tags/[id]', async () => {
|
||||||
const { container } = render(TagsListPanel, { tags });
|
const { container } = render(TagsListPanel, { tree });
|
||||||
const links = container.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/tags/t"]');
|
const link = container.querySelector<HTMLAnchorElement>('a[href="/admin/tags/t1"]');
|
||||||
expect(links.length).toBe(3);
|
expect(link).toBeTruthy();
|
||||||
expect(links[0].getAttribute('href')).toBe('/admin/tags/t1');
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('TagsListPanel — active state', () => {
|
||||||
it('marks the active tag link with aria-current=page', async () => {
|
it('marks the active tag link with aria-current=page', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /familie/i }))
|
.element(page.getByRole('link', { name: /familie/i }))
|
||||||
.toHaveAttribute('aria-current', 'page');
|
.toHaveAttribute('aria-current', 'page');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not mark inactive tag links with aria-current', async () => {
|
it('does not mark inactive tag links with aria-current', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /urlaub/i }))
|
.element(page.getByRole('link', { name: /urlaub/i }))
|
||||||
.not.toHaveAttribute('aria-current');
|
.not.toHaveAttribute('aria-current');
|
||||||
@@ -54,26 +107,52 @@ describe('TagsListPanel — active state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TagsListPanel — empty state', () => {
|
describe('TagsListPanel — empty state', () => {
|
||||||
it('shows empty state when tags array is empty', async () => {
|
it('shows empty state when tree is empty', async () => {
|
||||||
render(TagsListPanel, { tags: [] });
|
render(TagsListPanel, { tree: [] });
|
||||||
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
|
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', () => {
|
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 () => {
|
it('renders a collapse button with aria-label', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste einklappen/i }))
|
.element(page.getByRole('button', { name: /Liste einklappen/i }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking collapse shows the expand handle', async () => {
|
it('clicking collapse shows the expand handle', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await page.getByRole('button', { name: /Liste einklappen/i }).click();
|
await page.getByRole('button', { name: /Liste einklappen/i }).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
.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 () => {
|
it('autocollapse prop starts the panel in collapsed state', async () => {
|
||||||
render(TagsListPanel, { tags, autocollapse: true });
|
render(TagsListPanel, { tree, autocollapse: true });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists collapse state using the tags-specific localStorage key', async () => {
|
it('persists collapse state using the tags-specific localStorage key', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
@@ -97,3 +176,29 @@ describe('TagsListPanel — collapse toggle', () => {
|
|||||||
setSpy.mockRestore();
|
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 { data, form } = $props();
|
||||||
|
|
||||||
let { document: doc } = untrack(() => data);
|
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 senderId = $state(doc.sender?.id ?? '');
|
||||||
let selectedReceivers = $state(doc.receivers ?? []);
|
let selectedReceivers = $state(doc.receivers ?? []);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { type FilenameParseResult } from '$lib/utils/filename';
|
|||||||
|
|
||||||
let { data, form } = $props();
|
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 senderId = $state(untrack(() => data.initialSenderId));
|
||||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||||
$state(untrack(() => data.initialReceivers));
|
$state(untrack(() => data.initialReceivers));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ $effect(() => {
|
|||||||
onDestroy(() => fileLoader.destroy());
|
onDestroy(() => fileLoader.destroy());
|
||||||
|
|
||||||
// Form state
|
// 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 senderId = $state(untrack(() => doc.sender?.id ?? ''));
|
||||||
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
--color-danger: var(--c-danger);
|
--color-danger: var(--c-danger);
|
||||||
--color-danger-fg: var(--c-danger-fg);
|
--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) */
|
/* Static brand tokens (not themed) */
|
||||||
--color-brand-navy: var(--palette-navy);
|
--color-brand-navy: var(--palette-navy);
|
||||||
--color-brand-mint: var(--palette-mint);
|
--color-brand-mint: var(--palette-mint);
|
||||||
@@ -115,6 +119,18 @@
|
|||||||
--c-danger: #c0392b;
|
--c-danger: #c0392b;
|
||||||
--c-danger-fg: #ffffff;
|
--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) */
|
/* PersonType badge — institution (navy-tinted blue) */
|
||||||
--c-badge-institution-bg: #e8eff7;
|
--c-badge-institution-bg: #e8eff7;
|
||||||
--c-badge-institution-text: #1a4971;
|
--c-badge-institution-text: #1a4971;
|
||||||
@@ -183,6 +199,18 @@
|
|||||||
/* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */
|
/* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */
|
||||||
--c-danger: #e55347;
|
--c-danger: #e55347;
|
||||||
--c-danger-fg: #ffffff;
|
--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 ✓) */
|
/* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */
|
||||||
--c-danger: #e55347;
|
--c-danger: #e55347;
|
||||||
--c-danger-fg: #ffffff;
|
--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> ──── */
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const emptyData = {
|
|||||||
tags: [],
|
tags: [],
|
||||||
sort: 'DATE' as const,
|
sort: 'DATE' as const,
|
||||||
dir: 'desc' as const,
|
dir: 'desc' as const,
|
||||||
tagQ: ''
|
tagQ: '',
|
||||||
|
tagOp: 'AND'
|
||||||
},
|
},
|
||||||
documents: [],
|
documents: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user