feat: dedicated /documents search & browse page #282
@@ -106,4 +106,38 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
ORDER BY a.document_id, MIN(a.happened_at)
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
ranked.document_id AS documentId,
|
||||
ranked.actorInitials AS actorInitials,
|
||||
ranked.actorColor AS actorColor,
|
||||
ranked.actorName AS actorName
|
||||
FROM (
|
||||
SELECT
|
||||
a.document_id,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY a.document_id
|
||||
ORDER BY MAX(a.happened_at) DESC
|
||||
) AS rn
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
|
||||
AND a.document_id IN :documentIds
|
||||
AND a.actor_id IS NOT NULL
|
||||
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
|
||||
) ranked
|
||||
WHERE ranked.rn <= 4
|
||||
ORDER BY ranked.document_id, ranked.rn
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,15 @@ public class AuditLogQueryService {
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
|
||||
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
|
||||
}
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||
}
|
||||
|
||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DocumentSearchItem(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Document document,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int completionPercentage,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<ActivityActorDTO> contributors
|
||||
) {}
|
||||
@@ -1,35 +1,16 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<Document> documents,
|
||||
List<DocumentSearchItem> items,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long total,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Map<UUID, SearchMatchData> matchData
|
||||
long total
|
||||
) {
|
||||
/**
|
||||
* Creates a fully-enriched result from documents and their match overlay data.
|
||||
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
|
||||
* the frontend treats a missing entry as "no match data".
|
||||
*/
|
||||
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
|
||||
return new DocumentSearchResult(documents, documents.size(), matchData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result without match data — used for filter-only searches (no text query).
|
||||
* No pagination yet — the full matched set is always returned.
|
||||
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||
*/
|
||||
public static DocumentSearchResult of(List<Document> documents) {
|
||||
return withMatchData(documents, Map.of());
|
||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||
return new DocumentSearchResult(items, items.size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CompletionStatsRow {
|
||||
UUID getDocumentId();
|
||||
int getCompletionPercentage();
|
||||
}
|
||||
@@ -5,12 +5,24 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
b.document_id AS documentId,
|
||||
ROUND(COUNT(*) FILTER (WHERE b.reviewed = true) * 100.0 / COUNT(*))::int AS completionPercentage
|
||||
FROM transcription_blocks b
|
||||
WHERE b.document_id IN :documentIds
|
||||
GROUP BY b.document_id
|
||||
""", nativeQuery = true)
|
||||
List<CompletionStatsRow> findCompletionStatsForDocuments(
|
||||
@Param("documentIds") Collection<UUID> documentIds);
|
||||
|
||||
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
|
||||
|
||||
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
|
||||
@@ -3,8 +3,11 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -59,6 +62,8 @@ public class DocumentService {
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final AnnotationService annotationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -344,7 +349,7 @@ public class DocumentService {
|
||||
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
@@ -363,13 +368,11 @@ public class DocumentService {
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortByFirstReceiver(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortBySender(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sortBySender(results, dir), text);
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -382,12 +385,34 @@ public class DocumentService {
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sorted, text);
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
|
||||
return buildResult(results, text);
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||
)).toList();
|
||||
|
||||
return DocumentSearchResult.of(items);
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||
}
|
||||
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranscriptionBlockQueryService {
|
||||
|
||||
private final TranscriptionBlockRepository blockRepository;
|
||||
|
||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, Integer> result = new HashMap<>();
|
||||
for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) {
|
||||
result.put(row.getDocumentId(), row.getCompletionPercentage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
|
||||
ON transcription_blocks (document_id, reviewed);
|
||||
@@ -25,10 +25,12 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -115,12 +117,12 @@ class DocumentControllerTest {
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0))
|
||||
.andExpect(jsonPath("$.documents").isArray());
|
||||
.andExpect(jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseBodyContainsMatchDataKey() throws Exception {
|
||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
@@ -128,15 +130,15 @@ class DocumentControllerTest {
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.matchData").isMap())
|
||||
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
|
||||
.andExpect(jsonPath("$.items").isArray())
|
||||
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.audit.ContributorRow;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class AuditLogQueryRepositoryContributorsTest {
|
||||
|
||||
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001");
|
||||
static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002");
|
||||
static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003");
|
||||
static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004");
|
||||
static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005");
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findRecentContributors_returns_contributor_with_initials_and_color() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
|
||||
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')"
|
||||
})
|
||||
void findRecentContributors_limits_to_4_most_recent() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).hasSize(4);
|
||||
// Most recent first: E, D, C, B (A is 5th, excluded)
|
||||
assertThat(rows.get(0).getActorInitials()).isEqualTo("EK");
|
||||
assertThat(rows.get(1).getActorInitials()).isEqualTo("DO");
|
||||
assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ");
|
||||
assertThat(rows.get(3).getActorInitials()).isEqualTo("BW");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')"
|
||||
})
|
||||
void findRecentContributors_returns_empty_when_no_audit_entries() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
// Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findRecentContributors_excludes_entries_from_deleted_users() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -2,59 +2,52 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentSearchResultTest {
|
||||
|
||||
private Document doc(UUID id) {
|
||||
return Document.builder()
|
||||
.id(id)
|
||||
private DocumentSearchItem item(UUID docId) {
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_total_equals_list_size() {
|
||||
void of_total_equals_list_size() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
|
||||
assertThat(result.total()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_exposes_items_with_completion_and_contributors() {
|
||||
UUID id = UUID.randomUUID();
|
||||
List<Document> docs = List.of(doc(id));
|
||||
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
|
||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
||||
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).completionPercentage()).isEqualTo(75);
|
||||
assertThat(result.items().get(0).contributors()).containsExactly(actor);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_exposes_match_data_map() {
|
||||
UUID id = UUID.randomUUID();
|
||||
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
|
||||
|
||||
assertThat(result.matchData()).containsKey(id);
|
||||
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_factory_returns_empty_match_data() {
|
||||
UUID id = UUID.randomUUID();
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
|
||||
void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TranscriptionBlockRepositoryIntegrationTest {
|
||||
|
||||
static final UUID DOC_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
static final UUID DOC_B = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
static final UUID ANN_A = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
static final UUID ANN_B = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
@Autowired TranscriptionBlockRepository repository;
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, true)"
|
||||
})
|
||||
void findCompletionStats_returns_100_when_all_blocks_reviewed() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_A);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)"
|
||||
})
|
||||
void findCompletionStats_returns_0_when_no_blocks_reviewed() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
|
||||
})
|
||||
void findCompletionStats_returns_empty_when_document_has_no_blocks() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 2, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 3, false)"
|
||||
})
|
||||
void findCompletionStats_rounds_partial_completion_correctly() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Doc B', 'b.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 0, false)"
|
||||
})
|
||||
void findCompletionStats_handles_multiple_documents_in_one_call() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B));
|
||||
|
||||
Map<UUID, Integer> byDoc = rows.stream()
|
||||
.collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage));
|
||||
|
||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -30,6 +31,8 @@ class DocumentServiceSortTest {
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
||||
@@ -56,8 +59,8 @@ class DocumentServiceSortTest {
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,6 +99,6 @@ class DocumentServiceSortTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -51,6 +53,8 @@ class DocumentServiceTest {
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -1298,8 +1302,8 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||
@@ -1318,7 +1322,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
@@ -1341,7 +1345,7 @@ class DocumentServiceTest {
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("smith doc", "Null lastname doc");
|
||||
}
|
||||
|
||||
@@ -1362,8 +1366,8 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
assertThat(result.matchData()).containsKey(docId);
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.titleOffsets()).hasSize(1);
|
||||
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
||||
}
|
||||
@@ -1376,7 +1380,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1395,7 +1399,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@ interface Props {
|
||||
let { contributors, hasMore }: Props = $props();
|
||||
|
||||
const safeContributors = $derived(contributors ?? []);
|
||||
|
||||
function safeColor(color: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if safeContributors.length === 0}
|
||||
<span
|
||||
role="img"
|
||||
aria-label="Noch niemand angefangen"
|
||||
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
|
||||
title="Noch niemand angefangen"
|
||||
></span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center">
|
||||
@@ -24,8 +29,8 @@ const safeContributors = $derived(contributors ?? []);
|
||||
<span
|
||||
role="img"
|
||||
aria-label={actor.name ?? actor.initials}
|
||||
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
|
||||
style="background-color: {actor.color || '#8c9aa3'};"
|
||||
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
|
||||
style="background-color: {safeColor(actor.color)};"
|
||||
title={actor.name ?? actor.initials}
|
||||
>
|
||||
{actor.initials}
|
||||
@@ -33,7 +38,9 @@ const safeContributors = $derived(contributors ?? []);
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<span
|
||||
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[9px] font-bold text-ink-3 ring-2 ring-white"
|
||||
role="img"
|
||||
aria-label="Weitere Mitwirkende"
|
||||
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[10px] font-bold text-ink-3 ring-2 ring-white"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
|
||||
@@ -45,6 +45,8 @@ describe('ContributorStack', () => {
|
||||
|
||||
it('renders empty placeholder when no contributors', async () => {
|
||||
render(ContributorStack, { contributors: [], hasMore: false });
|
||||
await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('img', { name: 'Noch niemand angefangen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const { resumeDoc }: Props = $props();
|
||||
|
||||
function safeColor(color: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if resumeDoc === null}
|
||||
@@ -94,7 +98,7 @@ const { resumeDoc }: Props = $props();
|
||||
{#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)}
|
||||
<span
|
||||
class="-ml-1 inline-flex h-6 w-6 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white"
|
||||
style="background:{collab.color}">{collab.initials}</span
|
||||
style="background:{safeColor(collab.color)}">{collab.initials}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
182
frontend/src/lib/components/DocumentRow.svelte
Normal file
182
frontend/src/lib/components/DocumentRow.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let { item }: { item: DocumentSearchItem } = $props();
|
||||
|
||||
const doc = $derived(item.document);
|
||||
const titleText = $derived(doc.title || doc.originalFilename);
|
||||
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
|
||||
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
|
||||
const snippet = $derived(item.matchData?.transcriptionSnippet ?? null);
|
||||
const snippetSegments = $derived(
|
||||
snippet ? applyOffsets(snippet, item.matchData?.snippetOffsets ?? []) : null
|
||||
);
|
||||
const senderMatched = $derived(item.matchData?.senderMatched ?? false);
|
||||
const matchedReceiverIds = $derived(new Set(item.matchData?.matchedReceiverIds ?? []));
|
||||
const matchedTagIds = $derived(new Set(item.matchData?.matchedTagIds ?? []));
|
||||
const hasMore = $derived(item.contributors.length >= 4);
|
||||
|
||||
function tagClass(matched: boolean): string {
|
||||
return matched
|
||||
? 'inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-primary text-primary-fg transition-colors'
|
||||
: 'inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-muted text-ink hover:bg-primary hover:text-primary-fg transition-colors';
|
||||
}
|
||||
|
||||
function safeTagColor(color: string | null | undefined): string {
|
||||
return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#cdcbbf';
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||
<a href="/documents/{doc.id}" class="block px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-0 sm:gap-5">
|
||||
<!-- Left column -->
|
||||
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
|
||||
<!-- Title -->
|
||||
<h3 class="mb-1 font-serif text-xl font-medium text-ink group-hover:underline">
|
||||
{#each titleSegments as seg, i (i)}
|
||||
{#if seg.highlight}
|
||||
<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</h3>
|
||||
|
||||
<!-- Snippet -->
|
||||
{#if snippetSegments}
|
||||
<p
|
||||
data-testid="search-snippet"
|
||||
class="mb-2 line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each snippetSegments as seg, i (i)}
|
||||
{#if seg.highlight}
|
||||
<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Sender / receivers — desktop only -->
|
||||
<div class="mt-2 mb-2 hidden gap-4 font-sans text-xs text-ink-2 sm:grid sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||
<span class="ml-1">
|
||||
{#if doc.sender}
|
||||
{#if senderMatched}
|
||||
<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{doc.sender.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
{doc.sender.displayName}
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
|
||||
<span class="ml-1">
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
{#each doc.receivers as receiver, i (receiver.id)}
|
||||
{#if i > 0}<span>, </span>{/if}
|
||||
{#if matchedReceiverIds.has(receiver.id)}
|
||||
<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{receiver.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
{receiver.displayName}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class={tagClass(matchedTagIds.has(tag.id))}
|
||||
onclick={(e) => { e.stopPropagation(); goto('/documents?tag=' + encodeURIComponent(tag.name)); }}
|
||||
>
|
||||
{#if tag.color}
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
style="background-color: {safeTagColor(tag.color)};"
|
||||
></span>
|
||||
{/if}
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile-only metadata -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
|
||||
<div>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column — desktop only -->
|
||||
<div class="hidden flex-col gap-2 pl-4 font-sans text-xs text-ink-2 sm:flex sm:w-44 lg:w-56">
|
||||
<div>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||
<span class="ml-1">
|
||||
{#if doc.sender}
|
||||
{doc.sender.displayName}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
|
||||
<span class="ml-1">
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
{doc.receivers.map((r) => r.displayName).join(', ')}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
179
frontend/src/lib/components/DocumentRow.svelte.spec.ts
Normal file
179
frontend/src/lib/components/DocumentRow.svelte.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentRow from './DocumentRow.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
|
||||
return {
|
||||
document: {
|
||||
id: '1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED',
|
||||
documentDate: '2024-03-15',
|
||||
sender: null,
|
||||
receivers: [],
|
||||
tags: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN'
|
||||
},
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Title ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – title', () => {
|
||||
it('renders document title', async () => {
|
||||
render(DocumentRow, { item: makeItem() });
|
||||
await expect.element(page.getByRole('heading', { name: 'Testbrief' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is null', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, title: null } });
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a mark element for highlighted title offsets', async () => {
|
||||
const item = makeItem({
|
||||
document: { ...makeItem().document, title: 'Brief an Anna' },
|
||||
matchData: {
|
||||
titleOffsets: [{ start: 0, length: 5 }],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
const mark = page.getByRole('mark');
|
||||
await expect.element(mark).toBeInTheDocument();
|
||||
await expect.element(mark).toHaveTextContent('Brief');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snippet ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – snippet', () => {
|
||||
it('shows transcription snippet when present', async () => {
|
||||
const item = makeItem({
|
||||
matchData: {
|
||||
transcriptionSnippet: 'Er schrieb einen langen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render snippet section when no snippet', async () => {
|
||||
render(DocumentRow, { item: makeItem() });
|
||||
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sender / receivers ───────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – sender', () => {
|
||||
it('shows sender display name', async () => {
|
||||
const item = makeItem({
|
||||
document: {
|
||||
...makeItem().document,
|
||||
sender: { id: 's1', displayName: 'Großmutter Maria' }
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText('Großmutter Maria').first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unknown fallback when sender is null', async () => {
|
||||
render(DocumentRow, { item: makeItem() });
|
||||
const unknownElements = page.getByText('Unbekannt');
|
||||
await expect.element(unknownElements.first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – tags', () => {
|
||||
it('renders tag buttons', async () => {
|
||||
const item = makeItem({
|
||||
document: {
|
||||
...makeItem().document,
|
||||
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to /documents?tag=… on tag click', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
const item = makeItem({
|
||||
document: {
|
||||
...makeItem().document,
|
||||
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
await page.getByRole('button', { name: 'Urlaub & Reise' }).click();
|
||||
expect(goto).toHaveBeenCalledWith('/documents?tag=Urlaub%20%26%20Reise');
|
||||
});
|
||||
|
||||
it('tag click does not navigate to the document detail page', async () => {
|
||||
const item = makeItem({
|
||||
document: {
|
||||
...makeItem().document,
|
||||
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
const before = window.location.href;
|
||||
await page.getByRole('button', { name: 'Familie' }).click();
|
||||
expect(window.location.href).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – progress ring and contributors', () => {
|
||||
it('renders the completion percentage label', async () => {
|
||||
const item = makeItem({ completionPercentage: 42 });
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText('42%').first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders contributor initials when contributors present', async () => {
|
||||
const item = makeItem({
|
||||
contributors: [{ initials: 'AR', color: '#4a90e2', name: 'Anna Raddatz' }]
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
await expect.element(page.getByText('AR').first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
26
frontend/src/lib/components/ProgressRing.svelte
Normal file
26
frontend/src/lib/components/ProgressRing.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
let { percentage }: { percentage: number } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<svg width="36" height="36" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="7" fill="none" stroke="var(--c-line)" stroke-width="2" />
|
||||
<circle
|
||||
class="fill-arc"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="7"
|
||||
fill="none"
|
||||
stroke="var(--c-accent)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
transform="rotate(-90 10 10)"
|
||||
stroke-dasharray="{(percentage / 100) * 43.98} 43.98"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
|
||||
>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
44
frontend/src/lib/components/ProgressRing.svelte.spec.ts
Normal file
44
frontend/src/lib/components/ProgressRing.svelte.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ProgressRing', () => {
|
||||
it('renders the correct stroke-dasharray for 75%', async () => {
|
||||
render(ProgressRing, { percentage: 75 });
|
||||
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
|
||||
expect(arc).not.toBeNull();
|
||||
// circumference = 2 * π * 7 ≈ 43.98; 75% of that ≈ 32.99
|
||||
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
|
||||
const filled = parseFloat(dasharray.split(' ')[0]);
|
||||
expect(filled).toBeCloseTo(32.99, 1);
|
||||
});
|
||||
|
||||
it('renders a gray label when percentage is 0', async () => {
|
||||
render(ProgressRing, { percentage: 0 });
|
||||
const label = page.getByText('0%');
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
// Label should carry the gray class, not the mint class
|
||||
const el = (await label.element()) as HTMLElement;
|
||||
expect(el.className).toContain('text-gray-400');
|
||||
});
|
||||
|
||||
it('renders a mint-colored label when percentage is > 0', async () => {
|
||||
render(ProgressRing, { percentage: 75 });
|
||||
const label = page.getByText('75%');
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
const el = (await label.element()) as HTMLElement;
|
||||
expect(el.className).toContain('text-accent');
|
||||
});
|
||||
|
||||
it('renders a fully filled arc for 100%', async () => {
|
||||
render(ProgressRing, { percentage: 100 });
|
||||
const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null;
|
||||
expect(arc).not.toBeNull();
|
||||
const dasharray = arc!.getAttribute('stroke-dasharray') ?? '';
|
||||
const filled = parseFloat(dasharray.split(' ')[0]);
|
||||
expect(filled).toBeCloseTo(43.98, 1);
|
||||
});
|
||||
});
|
||||
@@ -1951,13 +1951,17 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
};
|
||||
DocumentSearchItem: {
|
||||
document: components["schemas"]["Document"];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: int32 */
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
documents: components["schemas"]["Document"][];
|
||||
items: components["schemas"]["DocumentSearchItem"][];
|
||||
/** Format: int64 */
|
||||
total: number;
|
||||
matchData: {
|
||||
[key: string]: components["schemas"]["SearchMatchData"];
|
||||
};
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -3,96 +3,22 @@ import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
type SearchMatchData = components['schemas']['SearchMatchData'];
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
|
||||
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE'] as const;
|
||||
type ValidSort = (typeof VALID_SORTS)[number];
|
||||
const rawSort = url.searchParams.get('sort') ?? 'DATE';
|
||||
const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort)
|
||||
? (rawSort as ValidSort)
|
||||
: 'DATE';
|
||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||
type ValidDir = (typeof VALID_DIRS)[number];
|
||||
const rawDir = url.searchParams.get('dir') ?? 'desc';
|
||||
const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir)
|
||||
? (rawDir as ValidDir)
|
||||
: 'desc';
|
||||
const tagQ = url.searchParams.get('tagQ') || '';
|
||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||
|
||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
isDashboard
|
||||
? Promise.resolve(null)
|
||||
: api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
sort,
|
||||
dir: dir || undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
const personsResult = await api.GET('/api/persons');
|
||||
|
||||
if (personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
if (docsResult && docsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
const searchResult = docsResult?.data as {
|
||||
documents?: Document[];
|
||||
total?: number;
|
||||
matchData?: Record<string, SearchMatchData>;
|
||||
} | null;
|
||||
const documents: Document[] = searchResult?.documents ?? [];
|
||||
const total: number = searchResult?.total ?? 0;
|
||||
const matchData: Record<string, SearchMatchData> = searchResult?.matchData ?? {};
|
||||
const allPersons = (personsResult.data ?? []) as {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}[];
|
||||
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
let stats: StatsDTO | null = null;
|
||||
let resumeDoc: DashboardResumeDTO | null = null;
|
||||
let pulse: DashboardPulseDTO | null = null;
|
||||
let activityFeed: ActivityFeedItemDTO[] = [];
|
||||
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||
|
||||
if (isDashboard) {
|
||||
const [
|
||||
statsResult,
|
||||
resumeResult,
|
||||
@@ -113,6 +39,15 @@ export async function load({ url, fetch }) {
|
||||
api.GET('/api/transcription/weekly-stats')
|
||||
]);
|
||||
|
||||
let stats: StatsDTO | null = null;
|
||||
let resumeDoc: DashboardResumeDTO | null = null;
|
||||
let pulse: DashboardPulseDTO | null = null;
|
||||
let activityFeed: ActivityFeedItemDTO[] = [];
|
||||
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
}
|
||||
@@ -137,13 +72,8 @@ export async function load({ url, fetch }) {
|
||||
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
|
||||
weeklyStats = weeklyStatsResult.value.data ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDashboard,
|
||||
documents,
|
||||
total,
|
||||
matchData,
|
||||
stats,
|
||||
resumeDoc,
|
||||
pulse,
|
||||
@@ -152,21 +82,12 @@ export async function load({ url, fetch }) {
|
||||
transcriptionDocs,
|
||||
readyDocs,
|
||||
weeklyStats,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}`.trim() : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}`.trim() : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
isDashboard,
|
||||
documents: [],
|
||||
total: 0,
|
||||
matchData: {} as Record<string, SearchMatchData>,
|
||||
stats: null,
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
@@ -175,8 +96,6 @@ export async function load({ url, fetch }) {
|
||||
transcriptionDocs: [],
|
||||
readyDocs: [],
|
||||
weeklyStats: null,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,94 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { navigating } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
|
||||
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
|
||||
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let q = $state(untrack(() => data.filters?.q || ''));
|
||||
let qFocused = $state(false);
|
||||
let from = $state(untrack(() => data.filters?.from || ''));
|
||||
let to = $state(untrack(() => data.filters?.to || ''));
|
||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||
untrack(() => (data.filters?.tags || []).map((name: string) => ({ name })))
|
||||
);
|
||||
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
||||
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
||||
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(
|
||||
untrack(() => (data.filters?.tagOp as 'AND' | 'OR') || 'AND')
|
||||
);
|
||||
|
||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
(filters?.tags?.length ?? 0) > 0 ||
|
||||
!!filters?.senderId ||
|
||||
!!filters?.receiverId ||
|
||||
!!filters?.from ||
|
||||
!!filters?.to;
|
||||
|
||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag.name));
|
||||
if (sort) params.set('sort', sort);
|
||||
if (dir) params.set('dir', dir);
|
||||
if (tagQ) params.set('tagQ', tagQ);
|
||||
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
||||
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleTextSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||
}
|
||||
|
||||
function handleImmediateSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
triggerSearch();
|
||||
}
|
||||
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
const cur = tagNames.map((t) => t.name).join(',');
|
||||
if (cur !== prevTagStr) {
|
||||
prevTagStr = cur;
|
||||
triggerSearch();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.filters?.q || '';
|
||||
from = data.filters?.from || '';
|
||||
to = data.filters?.to || '';
|
||||
senderId = data.filters?.senderId || '';
|
||||
receiverId = data.filters?.receiverId || '';
|
||||
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
|
||||
sort = data.filters?.sort || 'DATE';
|
||||
dir = data.filters?.dir || 'desc';
|
||||
tagQ = data.filters?.tagQ || '';
|
||||
tagOperator = (data.filters?.tagOp as 'AND' | 'OR') || 'AND';
|
||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||
});
|
||||
|
||||
const greetingText = $derived.by(() => {
|
||||
const name = data?.user?.firstName ?? '';
|
||||
const h = new Date().getHours();
|
||||
@@ -103,28 +22,6 @@ const greetingText = $derived.by(() => {
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
||||
<SearchFilterBar
|
||||
bind:q={q}
|
||||
bind:from={from}
|
||||
bind:to={to}
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:tagNames={tagNames}
|
||||
bind:showAdvanced={showAdvanced}
|
||||
bind:sort={sort}
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
initialSenderName={data.initialValues?.senderName}
|
||||
initialReceiverName={data.initialValues?.receiverName}
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if data.isDashboard}
|
||||
{#if data?.user}
|
||||
<div class="mb-6">
|
||||
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
||||
@@ -156,15 +53,4 @@ const greetingText = $derived.by(() => {
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentList
|
||||
documents={data.documents ?? []}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
total={data.total ?? 0}
|
||||
q={q}
|
||||
sort={sort}
|
||||
matchData={data.matchData ?? {}}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -40,9 +40,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-stretch lg:flex lg:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
href="/documents"
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
{page.url.pathname.startsWith('/documents')
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
@@ -141,9 +141,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
<div class="relative border-b border-line bg-surface shadow-md">
|
||||
<nav id="mobile-nav">
|
||||
<a
|
||||
href="/"
|
||||
href="/documents"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
{page.url.pathname.startsWith('/documents')
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
|
||||
@@ -1,51 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { groupDocuments } from '$lib/utils/groupDocuments';
|
||||
import GroupDivider from '$lib/components/GroupDivider.svelte';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import DocumentRow from '$lib/components/DocumentRow.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let {
|
||||
documents,
|
||||
items,
|
||||
canWrite,
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort,
|
||||
matchData = {}
|
||||
q = ''
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
originalFilename: string;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
sender?: {
|
||||
id?: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
tags?: { id: string; name: string; color?: string | null }[];
|
||||
}[];
|
||||
items: DocumentSearchItem[];
|
||||
canWrite: boolean;
|
||||
error?: string | null;
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: string;
|
||||
matchData?: Record<string, components['schemas']['SearchMatchData']>;
|
||||
} = $props();
|
||||
|
||||
const fallbackLabel = $derived(
|
||||
(sort ?? 'DATE') === 'DATE' ? m.docs_group_undated() : m.docs_group_unknown()
|
||||
);
|
||||
const groupedDocuments = $derived.by(() =>
|
||||
groupDocuments(documents, sort ?? 'DATE', fallbackLabel)
|
||||
);
|
||||
const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
const yearGroups = $derived.by(() => {
|
||||
const map = new SvelteMap<string, DocumentSearchItem[]>();
|
||||
for (const item of items) {
|
||||
const year = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum';
|
||||
const group = map.get(year);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
map.set(year, [item]);
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([year, groupItems]) => ({ year, items: groupItems }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
@@ -71,205 +59,35 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
|
||||
{/if}
|
||||
|
||||
<!-- DOCUMENT LIST -->
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
{#if error}
|
||||
<!-- ERROR -->
|
||||
{#if error}
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
<div class="bg-red-50 p-8 text-center text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
{:else if documents.length > 0}
|
||||
{#each groupedDocuments as group (group.label)}
|
||||
{#if showDividers}
|
||||
<GroupDivider label={group.label} />
|
||||
{/if}
|
||||
<ul class="divide-y divide-line-2">
|
||||
{#each group.documents as doc (doc.id)}
|
||||
{@const titleText = doc.title || doc.originalFilename}
|
||||
{@const match = matchData?.[doc.id]}
|
||||
{@const titleOffsets = match?.titleOffsets ?? []}
|
||||
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
|
||||
{@const snippet = match?.transcriptionSnippet}
|
||||
{@const snippetSegments = snippet ? applyOffsets(snippet, match?.snippetOffsets ?? []) : null}
|
||||
{@const summary = match?.summarySnippet}
|
||||
{@const summarySegments = summary ? applyOffsets(summary, match?.summaryOffsets ?? []) : null}
|
||||
{@const senderMatched = match?.senderMatched ?? false}
|
||||
{@const matchedReceiverIds = new Set(match?.matchedReceiverIds ?? [])}
|
||||
{@const matchedTagIds = new Set(match?.matchedTagIds ?? [])}
|
||||
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||
<a href="/documents/{doc.id}" class="block p-6">
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
<!-- Main Info -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
|
||||
{#each titleSegments as seg, i (i)}
|
||||
{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}
|
||||
{/each}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Row -->
|
||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</div>
|
||||
{#if doc.location}
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if snippetSegments}
|
||||
<div class="mb-4 flex items-baseline gap-2">
|
||||
<span
|
||||
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_content()}</span
|
||||
>
|
||||
<p
|
||||
data-testid="search-snippet"
|
||||
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each snippetSegments as seg, i (i)}{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if summarySegments}
|
||||
<div class="mb-4 flex items-baseline gap-2">
|
||||
<span
|
||||
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_summary()}</span
|
||||
>
|
||||
<p
|
||||
data-testid="search-summary"
|
||||
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each summarySegments as seg, i (i)}{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sender/Receiver Info -->
|
||||
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
{#if senderMatched}
|
||||
<mark
|
||||
data-testid="sender-match"
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{doc.sender.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
<span class="text-ink">{doc.sender.displayName}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_to()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-ink">
|
||||
{#each doc.receivers as receiver, ri (receiver.id ?? ri)}
|
||||
{#if ri > 0}<span>, </span>{/if}
|
||||
{#if receiver.id && matchedReceiverIds.has(receiver.id)}
|
||||
<mark
|
||||
data-testid="receiver-match"
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{receiver.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
{receiver.displayName}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Display -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
|
||||
title={tag.name}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goto(`/?tag=${encodeURIComponent(tag.name)}`);
|
||||
}}
|
||||
>
|
||||
{#if tag.color}
|
||||
<span
|
||||
data-testid="tag-color-dot"
|
||||
data-color={tag.color}
|
||||
style="background-color: var(--c-tag-{tag.color})"
|
||||
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
|
||||
></span>
|
||||
{/if}
|
||||
{#if matchedTagIds.has(tag.id)}
|
||||
<span data-testid="tag-match">{tag.name}</span>
|
||||
{:else}
|
||||
{tag.name}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Arrow Icon -->
|
||||
{:else if items.length > 0}
|
||||
<!-- YEAR CARDS -->
|
||||
{#each yearGroups as group (group.year)}
|
||||
<div
|
||||
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
|
||||
data-testid="year-card"
|
||||
class="mb-4 overflow-hidden border border-line bg-surface shadow-sm"
|
||||
>
|
||||
<div class="border-b border-line bg-muted px-5 py-2">
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{group.year}</span
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<ul class="divide-y divide-line">
|
||||
{#each group.items as item (item.document.id)}
|
||||
<DocumentRow item={item} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
{:else}
|
||||
<!-- EMPTY STATE -->
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
<div class="p-16 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<img
|
||||
@@ -284,11 +102,11 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
{q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
onclick={() => goto('/documents')}
|
||||
class="mt-6 text-sm font-bold tracking-wide text-primary uppercase transition hover:text-ink-2"
|
||||
>
|
||||
{m.docs_empty_btn_clear()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,64 +2,63 @@ import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const baseProps = {
|
||||
documents: [],
|
||||
canWrite: false,
|
||||
error: null,
|
||||
total: 0,
|
||||
q: '',
|
||||
matchData: {} as Record<
|
||||
string,
|
||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
||||
>
|
||||
};
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
type DocOverrides = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
documentDate?: string | null;
|
||||
sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null;
|
||||
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
const makeDoc = (overrides: DocOverrides = {}) => ({
|
||||
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
|
||||
return {
|
||||
document: {
|
||||
id: '1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED' as const,
|
||||
status: 'UPLOADED',
|
||||
documentDate: '2024-03-15',
|
||||
location: null,
|
||||
sender: null,
|
||||
receivers: [] as {
|
||||
id?: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
}[],
|
||||
receivers: [],
|
||||
tags: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN'
|
||||
},
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
...overrides
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = { items: [], canWrite: false, error: null, total: 0, q: '' };
|
||||
|
||||
// ─── Result count ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentList – result count', () => {
|
||||
it('shows result count when total > 0', async () => {
|
||||
render(DocumentList, { ...baseProps, documents: [makeDoc()], total: 1, q: 'test' });
|
||||
render(DocumentList, { ...baseProps, items: [makeItem()], total: 1, q: 'test' });
|
||||
await expect.element(page.getByText('1 Dokumente')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show result count when total is 0 and there is no error', async () => {
|
||||
it('does not show result count when total is 0', async () => {
|
||||
render(DocumentList, { ...baseProps, total: 0, q: '' });
|
||||
const count = page.getByText(/\d+ Dokumente/);
|
||||
await expect.element(count).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText(/\d+ Dokumente/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentList – empty state with search term', () => {
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentList – empty state', () => {
|
||||
it('shows generic empty heading when q is empty', async () => {
|
||||
render(DocumentList, { ...baseProps });
|
||||
await expect.element(page.getByText(/Keine Dokumente/)).toBeInTheDocument();
|
||||
@@ -71,73 +70,49 @@ describe('DocumentList – empty state with search term', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group headers ────────────────────────────────────────────────────────────
|
||||
// ─── Year grouping ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentList – group headers', () => {
|
||||
it('renders group-divider elements when DATE sort spans multiple years', async () => {
|
||||
const documents = [
|
||||
makeDoc({ id: '1', documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: '2', documentDate: '1965-08-03' })
|
||||
describe('DocumentList – year grouping', () => {
|
||||
it('groups documents by year into separate cards', async () => {
|
||||
const items = [
|
||||
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }),
|
||||
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } })
|
||||
];
|
||||
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
|
||||
await expect.element(page.getByTestId('group-divider').first()).toBeInTheDocument();
|
||||
render(DocumentList, { ...baseProps, items, total: 2 });
|
||||
const yearCards = page.getByTestId('year-card');
|
||||
await expect.element(yearCards.first()).toBeInTheDocument();
|
||||
await expect.element(yearCards.nth(1)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render group-divider when DATE sort has only one distinct year', async () => {
|
||||
const documents = [
|
||||
makeDoc({ id: '1', documentDate: '1938-01-01' }),
|
||||
makeDoc({ id: '2', documentDate: '1938-06-15' })
|
||||
it('uses Ohne Datum for items with no documentDate', async () => {
|
||||
const items = [
|
||||
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
|
||||
];
|
||||
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
|
||||
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
|
||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||
await expect.element(page.getByText('Ohne Datum')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render group-divider for TITLE sort', async () => {
|
||||
const documents = [
|
||||
makeDoc({ id: '1', documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: '2', documentDate: '1965-08-03' })
|
||||
it('single year renders one year-card', async () => {
|
||||
const items = [
|
||||
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }),
|
||||
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } })
|
||||
];
|
||||
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'TITLE' });
|
||||
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Undatiert fallback label when sort is undefined and doc has no date', async () => {
|
||||
const documents = [
|
||||
makeDoc({ id: '1', documentDate: '1938-01-01' }),
|
||||
makeDoc({ id: '2', documentDate: null })
|
||||
];
|
||||
render(DocumentList, { ...baseProps, documents, total: 2 }); // sort omitted — defaults to DATE grouping
|
||||
await expect.element(page.getByText(/UNDATIERT/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a doc with two receivers appears in both receiver groups', async () => {
|
||||
const documents = [
|
||||
makeDoc({
|
||||
id: '1',
|
||||
receivers: [
|
||||
{ firstName: null, lastName: 'Müller', displayName: 'Anna Müller' },
|
||||
{ firstName: null, lastName: 'Bauer', displayName: 'Karl Bauer' }
|
||||
]
|
||||
})
|
||||
];
|
||||
render(DocumentList, { ...baseProps, documents, total: 1, sort: 'RECEIVER' });
|
||||
const links = page.getByRole('link', { name: /Testbrief/ });
|
||||
await expect.element(links.first()).toBeInTheDocument();
|
||||
await expect.element(links.nth(1)).toBeInTheDocument();
|
||||
render(DocumentList, { ...baseProps, items, total: 2 });
|
||||
const yearCards = page.getByTestId('year-card');
|
||||
// Only one card for 1938
|
||||
await expect.element(yearCards.first()).toBeInTheDocument();
|
||||
await expect.element(yearCards.nth(1)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Match data: snippet and title highlighting ───────────────────────────────
|
||||
// ─── DocumentRow rendering (delegated) ───────────────────────────────────────
|
||||
|
||||
describe('DocumentList – match snippets and highlights', () => {
|
||||
it('shows transcription snippet when matchData has one for the document', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
describe('DocumentList – DocumentRow delegation', () => {
|
||||
it('shows transcription snippet when matchData has one', async () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
document: { ...makeItem().document, id: 'doc1' },
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen langen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
@@ -146,26 +121,23 @@ describe('DocumentList – match snippets and highlights', () => {
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
];
|
||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show snippet section when matchData has no entry for the document', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} });
|
||||
it('does not render snippet when matchData has no transcription snippet', async () => {
|
||||
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })];
|
||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a <mark> element when titleOffsets are present', async () => {
|
||||
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
it('renders mark for title highlight when titleOffsets present', async () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' },
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
@@ -173,221 +145,11 @@ describe('DocumentList – match snippets and highlights', () => {
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
// The word "Brief" should be inside a <mark> element
|
||||
})
|
||||
];
|
||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||
const mark = page.getByRole('mark');
|
||||
await expect.element(mark).toBeInTheDocument();
|
||||
await expect.element(mark).toHaveTextContent('Brief');
|
||||
});
|
||||
|
||||
it('renders title as plain text when titleOffsets is empty', async () => {
|
||||
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByRole('mark')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders <mark> inside snippet when snippetOffsets are present', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [{ start: 17, length: 5 }], // "Brief"
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const snippet = page.getByTestId('search-snippet');
|
||||
await expect.element(snippet).toBeInTheDocument();
|
||||
const mark = snippet.getByRole('mark');
|
||||
await expect.element(mark).toBeInTheDocument();
|
||||
await expect.element(mark).toHaveTextContent('Brief');
|
||||
});
|
||||
|
||||
it('renders snippet as plain text when snippetOffsets is empty', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const snippet = page.getByTestId('search-snippet');
|
||||
await expect.element(snippet).toBeInTheDocument();
|
||||
// No mark elements inside the snippet when offsets is empty
|
||||
await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('visually marks sender when senderMatched is true', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
sender: {
|
||||
id: 'sender-1',
|
||||
firstName: 'Walter',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Walter Raddatz'
|
||||
}
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: true,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const senderMark = page.getByTestId('sender-match');
|
||||
await expect.element(senderMark).toBeInTheDocument();
|
||||
await expect.element(senderMark).toHaveTextContent('Walter Raddatz');
|
||||
});
|
||||
|
||||
it('does not mark sender when senderMatched is false', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
sender: {
|
||||
id: 'sender-1',
|
||||
firstName: 'Walter',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Walter Raddatz'
|
||||
}
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('visually marks matched receiver when their id is in matchedReceiverIds', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
receivers: [
|
||||
{ id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
|
||||
{ id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' }
|
||||
]
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: ['p-1'],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only Anna Schmidt should be marked
|
||||
const receiverMark = page.getByTestId('receiver-match');
|
||||
await expect.element(receiverMark).toBeInTheDocument();
|
||||
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('renders a color dot on tag chips that have a color', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
tags: [{ id: 'tag-1', name: 'Familie', color: 'sage' }]
|
||||
});
|
||||
render(DocumentList, { ...baseProps, documents: [doc], total: 1 });
|
||||
const dot = page.getByTestId('tag-color-dot');
|
||||
await expect.element(dot).toBeInTheDocument();
|
||||
await expect.element(dot).toHaveAttribute('data-color', 'sage');
|
||||
});
|
||||
|
||||
it('does not render a color dot on tag chips without a color', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
tags: [{ id: 'tag-1', name: 'Familie' }]
|
||||
});
|
||||
render(DocumentList, { ...baseProps, documents: [doc], total: 1 });
|
||||
await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('visually marks matched tag when its id is in matchedTagIds', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
tags: [
|
||||
{ id: 'tag-1', name: 'Familiengeschichte' },
|
||||
{ id: 'tag-2', name: 'Reise' }
|
||||
]
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: ['tag-1'],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const tagMark = page.getByTestId('tag-match');
|
||||
await expect.element(tagMark).toBeInTheDocument();
|
||||
await expect.element(tagMark).toHaveTextContent('Familiengeschichte');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ $effect(() => {
|
||||
|
||||
<!-- Reset Button -->
|
||||
<a
|
||||
href="/"
|
||||
href="/documents"
|
||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
|
||||
title={m.docs_btn_reset_title()}
|
||||
>
|
||||
|
||||
93
frontend/src/routes/documents/+page.server.ts
Normal file
93
frontend/src/routes/documents/+page.server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
||||
type ValidSort = (typeof VALID_SORTS)[number];
|
||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||
type ValidDir = (typeof VALID_DIRS)[number];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
const rawSort = url.searchParams.get('sort') ?? 'DATE';
|
||||
const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort)
|
||||
? (rawSort as ValidSort)
|
||||
: 'DATE';
|
||||
const rawDir = url.searchParams.get('dir') ?? 'desc';
|
||||
const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir)
|
||||
? (rawDir as ValidDir)
|
||||
: 'desc';
|
||||
const tagQ = url.searchParams.get('tagQ') || '';
|
||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
sort,
|
||||
dir: dir || undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
items: [] as DocumentSearchItem[],
|
||||
total: 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
senderId,
|
||||
receiverId,
|
||||
tags,
|
||||
sort,
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
if (result.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const errorMessage: string | null = !result.response.ok
|
||||
? (getErrorMessage((result.error as unknown as { code?: string })?.code) ??
|
||||
'Daten konnten nicht geladen werden.')
|
||||
: null;
|
||||
|
||||
return {
|
||||
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
||||
total: result.data?.total ?? 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
senderId,
|
||||
receiverId,
|
||||
tags,
|
||||
sort,
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
123
frontend/src/routes/documents/+page.svelte
Normal file
123
frontend/src/routes/documents/+page.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { navigating } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
import DocumentList from '../DocumentList.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Local state initialised from server-returned filter values.
|
||||
// untrack() prevents infinite reactive loops during initialisation.
|
||||
let q = $state(untrack(() => data.q || ''));
|
||||
let qFocused = $state(false);
|
||||
let from = $state(untrack(() => data.from || ''));
|
||||
let to = $state(untrack(() => data.to || ''));
|
||||
let senderId = $state(untrack(() => data.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.receiverId || ''));
|
||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
||||
);
|
||||
let sort = $state(untrack(() => data.sort || 'DATE'));
|
||||
let dir = $state(untrack(() => data.dir || 'desc'));
|
||||
let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to
|
||||
);
|
||||
}
|
||||
|
||||
let showAdvanced = $state(untrack(hasAdvancedFilters));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
tagNames.forEach((tag) => params.append('tag', tag.name));
|
||||
if (sort) params.set('sort', sort);
|
||||
if (dir) params.set('dir', dir);
|
||||
if (tagQ) params.set('tagQ', tagQ);
|
||||
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleTextSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||
}
|
||||
|
||||
function handleImmediateSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
triggerSearch();
|
||||
}
|
||||
|
||||
// Trigger search reactively when the tag list changes.
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
const cur = tagNames.map((t) => t.name).join(',');
|
||||
if (cur !== prevTagStr) {
|
||||
prevTagStr = cur;
|
||||
triggerSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep local filter state in sync with server data after navigation completes.
|
||||
// Guard q: skip overwrite while the user is actively typing.
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.q || '';
|
||||
from = data.from || '';
|
||||
to = data.to || '';
|
||||
senderId = data.senderId || '';
|
||||
receiverId = data.receiverId || '';
|
||||
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
||||
sort = data.sort || 'DATE';
|
||||
dir = data.dir || 'desc';
|
||||
tagQ = data.tagQ || '';
|
||||
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
|
||||
if (hasAdvancedFilters()) showAdvanced = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.nav_documents()} – Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
||||
<h1 class="sr-only">{m.nav_documents()}</h1>
|
||||
|
||||
<SearchFilterBar
|
||||
bind:q={q}
|
||||
bind:from={from}
|
||||
bind:to={to}
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:tagNames={tagNames}
|
||||
bind:showAdvanced={showAdvanced}
|
||||
bind:sort={sort}
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.total}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
/>
|
||||
</main>
|
||||
169
frontend/src/routes/documents/page.server.spec.ts
Normal file
169
frontend/src/routes/documents/page.server.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load } from './+page.server';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
const url = new URL('http://localhost/documents');
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => url.searchParams.append(key, v));
|
||||
} else {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── search params forwarding ─────────────────────────────────────────────────
|
||||
|
||||
describe('documents page load — search params', () => {
|
||||
it('passes q, from, to to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/documents/search',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes senderId and receiverId to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/documents/search',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ senderId: 'p-1', receiverId: 'p-2' })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes sort, dir, tagQ to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/documents/search',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns items and total from the search result', async () => {
|
||||
const item = {
|
||||
document: { id: 'd1' },
|
||||
matchData: {},
|
||||
completionPercentage: 0,
|
||||
contributors: []
|
||||
};
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [item], total: 42 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'test' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(42);
|
||||
});
|
||||
|
||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.q).toBe('Urlaub');
|
||||
expect(result.from).toBe('1920-01-01');
|
||||
expect(result.sort).toBe('TITLE');
|
||||
expect(result.dir).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('documents page load — auth redirect', () => {
|
||||
it('redirects to /login when search API returns 401', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 401 }, data: null })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||
).rejects.toMatchObject({ location: '/login' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── network error fallback ───────────────────────────────────────────────────
|
||||
|
||||
describe('documents page load — network error fallback', () => {
|
||||
it('returns error string instead of throwing when API call throws', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
121
frontend/src/routes/documents/page.svelte.spec.ts
Normal file
121
frontend/src/routes/documents/page.svelte.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
||||
|
||||
import Page from './+page.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const SEARCH_LABEL = 'Titel, Personen, Tags durchsuchen…';
|
||||
|
||||
function makeData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
q: '',
|
||||
from: '',
|
||||
to: '',
|
||||
senderId: '',
|
||||
receiverId: '',
|
||||
tags: [],
|
||||
sort: 'DATE',
|
||||
dir: 'desc',
|
||||
tagQ: '',
|
||||
tagOp: 'AND',
|
||||
canWrite: false,
|
||||
error: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Initial state from server data ───────────────────────────────────────────
|
||||
|
||||
describe('documents page — initial state', () => {
|
||||
it('pre-fills the search input from data.q', async () => {
|
||||
render(Page, { data: makeData({ q: 'Geburtstag' }) });
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: SEARCH_LABEL }))
|
||||
.toHaveValue('Geburtstag');
|
||||
});
|
||||
|
||||
it('leaves the search input empty when data.q is not set', async () => {
|
||||
render(Page, { data: makeData() });
|
||||
await expect.element(page.getByRole('textbox', { name: SEARCH_LABEL })).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── URL building via triggerSearch ───────────────────────────────────────────
|
||||
|
||||
describe('documents page — URL building', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
it('calls goto with /documents?q=… after the 500 ms debounce', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
render(Page, { data: makeData() });
|
||||
|
||||
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||
await input.fill('Urlaub');
|
||||
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(goto).toHaveBeenCalledOnce();
|
||||
const [url] = vi.mocked(goto).mock.calls[0];
|
||||
expect(url).toContain('q=Urlaub');
|
||||
expect(url).toMatch(/^\/documents\?/);
|
||||
});
|
||||
|
||||
it('omits q from the URL when the search field is empty', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
render(Page, { data: makeData() });
|
||||
|
||||
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||
await input.fill('');
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
const [url] = vi.mocked(goto).mock.calls[0] ?? [''];
|
||||
expect(url).not.toContain('q=');
|
||||
});
|
||||
|
||||
it('second keystroke within 500 ms cancels the first timer — goto called only once', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
render(Page, { data: makeData() });
|
||||
|
||||
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||
await input.fill('U');
|
||||
vi.advanceTimersByTime(200);
|
||||
await input.fill('Urlaub');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(goto).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('passes keepFocus and noScroll options to goto', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
render(Page, { data: makeData() });
|
||||
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||
await input.fill('Brief');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(goto).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ keepFocus: true, noScroll: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,9 @@
|
||||
|
||||
/* ─── 4. Light mode (default) ─────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* 1px accent bar + 64px nav = 65px total sticky header height */
|
||||
--header-height: 65px;
|
||||
|
||||
--c-canvas: #f0efe9;
|
||||
--c-surface: #ffffff;
|
||||
--c-overlay: #ffffff;
|
||||
|
||||
@@ -19,10 +19,42 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
||||
// ─── always-dashboard behaviour ───────────────────────────────────────────────
|
||||
|
||||
describe('home page load — dashboard mode', () => {
|
||||
it('sets isDashboard true and fetches stats, resume, pulse, and activity APIs', async () => {
|
||||
it('never calls /api/documents/search regardless of URL params', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calledEndpoints).not.toContain('/api/documents/search');
|
||||
});
|
||||
|
||||
it('always fetches dashboard data regardless of URL params', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calledEndpoints).toContain('/api/stats');
|
||||
expect(calledEndpoints).toContain('/api/dashboard/resume');
|
||||
expect(calledEndpoints).toContain('/api/dashboard/pulse');
|
||||
expect(calledEndpoints).toContain('/api/dashboard/activity');
|
||||
});
|
||||
|
||||
// ─── dashboard mode ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('home page load — dashboard', () => {
|
||||
it('fetches stats, resume, pulse, and activity APIs', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
@@ -67,13 +99,11 @@ describe('home page load — dashboard mode', () => {
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.isDashboard).toBe(true);
|
||||
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
||||
expect(result.resumeDoc).not.toBeNull();
|
||||
expect(result.resumeDoc?.totalBlocks).toBe(2);
|
||||
expect(result.pulse).not.toBeNull();
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns stats with totalDocuments from /api/stats', async () => {
|
||||
@@ -109,8 +139,8 @@ describe('home page load — dashboard mode', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // resume
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // pulse
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
@@ -158,123 +188,6 @@ describe('home page load — dashboard mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search mode (any filter active) ─────────────────────────────────────────
|
||||
|
||||
describe('home page load — search mode', () => {
|
||||
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { documents: [{ id: 'd1' }], total: 1 }
|
||||
}) // search docs
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'Urlaub' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.stats).toBeNull();
|
||||
expect(result.resumeDoc).toBeNull();
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
// Only two API calls — no widget calls
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes search params from the URL to the documents API', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { documents: [], total: 0 }
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
const firstCall = mockGet.mock.calls[0];
|
||||
expect(firstCall[1].params.query.q).toBe('Urlaub');
|
||||
expect(firstCall[1].params.query.from).toBe('2020-01-01');
|
||||
});
|
||||
|
||||
it('sets isDashboard false when only tagQ is set', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { documents: [{ id: 'd1' }], total: 1 }
|
||||
}) // search docs
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ tagQ: 'fam' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes sort, dir, and tagQ params to the documents API', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { documents: [], total: 0 }
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'test', sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
const firstCall = mockGet.mock.calls[0];
|
||||
expect(firstCall[1].params.query.sort).toBe('TITLE');
|
||||
expect(firstCall[1].params.query.dir).toBe('asc');
|
||||
expect(firstCall[1].params.query.tagQ).toBe('fam');
|
||||
});
|
||||
|
||||
it('returns total from the DocumentSearchResult envelope', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { documents: [{ id: 'd1' }], total: 42 }
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'test' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.total).toBe(42);
|
||||
});
|
||||
|
||||
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('home page load — auth redirect', () => {
|
||||
@@ -300,6 +213,5 @@ describe('home page load — network error fallback', () => {
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.error).toBe('Daten konnten nicht geladen werden.');
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,31 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||
|
||||
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
type User = components['schemas']['AppUser'];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||
|
||||
const emptyData = {
|
||||
user: undefined,
|
||||
const baseData = {
|
||||
user: {
|
||||
id: 'u1',
|
||||
email: 'max@example.com',
|
||||
firstName: 'Max',
|
||||
lastName: '',
|
||||
groups: [],
|
||||
enabled: true,
|
||||
createdAt: '2024-01-01T00:00:00Z'
|
||||
} as User,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
isDashboard: false,
|
||||
filters: {
|
||||
q: '',
|
||||
from: '',
|
||||
to: '',
|
||||
senderId: '',
|
||||
receiverId: '',
|
||||
tags: [],
|
||||
sort: 'DATE' as const,
|
||||
dir: 'desc' as const,
|
||||
tagQ: '',
|
||||
tagOp: 'AND'
|
||||
},
|
||||
documents: [],
|
||||
total: 0,
|
||||
matchData: {} as Record<
|
||||
string,
|
||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
||||
>,
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: [],
|
||||
stats: null,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
segmentationDocs: [],
|
||||
transcriptionDocs: [],
|
||||
readyDocs: [],
|
||||
@@ -52,194 +33,22 @@ const emptyData = {
|
||||
error: null
|
||||
};
|
||||
|
||||
const makeDoc = (overrides = {}) => ({
|
||||
id: '1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED' as const,
|
||||
documentDate: '2024-03-15',
|
||||
location: 'Berlin',
|
||||
sender: {
|
||||
id: 'p1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON' as const
|
||||
},
|
||||
receivers: [
|
||||
{
|
||||
id: 'p2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON' as const
|
||||
}
|
||||
],
|
||||
tags: [{ id: 't1', name: 'Familie' }],
|
||||
filePath: '/files/testbrief.pdf',
|
||||
createdAt: '2024-03-15T10:00:00Z',
|
||||
updatedAt: '2024-03-15T10:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
// ─── Dashboard layout ─────────────────────────────────────────────────────────
|
||||
|
||||
const dataWithDocs = { ...emptyData, documents: [makeDoc()] };
|
||||
|
||||
// ─── Search bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – search bar', () => {
|
||||
it('renders the full-text search input', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
||||
describe('Home page – dashboard layout', () => {
|
||||
it('does not render a search input', async () => {
|
||||
render(Page, { data: baseData });
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||
await expect.element(input).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the filter toggle button', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
// Use exact match to avoid collision with the empty-state "Alle Filter löschen" button
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Filter', exact: true }))
|
||||
.toBeInTheDocument();
|
||||
it('renders a greeting for the logged-in user', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the reset link pointing to /', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const resetLink = page.getByTitle('Filter zurücksetzen');
|
||||
await expect.element(resetLink).toBeInTheDocument();
|
||||
await expect.element(resetLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('pre-fills the search input from filters.q', async () => {
|
||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||
.toHaveValue('Urlaub');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Advanced filters ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – advanced filters', () => {
|
||||
it('hides the advanced filters by default', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
// Date inputs are inside the {#if showAdvanced} block → not in DOM
|
||||
await tick();
|
||||
expect(document.querySelector('input[id="from"]')).toBeNull();
|
||||
expect(document.querySelector('input[id="to"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles the advanced filter panel open on button click', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
||||
await tick();
|
||||
expect(document.querySelector('input[id="from"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[id="to"]')).not.toBeNull();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' });
|
||||
});
|
||||
|
||||
it('collapses the advanced filter panel on second click', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const btn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await btn.click();
|
||||
// Wait for the input to appear before clicking again
|
||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
||||
await btn.click();
|
||||
// Wait for slide transition to finish
|
||||
await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tag filter section when filters are open', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Document list ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – document list', () => {
|
||||
it('shows empty state when there are no documents', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' });
|
||||
});
|
||||
|
||||
it('renders a document with title, date, location, sender and receiver', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
await expect.element(page.getByText('Testbrief')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('15. März 2024')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' });
|
||||
});
|
||||
|
||||
it('renders a tag chip for each document tag', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Unbekannt" for sender when sender is null', async () => {
|
||||
const data = { ...emptyData, documents: [makeDoc({ sender: null })] };
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('Unbekannt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders original filename when title is empty', async () => {
|
||||
const data = { ...emptyData, documents: [makeDoc({ title: null })] };
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links each document to its detail page', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
const link = page.getByRole('link', { name: /Testbrief/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/1');
|
||||
});
|
||||
|
||||
it('renders the "Neues Dokument" link', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const link = page.getByRole('link', { name: /Neues Dokument/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/new');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
||||
|
||||
describe('Home page – search input keystroke preservation', () => {
|
||||
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||
const { rerender } = render(Page, { data: emptyData });
|
||||
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026');
|
||||
|
||||
// User types "abc" — input is focused
|
||||
await input.click();
|
||||
await input.fill('abc');
|
||||
|
||||
// Simulate a navigation completing with stale data (q='a') while the user is still typing
|
||||
await rerender({ data: { ...emptyData, filters: { ...emptyData.filters, q: 'a' } } });
|
||||
await tick();
|
||||
|
||||
// Input must still show what the user typed, not the stale URL value
|
||||
await expect.element(input).toHaveValue('abc');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dashboard mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – dashboard mode', () => {
|
||||
const dashboardData = {
|
||||
...emptyData,
|
||||
isDashboard: true,
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: []
|
||||
};
|
||||
|
||||
it('renders empty state when resumeDoc is null', async () => {
|
||||
render(Page, { data: dashboardData });
|
||||
it('renders resume strip empty state when resumeDoc is null', async () => {
|
||||
render(Page, { data: baseData });
|
||||
const empty = page.getByTestId('resume-strip-empty');
|
||||
await expect.element(empty).toBeInTheDocument();
|
||||
});
|
||||
@@ -250,58 +59,22 @@ describe('Home page – dashboard mode', () => {
|
||||
title: 'Geburtsurkunde',
|
||||
caption: 'Max · 1920',
|
||||
excerpt: 'Hiermit…',
|
||||
page: 1,
|
||||
pages: 3,
|
||||
totalBlocks: 3,
|
||||
pct: 33,
|
||||
collaborators: []
|
||||
};
|
||||
render(Page, { data: { ...dashboardData, resumeDoc: resume } });
|
||||
render(Page, { data: { ...baseData, resumeDoc: resume } });
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – error state', () => {
|
||||
it('shows the error message when data.error is set', async () => {
|
||||
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('Daten konnten nicht geladen werden.')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Loading spinner ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – loading spinner', () => {
|
||||
it('does not show spinner by default', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const spinner = page.getByRole('status');
|
||||
await expect.element(spinner).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sort controls ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – sort controls', () => {
|
||||
it('pre-fills sort from filters.sort', async () => {
|
||||
const data = {
|
||||
...emptyData,
|
||||
filters: { ...emptyData.filters, sort: 'TITLE', dir: 'asc', tagQ: '' }
|
||||
};
|
||||
render(Page, { data });
|
||||
const select = page.getByRole('combobox');
|
||||
await expect.element(select).toHaveValue('TITLE');
|
||||
it('shows drop zone when canWrite is true', async () => {
|
||||
render(Page, { data: { ...baseData, canWrite: true } });
|
||||
await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders direction toggle with asc indicator when dir is asc', async () => {
|
||||
const data = {
|
||||
...emptyData,
|
||||
filters: { ...emptyData.filters, sort: 'DATE', dir: 'asc', tagQ: '' }
|
||||
};
|
||||
render(Page, { data });
|
||||
const btn = page.getByRole('button', { name: /aufsteigend/i });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
it('hides drop zone when canWrite is false', async () => {
|
||||
render(Page, { data: { ...baseData, canWrite: false } });
|
||||
await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user