Compare commits
11 Commits
c92eda33b1
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5239f515f | ||
|
|
f2bb58e294 | ||
|
|
2adb98895d | ||
|
|
6049dcadd3 | ||
|
|
7fe8842b57 | ||
|
|
f9340366d1 | ||
|
|
af84ffc379 | ||
|
|
23439e581a | ||
|
|
2c6b59d0c7 | ||
|
|
c0a7408ef4 | ||
|
|
9d283c4500 |
@@ -29,7 +29,6 @@ import java.util.UUID;
|
|||||||
})
|
})
|
||||||
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||||
@NamedAttributeNode("sender"),
|
@NamedAttributeNode("sender"),
|
||||||
@NamedAttributeNode("receivers"),
|
|
||||||
@NamedAttributeNode("tags")
|
@NamedAttributeNode("tags")
|
||||||
})
|
})
|
||||||
@Entity
|
@Entity
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record DocumentListItem(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
String title,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
String originalFilename,
|
|
||||||
String thumbnailUrl,
|
|
||||||
LocalDate documentDate,
|
|
||||||
Person sender,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<Person> receivers,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<Tag> tags,
|
|
||||||
String archiveBox,
|
|
||||||
String archiveFolder,
|
|
||||||
String location,
|
|
||||||
String summary,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int completionPercentage,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<ActivityActorDTO> contributors,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
SearchMatchData matchData
|
|
||||||
) {}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
|
import org.raddatz.familienarchiv.document.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
|
||||||
|
) {}
|
||||||
@@ -7,7 +7,7 @@ import java.util.List;
|
|||||||
|
|
||||||
public record DocumentSearchResult(
|
public record DocumentSearchResult(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentListItem> items,
|
List<DocumentSearchItem> items,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
long totalElements,
|
long totalElements,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -17,12 +17,20 @@ public record DocumentSearchResult(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
int totalPages
|
int totalPages
|
||||||
) {
|
) {
|
||||||
public static DocumentSearchResult of(List<DocumentListItem> items) {
|
/**
|
||||||
|
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||||
|
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||||
|
*/
|
||||||
|
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||||
int size = items.size();
|
int size = items.size();
|
||||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
|
/**
|
||||||
|
* Paged factory used by the service when it has a real Pageable + full match count
|
||||||
|
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||||
|
*/
|
||||||
|
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||||
int pageSize = pageable.getPageSize();
|
int pageSize = pageable.getPageSize();
|
||||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
|
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
|
||||||
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
|
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||||
@@ -735,7 +736,7 @@ public class DocumentService {
|
|||||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
|
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||||
|
|
||||||
@@ -743,7 +744,7 @@ public class DocumentService {
|
|||||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||||
|
|
||||||
return colorResolved.stream().map(doc -> toListItem(
|
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||||
doc,
|
doc,
|
||||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||||
@@ -751,26 +752,6 @@ public class DocumentService {
|
|||||||
)).toList();
|
)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
|
|
||||||
return new DocumentListItem(
|
|
||||||
doc.getId(),
|
|
||||||
doc.getTitle(),
|
|
||||||
doc.getOriginalFilename(),
|
|
||||||
doc.getThumbnailUrl(),
|
|
||||||
doc.getDocumentDate(),
|
|
||||||
doc.getSender(),
|
|
||||||
doc.getReceivers() != null ? List.copyOf(doc.getReceivers()) : List.of(),
|
|
||||||
doc.getTags() != null ? List.copyOf(doc.getTags()) : List.of(),
|
|
||||||
doc.getArchiveBox(),
|
|
||||||
doc.getArchiveFolder(),
|
|
||||||
doc.getLocation(),
|
|
||||||
doc.getSummary(),
|
|
||||||
completionPct,
|
|
||||||
contributors,
|
|
||||||
match
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.document.SearchMatchData;
|
import org.raddatz.familienarchiv.document.SearchMatchData;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -129,13 +130,16 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(docId)
|
||||||
|
.title("Brief an Anna")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
var matchData = new SearchMatchData(
|
var matchData = new SearchMatchData(
|
||||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
|
||||||
List.of(), List.of(), null, null, null, null,
|
|
||||||
0, List.of(), matchData))));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -144,27 +148,6 @@ class DocumentControllerTest {
|
|||||||
.value("Er schrieb einen langen Brief"));
|
.value("Er schrieb einen langen Brief"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
var matchData = new SearchMatchData(null, 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(), any()))
|
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
|
||||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
|
||||||
List.of(), List.of(), null, null, null, null,
|
|
||||||
0, List.of(), matchData))));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
// flat id field present at top of item (not nested under $.items[0].document.id)
|
|
||||||
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
|
|
||||||
// sensitive storage fields must never appear in list response
|
|
||||||
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
|
|
||||||
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
|
|
||||||
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class DocumentLazyLoadingTest {
|
|||||||
PageRequest.of(0, 20));
|
PageRequest.of(0, 20));
|
||||||
assertThat(result.totalElements()).isGreaterThan(0);
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
assertThatCode(() ->
|
assertThatCode(() ->
|
||||||
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
result.items().forEach(i -> i.document().getSender().getLastName()))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
|
|
||||||
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
class DocumentListItemIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
S3Client s3Client;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
AuditLogQueryService auditLogQueryService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
DocumentRepository documentRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
DocumentService documentService;
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
documentRepository.deleteAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
|
|
||||||
documentRepository.save(Document.builder()
|
|
||||||
.title("Kurrent Brief")
|
|
||||||
.originalFilename("kurrent.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
|
||||||
.build());
|
|
||||||
|
|
||||||
assertThatCode(() -> documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(0, 50)))
|
|
||||||
.doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
|
|
||||||
documentRepository.save(Document.builder()
|
|
||||||
.title("Kurrent Brief")
|
|
||||||
.originalFilename("kurrent2.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
|
||||||
.build());
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(0, 50));
|
|
||||||
|
|
||||||
assertThat(result.totalElements()).isGreaterThan(0);
|
|
||||||
DocumentListItem item = result.items().get(0);
|
|
||||||
assertThat(item.id()).isNotNull();
|
|
||||||
assertThat(item.title()).isEqualTo("Kurrent Brief");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void detail_stillReturnsTrainingLabels() {
|
|
||||||
Document saved = documentRepository.save(Document.builder()
|
|
||||||
.title("Detail Test")
|
|
||||||
.originalFilename("detail_test.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
|
||||||
.build());
|
|
||||||
|
|
||||||
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
|
|
||||||
Document loaded = documentService.getDocumentById(saved.getId());
|
|
||||||
|
|
||||||
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
|
|
||||||
// No document id should appear on both pages — slicing must be exclusive.
|
// No document id should appear on both pages — slicing must be exclusive.
|
||||||
var idsOnPage0 = page0.items().stream()
|
var idsOnPage0 = page0.items().stream()
|
||||||
.map(item -> item.id())
|
.map(item -> item.document().getId())
|
||||||
.toList();
|
.toList();
|
||||||
var idsOnPage1 = page1.items().stream()
|
var idsOnPage1 = page1.items().stream()
|
||||||
.map(item -> item.id())
|
.map(item -> item.document().getId())
|
||||||
.toList();
|
.toList();
|
||||||
for (UUID id : idsOnPage0) {
|
for (UUID id : idsOnPage0) {
|
||||||
assertThat(idsOnPage1).doesNotContain(id);
|
assertThat(idsOnPage1).doesNotContain(id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.document;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -12,11 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class DocumentSearchResultTest {
|
class DocumentSearchResultTest {
|
||||||
|
|
||||||
private DocumentListItem item(UUID docId) {
|
private DocumentSearchItem item(UUID docId) {
|
||||||
return new DocumentListItem(
|
Document doc = Document.builder()
|
||||||
docId, "Test", "test.pdf", null, null, null,
|
.id(docId)
|
||||||
List.of(), List.of(), null, null, null, null,
|
.title("Test")
|
||||||
0, List.of(), SearchMatchData.empty());
|
.originalFilename("test.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -40,7 +45,7 @@ class DocumentSearchResultTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||||
List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||||
|
|
||||||
@@ -63,10 +68,9 @@ class DocumentSearchResultTest {
|
|||||||
void of_exposes_items_with_completion_and_contributors() {
|
void of_exposes_items_with_completion_and_contributors() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
||||||
DocumentListItem item = new DocumentListItem(
|
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
||||||
id, "T", "t.pdf", null, null, null,
|
.status(DocumentStatus.UPLOADED).build();
|
||||||
List.of(), List.of(), null, null, null, null,
|
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
||||||
75, List.of(actor), SearchMatchData.empty());
|
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
|
|||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
||||||
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
||||||
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSort.RELEVANCE, null, null, PAGE);
|
DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
|
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
|
||||||
import org.raddatz.familienarchiv.document.DocumentListItem;
|
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||||
@@ -1444,7 +1444,7 @@ class DocumentServiceTest {
|
|||||||
assertThat(result.totalPages()).isEqualTo(3);
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
assertThat(result.items()).hasSize(50);
|
assertThat(result.items()).hasSize(50);
|
||||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||||
assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
|
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1565,7 +1565,7 @@ class DocumentServiceTest {
|
|||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||||
@@ -1584,7 +1584,7 @@ class DocumentServiceTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,7 +1607,7 @@ class DocumentServiceTest {
|
|||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("smith doc", "Null lastname doc");
|
.containsExactly("smith doc", "Null lastname doc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -522,6 +522,7 @@
|
|||||||
"notification_filter_unread": "Ungelesen",
|
"notification_filter_unread": "Ungelesen",
|
||||||
"notification_filter_mention": "Erwähnung",
|
"notification_filter_mention": "Erwähnung",
|
||||||
"notification_filter_reply": "Antwort",
|
"notification_filter_reply": "Antwort",
|
||||||
|
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||||
"notification_load_more": "Ältere laden",
|
"notification_load_more": "Ältere laden",
|
||||||
"notification_empty_history": "Keine Benachrichtigungen",
|
"notification_empty_history": "Keine Benachrichtigungen",
|
||||||
|
|||||||
@@ -522,6 +522,7 @@
|
|||||||
"notification_filter_unread": "Unread",
|
"notification_filter_unread": "Unread",
|
||||||
"notification_filter_mention": "Mention",
|
"notification_filter_mention": "Mention",
|
||||||
"notification_filter_reply": "Reply",
|
"notification_filter_reply": "Reply",
|
||||||
|
"notification_error_generic": "Action failed. Please try again.",
|
||||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||||
"notification_load_more": "Load older",
|
"notification_load_more": "Load older",
|
||||||
"notification_empty_history": "No notifications",
|
"notification_empty_history": "No notifications",
|
||||||
|
|||||||
@@ -522,6 +522,7 @@
|
|||||||
"notification_filter_unread": "No leídas",
|
"notification_filter_unread": "No leídas",
|
||||||
"notification_filter_mention": "Mención",
|
"notification_filter_mention": "Mención",
|
||||||
"notification_filter_reply": "Respuesta",
|
"notification_filter_reply": "Respuesta",
|
||||||
|
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
|
||||||
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
||||||
"notification_load_more": "Cargar anteriores",
|
"notification_load_more": "Cargar anteriores",
|
||||||
"notification_empty_history": "Sin notificaciones",
|
"notification_empty_history": "Sin notificaciones",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
@@ -6,11 +7,13 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unread: NotificationItem[];
|
unread: NotificationItem[];
|
||||||
onMarkRead: (n: NotificationItem) => void;
|
optimisticMarkRead: (id: string) => void;
|
||||||
onMarkAllRead: () => void;
|
optimisticMarkAllRead: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
|
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
||||||
|
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
function verb(type: NotificationItem['type'], actor: string): string {
|
function verb(type: NotificationItem['type'], actor: string): string {
|
||||||
return type === 'REPLY'
|
return type === 'REPLY'
|
||||||
@@ -24,6 +27,9 @@ function href(n: NotificationItem): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="rounded-sm border border-line bg-surface p-5">
|
<section class="rounded-sm border border-line bg-surface p-5">
|
||||||
|
{#if errorMessage}
|
||||||
|
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
{#if unread.length === 0}
|
{#if unread.length === 0}
|
||||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
<svg
|
<svg
|
||||||
@@ -66,14 +72,28 @@ function href(n: NotificationItem): string {
|
|||||||
{m.chronik_for_you_count({ count: unread.length })}
|
{m.chronik_for_you_count({ count: unread.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
|
action="/aktivitaeten?/mark-all-read"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkAllRead();
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
data-testid="chronik-mark-all-read"
|
data-testid="chronik-mark-all-read"
|
||||||
onclick={onMarkAllRead}
|
|
||||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.chronik_mark_all_read()}
|
{m.chronik_mark_all_read()}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul role="list" class="flex flex-col gap-2">
|
<ul role="list" class="flex flex-col gap-2">
|
||||||
@@ -89,7 +109,7 @@ function href(n: NotificationItem): string {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
|
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
|
||||||
>
|
>
|
||||||
{n.type === 'MENTION' ? '@' : '\u21A9'}
|
{n.type === 'MENTION' ? '@' : '↩'}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-sans text-sm leading-snug text-ink">
|
<p class="font-sans text-sm leading-snug text-ink">
|
||||||
@@ -100,11 +120,25 @@ function href(n: NotificationItem): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<form
|
||||||
|
action="/aktivitaeten?/dismiss-notification"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkRead(n.id);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="notificationId" value={n.id} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
data-testid="chronik-fuerdich-dismiss"
|
data-testid="chronik-fuerdich-dismiss"
|
||||||
aria-label={m.chronik_mark_read_aria()}
|
aria-label={m.chronik_mark_read_aria()}
|
||||||
onclick={() => onMarkRead(n)}
|
|
||||||
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -119,6 +153,7 @@ function href(n: NotificationItem): string {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,7 +5,36 @@ import { page, userEvent } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
afterEach(cleanup);
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(
|
||||||
|
node: HTMLFormElement,
|
||||||
|
submit?: (opts: {
|
||||||
|
formData: FormData;
|
||||||
|
}) => (opts: {
|
||||||
|
result: { type: string; data?: Record<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
const handler = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cb = submit?.({ formData: new FormData(node) } as never);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
await (
|
||||||
|
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
||||||
return {
|
return {
|
||||||
@@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders inbox-zero state when there are no unread items', async () => {
|
it('renders inbox-zero state when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||||
expect(zero).not.toBeNull();
|
expect(zero).not.toBeNull();
|
||||||
@@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('links to the archived mentions in the inbox-zero state', async () => {
|
it('links to the archived mentions in the inbox-zero state', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders the count badge with correct total when unread exists', async () => {
|
it('renders the count badge with correct total when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -56,8 +85,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('count badge has aria-live=polite when unread exists', async () => {
|
it('count badge has aria-live=polite when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
// Wait for render
|
// Wait for render
|
||||||
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
||||||
@@ -69,8 +98,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
||||||
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
||||||
@@ -80,38 +109,38 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders the "Alle gelesen" button when unread exists', async () => {
|
it('renders the "Alle gelesen" button when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
|
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
const optimisticMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead
|
optimisticMarkAllRead
|
||||||
});
|
});
|
||||||
await userEvent.click(page.getByText('Alle gelesen'));
|
await userEvent.click(page.getByText('Alle gelesen'));
|
||||||
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
|
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
|
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
|
||||||
const onMarkRead = vi.fn();
|
const optimisticMarkRead = vi.fn();
|
||||||
const n = notif({ id: 'xyz' });
|
const n = notif({ id: 'xyz' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [n],
|
unread: [n],
|
||||||
onMarkRead,
|
optimisticMarkRead,
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
dismiss?.click();
|
dismiss?.click();
|
||||||
expect(onMarkRead).toHaveBeenCalledTimes(1);
|
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
|
||||||
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||||
@@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
annotationId: 'annot-9'
|
annotationId: 'annot-9'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector(
|
const link = document.querySelector(
|
||||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
||||||
@@ -136,8 +165,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'x' })],
|
unread: [notif({ id: 'x' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
@@ -145,4 +174,22 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||||
expect(dismiss?.closest('a')).toBeNull();
|
expect(dismiss?.closest('a')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
render(ChronikFuerDichBox, {
|
||||||
|
unread: [notif({ id: 'err-1' })],
|
||||||
|
optimisticMarkRead: vi.fn(),
|
||||||
|
optimisticMarkAllRead: vi.fn()
|
||||||
|
});
|
||||||
|
const dismiss = document.querySelector(
|
||||||
|
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(dismiss).not.toBeNull();
|
||||||
|
dismiss?.click();
|
||||||
|
// Allow microtask queue to flush
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const alert = document.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,36 @@ import { page } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
|
|
||||||
afterEach(cleanup);
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(
|
||||||
|
node: HTMLFormElement,
|
||||||
|
submit?: (opts: {
|
||||||
|
formData: FormData;
|
||||||
|
}) => (opts: {
|
||||||
|
result: { type: string; data?: Record<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
const handler = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cb = submit?.({ formData: new FormData(node) } as never);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
await (
|
||||||
|
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||||
id: 'n-1',
|
id: 'n-1',
|
||||||
@@ -22,7 +51,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
|
|||||||
describe('ChronikFuerDichBox', () => {
|
describe('ChronikFuerDichBox', () => {
|
||||||
it('renders the inbox-zero state when there are no unread', async () => {
|
it('renders the inbox-zero state when there are no unread', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||||
@@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ actorName: 'Bertha' })],
|
unread: [mention({ actorName: 'Bertha' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
|
||||||
const onMarkRead = vi.fn();
|
const optimisticMarkRead = vi.fn();
|
||||||
const item = mention({ id: 'n-7' });
|
const item = mention({ id: 'n-7' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
@@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
dismiss.click();
|
dismiss.click();
|
||||||
|
|
||||||
expect(onMarkRead).toHaveBeenCalledWith(item);
|
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
const optimisticMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention()],
|
unread: [mention()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead
|
optimisticMarkAllRead
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||||
btn.click();
|
btn.click();
|
||||||
|
|
||||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds a deep-link href to the comment for each notification', async () => {
|
it('builds a deep-link href to the comment for each notification', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||||
expect(link.getAttribute('href')).toContain('doc-x');
|
expect(link.getAttribute('href')).toContain('doc-x');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
render(ChronikFuerDichBox, {
|
||||||
|
props: {
|
||||||
|
unread: [mention({ id: 'err-1' })],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = document.querySelector(
|
||||||
|
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||||
|
) as HTMLElement;
|
||||||
|
dismiss.click();
|
||||||
|
// Allow microtask queue to flush
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const alert = document.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
|
|||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
|
||||||
type Document = components['schemas']['Document'];
|
type Document = components['schemas']['Document'];
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedDocuments?: Document[];
|
selectedDocuments?: Document[];
|
||||||
@@ -45,8 +45,8 @@ function handleInput() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const body: { items: DocumentListItem[] } = await res.json();
|
const body: { items: DocumentSearchItem[] } = await res.json();
|
||||||
const docs = body.items as unknown as Document[];
|
const docs = body.items.map((it) => it.document);
|
||||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
|||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: vi.fn().mockResolvedValue({ items })
|
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,10 @@ describe('DocumentMultiSelect — search and select', () => {
|
|||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: vi.fn().mockResolvedValue({
|
json: vi.fn().mockResolvedValue({
|
||||||
items: [docFactory('d1', 'Already attached'), docFactory('d2', 'Not attached')]
|
items: [
|
||||||
|
{ document: docFactory('d1', 'Already attached') },
|
||||||
|
{ document: docFactory('d2', 'Not attached') }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
|
|||||||
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
|
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
|
||||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||||
|
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
let { item, canWrite = false }: { item: DocumentListItem; canWrite?: boolean } = $props();
|
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||||
|
|
||||||
const doc = $derived(item);
|
const doc = $derived(item.document);
|
||||||
const titleText = $derived(doc.title || doc.originalFilename);
|
const titleText = $derived(doc.title || doc.originalFilename);
|
||||||
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
|
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
|
||||||
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
|
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
|
||||||
|
|||||||
@@ -14,17 +14,24 @@ afterEach(() => {
|
|||||||
bulkSelectionStore.clear();
|
bulkSelectionStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
|
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
|
||||||
return {
|
return {
|
||||||
|
document: {
|
||||||
id: '1',
|
id: '1',
|
||||||
title: 'Testbrief',
|
title: 'Testbrief',
|
||||||
originalFilename: 'testbrief.pdf',
|
originalFilename: 'testbrief.pdf',
|
||||||
|
status: 'UPLOADED',
|
||||||
documentDate: '2024-03-15',
|
documentDate: '2024-03-15',
|
||||||
sender: undefined,
|
sender: null,
|
||||||
receivers: [],
|
receivers: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN'
|
||||||
|
},
|
||||||
matchData: {
|
matchData: {
|
||||||
titleOffsets: [],
|
titleOffsets: [],
|
||||||
senderMatched: false,
|
senderMatched: false,
|
||||||
@@ -48,14 +55,14 @@ describe('DocumentRow – title', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to originalFilename when title is null', async () => {
|
it('falls back to originalFilename when title is null', async () => {
|
||||||
const item = makeItem({ title: null as unknown as string });
|
const item = makeItem({ document: { ...makeItem().document, title: null } });
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
|
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a mark element for highlighted title offsets', async () => {
|
it('renders a mark element for highlighted title offsets', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
title: 'Brief an Anna',
|
document: { ...makeItem().document, title: 'Brief an Anna' },
|
||||||
matchData: {
|
matchData: {
|
||||||
titleOffsets: [{ start: 0, length: 5 }],
|
titleOffsets: [{ start: 0, length: 5 }],
|
||||||
senderMatched: false,
|
senderMatched: false,
|
||||||
@@ -102,12 +109,9 @@ describe('DocumentRow – snippet', () => {
|
|||||||
describe('DocumentRow – sender', () => {
|
describe('DocumentRow – sender', () => {
|
||||||
it('shows sender display name', async () => {
|
it('shows sender display name', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
sender: {
|
document: {
|
||||||
id: 's1',
|
...makeItem().document,
|
||||||
lastName: 'Maria',
|
sender: { id: 's1', displayName: 'Großmutter Maria' }
|
||||||
displayName: 'Großmutter Maria',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
@@ -122,12 +126,9 @@ describe('DocumentRow – sender', () => {
|
|||||||
|
|
||||||
it('highlights the sender when senderMatched is true', async () => {
|
it('highlights the sender when senderMatched is true', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
sender: {
|
document: {
|
||||||
id: 's1',
|
...makeItem().document,
|
||||||
lastName: 'Maria',
|
sender: { id: 's1', displayName: 'Großmutter Maria' }
|
||||||
displayName: 'Großmutter Maria',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
matchData: {
|
matchData: {
|
||||||
...makeItem().matchData,
|
...makeItem().matchData,
|
||||||
@@ -141,15 +142,10 @@ describe('DocumentRow – sender', () => {
|
|||||||
|
|
||||||
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
|
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
receivers: [
|
document: {
|
||||||
{
|
...makeItem().document,
|
||||||
id: 'r1',
|
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }]
|
||||||
lastName: 'Karl',
|
},
|
||||||
displayName: 'Onkel Karl',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
matchData: {
|
matchData: {
|
||||||
...makeItem().matchData,
|
...makeItem().matchData,
|
||||||
matchedReceiverIds: ['r1']
|
matchedReceiverIds: ['r1']
|
||||||
@@ -166,7 +162,10 @@ describe('DocumentRow – sender', () => {
|
|||||||
describe('DocumentRow – summary', () => {
|
describe('DocumentRow – summary', () => {
|
||||||
it('renders the document summary when present', async () => {
|
it('renders the document summary when present', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
|
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect
|
await expect
|
||||||
@@ -181,7 +180,7 @@ describe('DocumentRow – summary', () => {
|
|||||||
|
|
||||||
it('applies summary search-match highlight via summaryOffsets', async () => {
|
it('applies summary search-match highlight via summaryOffsets', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
summary: 'Brief über Menton',
|
document: { ...makeItem().document, summary: 'Brief über Menton' },
|
||||||
matchData: {
|
matchData: {
|
||||||
...makeItem().matchData,
|
...makeItem().matchData,
|
||||||
summaryOffsets: [{ start: 11, length: 6 }]
|
summaryOffsets: [{ start: 11, length: 6 }]
|
||||||
@@ -197,19 +196,25 @@ describe('DocumentRow – summary', () => {
|
|||||||
|
|
||||||
describe('DocumentRow – archive chips', () => {
|
describe('DocumentRow – archive chips', () => {
|
||||||
it('renders the archive box chip when set', async () => {
|
it('renders the archive box chip when set', async () => {
|
||||||
const item = makeItem({ archiveBox: 'K3' });
|
const item = makeItem({
|
||||||
|
document: { ...makeItem().document, archiveBox: 'K3' }
|
||||||
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect.element(page.getByText('K3')).toBeInTheDocument();
|
await expect.element(page.getByText('K3')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the archive folder chip when set', async () => {
|
it('renders the archive folder chip when set', async () => {
|
||||||
const item = makeItem({ archiveFolder: 'Mappe A' });
|
const item = makeItem({
|
||||||
|
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
|
||||||
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
|
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the location chip when meta_location is set', async () => {
|
it('renders the location chip when meta_location is set', async () => {
|
||||||
const item = makeItem({ location: 'Berlin' });
|
const item = makeItem({
|
||||||
|
document: { ...makeItem().document, location: 'Berlin' }
|
||||||
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -220,7 +225,10 @@ describe('DocumentRow – archive chips', () => {
|
|||||||
describe('DocumentRow – tags', () => {
|
describe('DocumentRow – tags', () => {
|
||||||
it('renders tag buttons', async () => {
|
it('renders tag buttons', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
tags: [{ id: 't1', name: 'Familie' }]
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
|
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
||||||
@@ -228,7 +236,10 @@ describe('DocumentRow – tags', () => {
|
|||||||
|
|
||||||
it('navigates to /documents?tag=… on tag click', async () => {
|
it('navigates to /documents?tag=… on tag click', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
tags: [{ id: 't1', name: 'Urlaub & Reise' }]
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
|
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
// Tailwind CSS isn't loaded in the vitest-browser client project, so the
|
// Tailwind CSS isn't loaded in the vitest-browser client project, so the
|
||||||
@@ -244,7 +255,10 @@ describe('DocumentRow – tags', () => {
|
|||||||
|
|
||||||
it('tag click does not navigate to the document detail page', async () => {
|
it('tag click does not navigate to the document detail page', async () => {
|
||||||
const item = makeItem({
|
const item = makeItem({
|
||||||
tags: [{ id: 't2', name: 'Familie' }]
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
|
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
render(DocumentRow, { item });
|
render(DocumentRow, { item });
|
||||||
const before = window.location.href;
|
const before = window.location.href;
|
||||||
@@ -267,7 +281,7 @@ describe('DocumentRow – bulk selection checkbox', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('checkbox aria-label includes the document title', async () => {
|
it('checkbox aria-label includes the document title', async () => {
|
||||||
const item = makeItem({ title: 'Brief an Anna' });
|
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||||
render(DocumentRow, { item, canWrite: true });
|
render(DocumentRow, { item, canWrite: true });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||||
@@ -275,7 +289,7 @@ describe('DocumentRow – bulk selection checkbox', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||||
const item = makeItem({ id: 'doc-42' });
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||||
render(DocumentRow, { item, canWrite: true });
|
render(DocumentRow, { item, canWrite: true });
|
||||||
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||||
|
|
||||||
@@ -286,7 +300,7 @@ describe('DocumentRow – bulk selection checkbox', () => {
|
|||||||
|
|
||||||
it('checked state mirrors the store', async () => {
|
it('checked state mirrors the store', async () => {
|
||||||
bulkSelectionStore.add('doc-99');
|
bulkSelectionStore.add('doc-99');
|
||||||
const item = makeItem({ id: 'doc-99' });
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||||
render(DocumentRow, { item, canWrite: true });
|
render(DocumentRow, { item, canWrite: true });
|
||||||
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,31 +20,10 @@ const { default: DocumentRow } = await import('./DocumentRow.svelte');
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const sender = {
|
const sender = { id: 's1', displayName: 'Anna Schmidt' };
|
||||||
id: 's1',
|
const receiver = { id: 'r1', displayName: 'Bert Meier' };
|
||||||
lastName: 'Schmidt',
|
|
||||||
displayName: 'Anna Schmidt',
|
|
||||||
personType: 'PERSON' as const,
|
|
||||||
familyMember: false
|
|
||||||
};
|
|
||||||
const receiver = {
|
|
||||||
id: 'r1',
|
|
||||||
lastName: 'Meier',
|
|
||||||
displayName: 'Bert Meier',
|
|
||||||
personType: 'PERSON' as const,
|
|
||||||
familyMember: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyMatchData = {
|
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||||
titleOffsets: [],
|
|
||||||
senderMatched: false,
|
|
||||||
matchedReceiverIds: [],
|
|
||||||
matchedTagIds: [],
|
|
||||||
snippetOffsets: [],
|
|
||||||
summaryOffsets: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseItem = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: 'd1',
|
id: 'd1',
|
||||||
title: 'Brief 1923',
|
title: 'Brief 1923',
|
||||||
originalFilename: 'b.pdf',
|
originalFilename: 'b.pdf',
|
||||||
@@ -52,16 +31,22 @@ const baseItem = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
sender,
|
sender,
|
||||||
receivers: [receiver],
|
receivers: [receiver],
|
||||||
tags: [],
|
tags: [],
|
||||||
summary: undefined,
|
thumbnailUrl: null,
|
||||||
archiveBox: undefined,
|
contentType: 'application/pdf',
|
||||||
archiveFolder: undefined,
|
summary: null,
|
||||||
location: undefined,
|
archiveBox: null,
|
||||||
matchData: emptyMatchData,
|
archiveFolder: null,
|
||||||
completionPercentage: 0,
|
location: null,
|
||||||
contributors: [],
|
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseItem = (docOverrides: Record<string, unknown> = {}) => ({
|
||||||
|
document: makeDoc(docOverrides),
|
||||||
|
matchData: null,
|
||||||
|
completionPercentage: 0,
|
||||||
|
contributors: []
|
||||||
|
});
|
||||||
|
|
||||||
describe('DocumentRow', () => {
|
describe('DocumentRow', () => {
|
||||||
it('renders the title', async () => {
|
it('renders the title', async () => {
|
||||||
render(DocumentRow, { props: { item: baseItem() } });
|
render(DocumentRow, { props: { item: baseItem() } });
|
||||||
@@ -136,9 +121,12 @@ describe('DocumentRow', () => {
|
|||||||
it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
|
it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
|
||||||
render(DocumentRow, {
|
render(DocumentRow, {
|
||||||
props: {
|
props: {
|
||||||
item: baseItem({
|
item: {
|
||||||
matchData: { ...emptyMatchData, transcriptionSnippet: 'Hello world snippet' }
|
document: makeDoc(),
|
||||||
})
|
matchData: { transcriptionSnippet: 'Hello world snippet' },
|
||||||
|
completionPercentage: 50,
|
||||||
|
contributors: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2068,20 +2068,12 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ImportStatus: {
|
ImportStatus: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||||
statusCode: string;
|
statusCode?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
processed: number;
|
processed?: number;
|
||||||
skippedFiles: components["schemas"]["SkippedFile"][];
|
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
/** Format: int32 */
|
|
||||||
skipped?: number;
|
|
||||||
};
|
|
||||||
SkippedFile: {
|
|
||||||
filename: string;
|
|
||||||
/** @enum {string} */
|
|
||||||
reason: "INVALID_FILENAME_PATH_TRAVERSAL" | "INVALID_PDF_SIGNATURE" | "FILE_READ_ERROR" | "ALREADY_EXISTS" | "S3_UPLOAD_FAILED";
|
|
||||||
};
|
};
|
||||||
BackfillStatus: {
|
BackfillStatus: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
@@ -2205,10 +2197,10 @@ export interface components {
|
|||||||
totalStories: number;
|
totalStories: number;
|
||||||
};
|
};
|
||||||
PersonSummaryDTO: {
|
PersonSummaryDTO: {
|
||||||
|
title?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id?: string;
|
id?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
title?: string;
|
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
@@ -2315,14 +2307,14 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
size?: number;
|
size?: number;
|
||||||
content?: components["schemas"]["NotificationDTO"][];
|
content?: components["schemas"]["NotificationDTO"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
|
first?: boolean;
|
||||||
|
last?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
@@ -2388,28 +2380,15 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
};
|
};
|
||||||
DocumentListItem: {
|
DocumentSearchItem: {
|
||||||
/** Format: uuid */
|
document: components["schemas"]["Document"];
|
||||||
id: string;
|
matchData: components["schemas"]["SearchMatchData"];
|
||||||
title: string;
|
|
||||||
originalFilename: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
/** Format: date */
|
|
||||||
documentDate?: string;
|
|
||||||
sender?: components["schemas"]["Person"];
|
|
||||||
receivers: components["schemas"]["Person"][];
|
|
||||||
tags: components["schemas"]["Tag"][];
|
|
||||||
archiveBox?: string;
|
|
||||||
archiveFolder?: string;
|
|
||||||
location?: string;
|
|
||||||
summary?: string;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
completionPercentage: number;
|
completionPercentage: number;
|
||||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||||
matchData: components["schemas"]["SearchMatchData"];
|
|
||||||
};
|
};
|
||||||
DocumentSearchResult: {
|
DocumentSearchResult: {
|
||||||
items: components["schemas"]["DocumentListItem"][];
|
items: components["schemas"]["DocumentSearchItem"][];
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
totalElements: number;
|
totalElements: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -30,17 +28,6 @@ function closeDropdown() {
|
|||||||
bellButtonEl?.focus();
|
bellButtonEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
|
||||||
await stream.markRead(notification);
|
|
||||||
const url = buildCommentHref(
|
|
||||||
notification.documentId,
|
|
||||||
notification.referenceId,
|
|
||||||
notification.annotationId
|
|
||||||
);
|
|
||||||
closeDropdown();
|
|
||||||
goto(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && open) {
|
if (event.key === 'Escape' && open) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -113,8 +100,8 @@ onDestroy(() => {
|
|||||||
{#if open}
|
{#if open}
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
notifications={stream.notifications}
|
notifications={stream.notifications}
|
||||||
onMarkRead={handleMarkRead}
|
optimisticMarkRead={stream.optimisticMarkRead}
|
||||||
onMarkAllRead={stream.markAllRead}
|
optimisticMarkAllRead={stream.optimisticMarkAllRead}
|
||||||
onClose={closeDropdown}
|
onClose={closeDropdown}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
import NotificationBell from './NotificationBell.svelte';
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit?.({ formData: new FormData(node) } as never);
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
||||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||||
@@ -17,18 +25,17 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
|
|||||||
get unreadCount() {
|
get unreadCount() {
|
||||||
return mockNotificationList.value.length;
|
return mockNotificationList.value.length;
|
||||||
},
|
},
|
||||||
markRead: mockMarkRead,
|
optimisticMarkRead: vi.fn(),
|
||||||
|
optimisticMarkAllRead: vi.fn(),
|
||||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn()
|
||||||
markAllRead: vi.fn()
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
gotoMock.mockClear();
|
vi.clearAllMocks();
|
||||||
mockMarkRead.mockClear();
|
|
||||||
mockNotificationList.value = [];
|
mockNotificationList.value = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,16 +52,6 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openDropdownAndClickFirstNotification() {
|
|
||||||
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
||||||
bellButton.click();
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
|
|
||||||
notifButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('NotificationBell — cursor and tooltip', () => {
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
it('bell button has cursor-pointer class', async () => {
|
it('bell button has cursor-pointer class', async () => {
|
||||||
render(NotificationBell);
|
render(NotificationBell);
|
||||||
@@ -82,29 +79,3 @@ describe('NotificationBell — cursor and tooltip', () => {
|
|||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
|
||||||
render(NotificationBell);
|
|
||||||
|
|
||||||
await openDropdownAndClickFirstNotification();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(gotoMock).toHaveBeenCalledWith(
|
|
||||||
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
|
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: null })];
|
|
||||||
render(NotificationBell);
|
|
||||||
|
|
||||||
await openDropdownAndClickFirstNotification();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
|
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
onMarkRead: (notification: NotificationItem) => void;
|
optimisticMarkRead: (id: string) => void;
|
||||||
onMarkAllRead: () => void;
|
optimisticMarkAllRead: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
function handleViewAll() {
|
function handleViewAll() {
|
||||||
onClose(); // close first — avoids stale dropdown during navigation transition
|
onClose(); // close first — avoids stale dropdown during navigation transition
|
||||||
@@ -31,16 +35,35 @@ function handleViewAll() {
|
|||||||
{m.notification_bell_label()}
|
{m.notification_bell_label()}
|
||||||
</span>
|
</span>
|
||||||
{#if notifications.length > 0}
|
{#if notifications.length > 0}
|
||||||
|
<form
|
||||||
|
action="/aktivitaeten?/mark-all-read"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkAllRead();
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onclick={onMarkAllRead}
|
|
||||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
{m.notification_mark_all_read()}
|
{m.notification_mark_all_read()}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
|
||||||
|
{#if errorMessage}
|
||||||
|
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Notification list -->
|
<!-- Notification list -->
|
||||||
{#if notifications.length === 0}
|
{#if notifications.length === 0}
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
@@ -66,10 +89,35 @@ function handleViewAll() {
|
|||||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||||
{#each notifications as notification (notification.id)}
|
{#each notifications as notification (notification.id)}
|
||||||
<li>
|
<li>
|
||||||
|
<form
|
||||||
|
action="/aktivitaeten?/dismiss-notification"
|
||||||
|
method="POST"
|
||||||
|
class="contents"
|
||||||
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkRead(notification.id);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
} else {
|
||||||
|
// Navigate away — no need to update the store since we're leaving the page
|
||||||
|
onClose();
|
||||||
|
goto(
|
||||||
|
buildCommentHref(
|
||||||
|
notification.documentId,
|
||||||
|
notification.referenceId,
|
||||||
|
notification.annotationId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="notificationId" value={notification.id} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onclick={() => onMarkRead(notification)}
|
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas
|
||||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
|
||||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
>
|
>
|
||||||
<!-- Type icon -->
|
<!-- Type icon -->
|
||||||
@@ -127,6 +175,7 @@ function handleViewAll() {
|
|||||||
></span>
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -6,9 +6,38 @@ import NotificationDropdown from './NotificationDropdown.svelte';
|
|||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
// Configurable result for the enhance mock — tests that need failure set
|
||||||
|
// mockFormResult.type = 'failure' before clicking.
|
||||||
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
|
// Invoke the SubmitFunction and always call the returned result callback with
|
||||||
|
// mockFormResult so tests can exercise both success and failure branches.
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(
|
||||||
|
node: HTMLFormElement,
|
||||||
|
submit?: (opts: {
|
||||||
|
formData: FormData;
|
||||||
|
}) => (opts: {
|
||||||
|
result: { type: string; data?: Record<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
const handler = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cb = submit?.({ formData: new FormData(node) } as never);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
await cb({ result: mockFormResult, update: async () => {} } as never);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockFormResult.type = 'success'; // reset to default after each test
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -29,8 +58,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -42,8 +71,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -55,8 +84,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -70,8 +99,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,8 +112,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,8 +127,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,8 +145,8 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', read: false }),
|
makeNotification({ id: 'n1', read: false }),
|
||||||
makeNotification({ id: 'n2', read: true })
|
makeNotification({ id: 'n2', read: true })
|
||||||
],
|
],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -126,37 +155,100 @@ describe('NotificationDropdown', () => {
|
|||||||
expect(unreadDots.length).toBe(1);
|
expect(unreadDots.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkRead with the notification when an item is clicked', async () => {
|
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
|
||||||
const onMarkRead = vi.fn();
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification({ id: 'n42' })],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form?.getAttribute('method')).toBe('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification({ id: 'n42' })],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.querySelector<HTMLInputElement>(
|
||||||
|
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
|
||||||
|
);
|
||||||
|
expect(input?.value).toBe('n42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
|
||||||
|
const optimisticMarkRead = vi.fn();
|
||||||
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [n],
|
notifications: [n],
|
||||||
onMarkRead,
|
optimisticMarkRead,
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||||
|
|
||||||
expect(onMarkRead).toHaveBeenCalledWith(n);
|
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead,
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form?.getAttribute('method')).toBe('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
|
||||||
|
const optimisticMarkAllRead = vi.fn();
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification()],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead,
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||||
|
|
||||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification()],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||||
|
|
||||||
|
const alert = document.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClose when the view-all button is clicked', async () => {
|
it('calls onClose when the view-all button is clicked', async () => {
|
||||||
@@ -164,8 +256,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -179,8 +271,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -193,12 +285,15 @@ describe('NotificationDropdown', () => {
|
|||||||
it('calls onClose before navigating to /aktivitaeten', async () => {
|
it('calls onClose before navigating to /aktivitaeten', async () => {
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const onClose = vi.fn(() => callOrder.push('close'));
|
const onClose = vi.fn(() => callOrder.push('close'));
|
||||||
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
|
vi.mocked(goto).mockImplementation(() => {
|
||||||
|
callOrder.push('goto');
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -212,8 +307,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -225,8 +320,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -242,14 +337,78 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', actorName: 'First' }),
|
makeNotification({ id: 'n1', actorName: 'First' }),
|
||||||
makeNotification({ id: 'n2', actorName: 'Second' })
|
makeNotification({ id: 'n2', actorName: 'Second' })
|
||||||
],
|
],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = document.querySelectorAll('button[type="button"]');
|
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||||
// At least 2 items + mark-all button
|
expect(forms.length).toBe(2);
|
||||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
});
|
||||||
|
|
||||||
|
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const n = makeNotification({
|
||||||
|
id: 'n42',
|
||||||
|
documentId: 'd1',
|
||||||
|
referenceId: 'c1',
|
||||||
|
annotationId: null,
|
||||||
|
actorName: 'Anna'
|
||||||
|
});
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
|
||||||
|
const n = makeNotification({
|
||||||
|
id: 'n55',
|
||||||
|
documentId: 'd1',
|
||||||
|
referenceId: 'c1',
|
||||||
|
annotationId: 'a1',
|
||||||
|
actorName: 'Eva'
|
||||||
|
});
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,12 +108,46 @@ describe('notificationStore (singleton)', () => {
|
|||||||
expect(notificationStore.unreadCount).toBe(1);
|
expect(notificationStore.unreadCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('markAllRead resets unreadCount', async () => {
|
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
notificationStore.init();
|
||||||
await notificationStore.markAllRead();
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
notificationStore.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
|
expect(notificationStore.notifications[0].read).toBe(true);
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
const notification = makeNotification({ id: 'sse-1', read: true });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
|
notificationStore.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
lastEventSource!.simulate(
|
||||||
|
'notification',
|
||||||
|
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
||||||
|
);
|
||||||
|
lastEventSource!.simulate(
|
||||||
|
'notification',
|
||||||
|
JSON.stringify(makeNotification({ id: 'n2', read: false }))
|
||||||
|
);
|
||||||
|
mockFetch.mockReset();
|
||||||
|
|
||||||
|
notificationStore.optimisticMarkAllRead();
|
||||||
|
|
||||||
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,28 +35,19 @@ async function fetchUnreadCount(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(notification: NotificationItem): Promise<void> {
|
function optimisticMarkRead(id: string): void {
|
||||||
if (!notification.read) {
|
const notification = notifications.find((n) => n.id === id);
|
||||||
try {
|
if (notification && !notification.read) {
|
||||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
|
||||||
notification.read = true;
|
notification.read = true;
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark notification as read', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead(): Promise<void> {
|
function optimisticMarkAllRead(): void {
|
||||||
try {
|
|
||||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
n.read = true;
|
n.read = true;
|
||||||
}
|
}
|
||||||
unreadCount = 0;
|
unreadCount = 0;
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark all notifications as read', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(): void {
|
function init(): void {
|
||||||
@@ -123,8 +114,8 @@ export const notificationStore = {
|
|||||||
},
|
},
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
markRead,
|
optimisticMarkRead,
|
||||||
markAllRead,
|
optimisticMarkAllRead,
|
||||||
init,
|
init,
|
||||||
destroy
|
destroy
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import DocumentRow from '$lib/document/DocumentRow.svelte';
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
|
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ let {
|
|||||||
q = '',
|
q = '',
|
||||||
sort = 'DATE'
|
sort = 'DATE'
|
||||||
}: {
|
}: {
|
||||||
items: DocumentListItem[];
|
items: DocumentSearchItem[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -31,10 +31,10 @@ const groups = $derived.by(() => {
|
|||||||
return groupByYear(items);
|
return groupByYear(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
function groupByYear(docItems: DocumentListItem[]) {
|
function groupByYear(docItems: DocumentSearchItem[]) {
|
||||||
const map = new SvelteMap<string, DocumentListItem[]>();
|
const map = new SvelteMap<string, DocumentSearchItem[]>();
|
||||||
for (const item of docItems) {
|
for (const item of docItems) {
|
||||||
const label = item.documentDate?.substring(0, 4) ?? m.docs_group_undated();
|
const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated();
|
||||||
const bucket = map.get(label);
|
const bucket = map.get(label);
|
||||||
if (bucket) bucket.push(item);
|
if (bucket) bucket.push(item);
|
||||||
else map.set(label, [item]);
|
else map.set(label, [item]);
|
||||||
@@ -42,10 +42,10 @@ function groupByYear(docItems: DocumentListItem[]) {
|
|||||||
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
|
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupBySender(docItems: DocumentListItem[]) {
|
function groupBySender(docItems: DocumentSearchItem[]) {
|
||||||
const map = new SvelteMap<string, DocumentListItem[]>();
|
const map = new SvelteMap<string, DocumentSearchItem[]>();
|
||||||
for (const item of docItems) {
|
for (const item of docItems) {
|
||||||
const label = item.sender?.displayName ?? m.docs_group_unknown_sender();
|
const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender();
|
||||||
const bucket = map.get(label);
|
const bucket = map.get(label);
|
||||||
if (bucket) bucket.push(item);
|
if (bucket) bucket.push(item);
|
||||||
else map.set(label, [item]);
|
else map.set(label, [item]);
|
||||||
@@ -53,10 +53,10 @@ function groupBySender(docItems: DocumentListItem[]) {
|
|||||||
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
|
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByReceiver(docItems: DocumentListItem[]) {
|
function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||||
const map = new SvelteMap<string, DocumentListItem[]>();
|
const map = new SvelteMap<string, DocumentSearchItem[]>();
|
||||||
for (const item of docItems) {
|
for (const item of docItems) {
|
||||||
const receivers = item.receivers ?? [];
|
const receivers = item.document.receivers ?? [];
|
||||||
const labels =
|
const labels =
|
||||||
receivers.length > 0
|
receivers.length > 0
|
||||||
? receivers.map((r) => r.displayName)
|
? receivers.map((r) => r.displayName)
|
||||||
@@ -99,7 +99,7 @@ function groupByReceiver(docItems: DocumentListItem[]) {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-line">
|
<ul class="divide-y divide-line">
|
||||||
{#each group.items as item (group.label + '-' + item.id)}
|
{#each group.items as item (group.label + '-' + item.document.id)}
|
||||||
<DocumentRow item={item} canWrite={canWrite} />
|
<DocumentRow item={item} canWrite={canWrite} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
|
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
|
||||||
return {
|
return {
|
||||||
|
document: {
|
||||||
id: '1',
|
id: '1',
|
||||||
title: 'Testbrief',
|
title: 'Testbrief',
|
||||||
originalFilename: 'testbrief.pdf',
|
originalFilename: 'testbrief.pdf',
|
||||||
|
status: 'UPLOADED',
|
||||||
documentDate: '2024-03-15',
|
documentDate: '2024-03-15',
|
||||||
sender: undefined,
|
sender: undefined,
|
||||||
receivers: [],
|
receivers: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN'
|
||||||
|
},
|
||||||
matchData: {
|
matchData: {
|
||||||
titleOffsets: [],
|
titleOffsets: [],
|
||||||
senderMatched: false,
|
senderMatched: false,
|
||||||
@@ -68,8 +75,8 @@ describe('DocumentList – empty state', () => {
|
|||||||
describe('DocumentList – year grouping', () => {
|
describe('DocumentList – year grouping', () => {
|
||||||
it('groups documents by year into separate cards', async () => {
|
it('groups documents by year into separate cards', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({ id: '1', documentDate: '1923-04-12' }),
|
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }),
|
||||||
makeItem({ id: '2', documentDate: '1965-08-03' })
|
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } })
|
||||||
];
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 2 });
|
render(DocumentList, { ...baseProps, items, total: 2 });
|
||||||
const groupCards = page.getByTestId('group-card');
|
const groupCards = page.getByTestId('group-card');
|
||||||
@@ -78,15 +85,17 @@ describe('DocumentList – year grouping', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses undated label for items with no documentDate', async () => {
|
it('uses undated label for items with no documentDate', async () => {
|
||||||
const items = [makeItem({ id: '1', documentDate: undefined })];
|
const items = [
|
||||||
|
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
|
||||||
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||||
await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
|
await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('single year renders one group-card', async () => {
|
it('single year renders one group-card', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({ id: '1', documentDate: '1938-01-01' }),
|
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }),
|
||||||
makeItem({ id: '2', documentDate: '1938-06-15' })
|
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } })
|
||||||
];
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 2 });
|
render(DocumentList, { ...baseProps, items, total: 2 });
|
||||||
const groupCards = page.getByTestId('group-card');
|
const groupCards = page.getByTestId('group-card');
|
||||||
@@ -99,7 +108,9 @@ describe('DocumentList – year grouping', () => {
|
|||||||
|
|
||||||
describe('DocumentList – sort fallback', () => {
|
describe('DocumentList – sort fallback', () => {
|
||||||
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
|
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
|
||||||
const items = [makeItem({ id: '1', documentDate: '2024-03-15' })];
|
const items = [
|
||||||
|
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } })
|
||||||
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
|
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByTestId('group-header').filter({ hasText: '2024' }))
|
.element(page.getByTestId('group-header').filter({ hasText: '2024' }))
|
||||||
@@ -113,6 +124,8 @@ describe('DocumentList – sender grouping', () => {
|
|||||||
it('groups by sender displayName when sort is SENDER', async () => {
|
it('groups by sender displayName when sort is SENDER', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({
|
makeItem({
|
||||||
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
id: '1',
|
id: '1',
|
||||||
sender: {
|
sender: {
|
||||||
id: 's1',
|
id: 's1',
|
||||||
@@ -121,8 +134,11 @@ describe('DocumentList – sender grouping', () => {
|
|||||||
personType: 'PERSON',
|
personType: 'PERSON',
|
||||||
familyMember: false
|
familyMember: false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
makeItem({
|
makeItem({
|
||||||
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
id: '2',
|
id: '2',
|
||||||
sender: {
|
sender: {
|
||||||
id: 's2',
|
id: 's2',
|
||||||
@@ -131,6 +147,7 @@ describe('DocumentList – sender grouping', () => {
|
|||||||
personType: 'PERSON',
|
personType: 'PERSON',
|
||||||
familyMember: false
|
familyMember: false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
|
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
|
||||||
@@ -150,7 +167,10 @@ describe('DocumentList – sender grouping', () => {
|
|||||||
personType: 'PERSON' as const,
|
personType: 'PERSON' as const,
|
||||||
familyMember: false
|
familyMember: false
|
||||||
};
|
};
|
||||||
const items = [makeItem({ id: '1', sender }), makeItem({ id: '2', sender })];
|
const items = [
|
||||||
|
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
|
||||||
|
makeItem({ document: { ...makeItem().document, id: '2', sender } })
|
||||||
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
|
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
|
||||||
const cards = page.getByTestId('group-card');
|
const cards = page.getByTestId('group-card');
|
||||||
await expect.element(cards.first()).toBeInTheDocument();
|
await expect.element(cards.first()).toBeInTheDocument();
|
||||||
@@ -158,7 +178,7 @@ describe('DocumentList – sender grouping', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('places items with no sender under fallback label', async () => {
|
it('places items with no sender under fallback label', async () => {
|
||||||
const items = [makeItem({ id: '1', sender: undefined })];
|
const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
|
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
|
||||||
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
|
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -170,6 +190,8 @@ describe('DocumentList – receiver grouping', () => {
|
|||||||
it('groups by receiver displayName when sort is RECEIVER', async () => {
|
it('groups by receiver displayName when sort is RECEIVER', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({
|
makeItem({
|
||||||
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
id: '1',
|
id: '1',
|
||||||
receivers: [
|
receivers: [
|
||||||
{
|
{
|
||||||
@@ -180,6 +202,7 @@ describe('DocumentList – receiver grouping', () => {
|
|||||||
familyMember: false
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
||||||
@@ -191,6 +214,8 @@ describe('DocumentList – receiver grouping', () => {
|
|||||||
it('duplicates a document into each receiver group', async () => {
|
it('duplicates a document into each receiver group', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({
|
makeItem({
|
||||||
|
document: {
|
||||||
|
...makeItem().document,
|
||||||
id: '1',
|
id: '1',
|
||||||
title: 'Rundbriefchen',
|
title: 'Rundbriefchen',
|
||||||
receivers: [
|
receivers: [
|
||||||
@@ -209,6 +234,7 @@ describe('DocumentList – receiver grouping', () => {
|
|||||||
familyMember: false
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
||||||
@@ -223,7 +249,7 @@ describe('DocumentList – receiver grouping', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('places items with no receivers under fallback label', async () => {
|
it('places items with no receivers under fallback label', async () => {
|
||||||
const items = [makeItem({ id: '1', receivers: [] })];
|
const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
|
||||||
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
|
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -235,7 +261,7 @@ describe('DocumentList – DocumentRow delegation', () => {
|
|||||||
it('shows transcription snippet when matchData has one', async () => {
|
it('shows transcription snippet when matchData has one', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({
|
makeItem({
|
||||||
id: 'doc1',
|
document: { ...makeItem().document, id: 'doc1' },
|
||||||
matchData: {
|
matchData: {
|
||||||
transcriptionSnippet: 'Er schrieb einen langen Brief',
|
transcriptionSnippet: 'Er schrieb einen langen Brief',
|
||||||
titleOffsets: [],
|
titleOffsets: [],
|
||||||
@@ -252,7 +278,7 @@ describe('DocumentList – DocumentRow delegation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not render snippet when matchData has no transcription snippet', async () => {
|
it('does not render snippet when matchData has no transcription snippet', async () => {
|
||||||
const items = [makeItem({ id: 'doc1' })];
|
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })];
|
||||||
render(DocumentList, { ...baseProps, items, total: 1 });
|
render(DocumentList, { ...baseProps, items, total: 1 });
|
||||||
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
|
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -260,8 +286,7 @@ describe('DocumentList – DocumentRow delegation', () => {
|
|||||||
it('renders mark for title highlight when titleOffsets present', async () => {
|
it('renders mark for title highlight when titleOffsets present', async () => {
|
||||||
const items = [
|
const items = [
|
||||||
makeItem({
|
makeItem({
|
||||||
id: 'doc1',
|
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' },
|
||||||
title: 'Brief an Anna',
|
|
||||||
matchData: {
|
matchData: {
|
||||||
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
|
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
|
||||||
senderMatched: false,
|
senderMatched: false,
|
||||||
|
|||||||
@@ -20,31 +20,11 @@ const { default: DocumentList } = await import('./DocumentList.svelte');
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const sender = {
|
const sender = { id: 's1', displayName: 'Anna Schmidt' };
|
||||||
id: 's1',
|
const receiver = { id: 'r1', displayName: 'Bert Meier' };
|
||||||
lastName: 'Schmidt',
|
|
||||||
displayName: 'Anna Schmidt',
|
|
||||||
personType: 'PERSON' as const,
|
|
||||||
familyMember: false
|
|
||||||
};
|
|
||||||
const receiver = {
|
|
||||||
id: 'r1',
|
|
||||||
lastName: 'Meier',
|
|
||||||
displayName: 'Bert Meier',
|
|
||||||
personType: 'PERSON' as const,
|
|
||||||
familyMember: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyMatchData = {
|
|
||||||
titleOffsets: [],
|
|
||||||
senderMatched: false,
|
|
||||||
matchedReceiverIds: [],
|
|
||||||
matchedTagIds: [],
|
|
||||||
snippetOffsets: [],
|
|
||||||
summaryOffsets: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeItem = (overrides: Record<string, unknown> = {}) => ({
|
const makeItem = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
document: {
|
||||||
id: 'd1',
|
id: 'd1',
|
||||||
title: 'Brief 1923',
|
title: 'Brief 1923',
|
||||||
originalFilename: 'b.pdf',
|
originalFilename: 'b.pdf',
|
||||||
@@ -52,14 +32,17 @@ const makeItem = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
sender,
|
sender,
|
||||||
receivers: [receiver],
|
receivers: [receiver],
|
||||||
tags: [],
|
tags: [],
|
||||||
summary: undefined,
|
thumbnailUrl: null,
|
||||||
archiveBox: undefined,
|
contentType: 'application/pdf',
|
||||||
archiveFolder: undefined,
|
summary: null,
|
||||||
location: undefined,
|
archiveBox: null,
|
||||||
matchData: emptyMatchData,
|
archiveFolder: null,
|
||||||
completionPercentage: 0,
|
location: null,
|
||||||
contributors: [],
|
|
||||||
...overrides
|
...overrides
|
||||||
|
},
|
||||||
|
matchData: null,
|
||||||
|
completionPercentage: 0,
|
||||||
|
contributors: []
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DocumentList', () => {
|
describe('DocumentList', () => {
|
||||||
@@ -104,26 +87,8 @@ describe('DocumentList', () => {
|
|||||||
render(DocumentList, {
|
render(DocumentList, {
|
||||||
props: {
|
props: {
|
||||||
items: [
|
items: [
|
||||||
makeItem({
|
makeItem({ id: 'd1', sender: { id: 's1', displayName: 'Anna Schmidt' } }),
|
||||||
id: 'd1',
|
makeItem({ id: 'd2', sender: { id: 's2', displayName: 'Bert Meier' } })
|
||||||
sender: {
|
|
||||||
id: 's1',
|
|
||||||
lastName: 'Schmidt',
|
|
||||||
displayName: 'Anna Schmidt',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
makeItem({
|
|
||||||
id: 'd2',
|
|
||||||
sender: {
|
|
||||||
id: 's2',
|
|
||||||
lastName: 'Meier',
|
|
||||||
displayName: 'Bert Meier',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
],
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
sort: 'SENDER' as const
|
sort: 'SENDER' as const
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components, operations } from '$lib/generated/api';
|
import type { components, operations } from '$lib/generated/api';
|
||||||
|
|
||||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
@@ -65,3 +67,31 @@ export async function load({ fetch, url }) {
|
|||||||
loadError
|
loadError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
'dismiss-notification': async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const raw = data.get('notificationId');
|
||||||
|
const notificationId = typeof raw === 'string' ? raw : null;
|
||||||
|
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.PATCH('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id: notificationId } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
'mark-all-read': async ({ fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.POST('/api/notifications/read-all');
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -76,14 +76,6 @@ async function onFilterChange(v: FilterValue) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMarkRead(n: NotificationItem) {
|
|
||||||
await notificationStore.markRead(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMarkAllRead() {
|
|
||||||
await notificationStore.markAllRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
||||||
|
|
||||||
const isEmpty = $derived(displayFeed.length === 0);
|
const isEmpty = $derived(displayFeed.length === 0);
|
||||||
@@ -108,7 +100,11 @@ function retry() {
|
|||||||
{#if data.loadError === 'activity'}
|
{#if data.loadError === 'activity'}
|
||||||
<ChronikErrorCard onRetry={retry} />
|
<ChronikErrorCard onRetry={retry} />
|
||||||
{:else}
|
{:else}
|
||||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
<ChronikFuerDichBox
|
||||||
|
unread={unread}
|
||||||
|
optimisticMarkRead={notificationStore.optimisticMarkRead}
|
||||||
|
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { load } from './+page.server';
|
import { load, actions } from './+page.server';
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
GET: vi.fn()
|
GET: vi.fn(),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
POST: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
@@ -173,3 +175,84 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
expect(call[1].params.query.kinds).toHaveLength(2);
|
expect(call[1].params.query.kinds).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function makeActionEvent(formData: FormData): any {
|
||||||
|
return {
|
||||||
|
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
|
||||||
|
fetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aktivitaeten/actions — dismiss-notification', () => {
|
||||||
|
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(mockApi.PATCH).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id: 'n-abc' } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { success: true } when the API responds ok', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({
|
||||||
|
response: { ok: false, status: 403 },
|
||||||
|
error: { code: 'NOTIFICATION_NOT_FOUND' }
|
||||||
|
});
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aktivitaeten/actions — mark-all-read', () => {
|
||||||
|
it('calls POST /api/notifications/read-all', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||||
|
|
||||||
|
await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { success: true } when the API responds ok', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||||
|
|
||||||
|
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({
|
||||||
|
response: { ok: false, status: 500 },
|
||||||
|
error: { code: 'INTERNAL_ERROR' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function resolvePersonName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
||||||
type ValidSort = (typeof VALID_SORTS)[number];
|
type ValidSort = (typeof VALID_SORTS)[number];
|
||||||
@@ -77,7 +77,7 @@ export async function load({ url, fetch }) {
|
|||||||
]);
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
items: [] as DocumentListItem[],
|
items: [] as DocumentSearchItem[],
|
||||||
totalElements: 0,
|
totalElements: 0,
|
||||||
pageNumber: 0,
|
pageNumber: 0,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
@@ -108,7 +108,7 @@ export async function load({ url, fetch }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: (result.data?.items ?? []) as DocumentListItem[],
|
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
||||||
totalElements: result.data?.totalElements ?? 0,
|
totalElements: result.data?.totalElements ?? 0,
|
||||||
pageNumber: result.data?.pageNumber ?? page,
|
pageNumber: result.data?.pageNumber ?? page,
|
||||||
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||||
|
|||||||
@@ -140,12 +140,15 @@ describe('documents/+ page', () => {
|
|||||||
data: baseData({
|
data: baseData({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
document: {
|
||||||
id: 'd1',
|
id: 'd1',
|
||||||
title: 'Brief 1899',
|
title: 'Brief 1899',
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
documentDate: '1899-04-14',
|
documentDate: '1899-04-14',
|
||||||
|
summary: '',
|
||||||
originalFilename: 'b1.pdf',
|
originalFilename: 'b1.pdf',
|
||||||
receivers: [],
|
receivers: []
|
||||||
tags: [],
|
},
|
||||||
matchData: {
|
matchData: {
|
||||||
titleOffsets: [],
|
titleOffsets: [],
|
||||||
senderMatched: false,
|
senderMatched: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user