feat(chronik): add commentPreview to ActivityFeedItemDTO (#454) #475
@@ -190,6 +190,13 @@
|
|||||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||||
<version>20240325.1</version>
|
<version>20240325.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- HTML → plain-text extraction for comment previews -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,5 +29,11 @@ public record ActivityFeedItemDTO(
|
|||||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||||
)
|
)
|
||||||
UUID annotationId
|
UUID annotationId,
|
||||||
|
@Nullable
|
||||||
|
@Schema(
|
||||||
|
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||||
|
description = "Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments."
|
||||||
|
)
|
||||||
|
String commentPreview
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.document.Document;
|
|||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||||
|
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
@@ -133,9 +134,9 @@ public class DashboardService {
|
|||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
Map<UUID, CommentData> commentDataByComment = commentIds.isEmpty()
|
||||||
? Map.of()
|
? Map.of()
|
||||||
: commentService.findAnnotationIdsByIds(commentIds);
|
: commentService.findDataByIds(commentIds);
|
||||||
|
|
||||||
return rows.stream().map(row -> {
|
return rows.stream().map(row -> {
|
||||||
ActivityActorDTO actor = row.getActorId() != null
|
ActivityActorDTO actor = row.getActorId() != null
|
||||||
@@ -146,7 +147,10 @@ public class DashboardService {
|
|||||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||||
: null;
|
: null;
|
||||||
UUID commentId = row.getCommentId();
|
UUID commentId = row.getCommentId();
|
||||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null;
|
||||||
|
UUID annotationId = commentData != null ? commentData.annotationId() : null;
|
||||||
|
String commentPreview = commentData != null && !commentData.preview().isBlank()
|
||||||
|
? commentData.preview() : null;
|
||||||
return new ActivityFeedItemDTO(
|
return new ActivityFeedItemDTO(
|
||||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||||
actor,
|
actor,
|
||||||
@@ -158,7 +162,8 @@ public class DashboardService {
|
|||||||
row.getCount(),
|
row.getCount(),
|
||||||
happenedAtUntil,
|
happenedAtUntil,
|
||||||
commentId,
|
commentId,
|
||||||
annotationId
|
annotationId,
|
||||||
|
commentPreview
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.document.comment;
|
||||||
|
|
||||||
|
import jakarta.annotation.Nullable;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CommentData(@Nullable UUID annotationId, String preview) {}
|
||||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.document.comment.DocumentComment;
|
|||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
||||||
import org.raddatz.familienarchiv.notification.NotificationService;
|
import org.raddatz.familienarchiv.notification.NotificationService;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -28,21 +29,29 @@ import java.util.UUID;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
|
private static final int PREVIEW_MAX_CHARS = 120;
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TranscriptionService transcriptionService;
|
private final TranscriptionService transcriptionService;
|
||||||
|
|
||||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
public Map<UUID, CommentData> findDataByIds(Collection<UUID> commentIds) {
|
||||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
||||||
Map<UUID, UUID> result = new HashMap<>();
|
Map<UUID, CommentData> result = new HashMap<>();
|
||||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
||||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
result.put(c.getId(), new CommentData(c.getAnnotationId(), stripAndTruncate(c.getContent())));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String stripAndTruncate(String html) {
|
||||||
|
if (html == null || html.isBlank()) return "";
|
||||||
|
String text = Jsoup.parse(html).text().trim();
|
||||||
|
return text.length() > PREVIEW_MAX_CHARS ? text.substring(0, PREVIEW_MAX_CHARS) : text;
|
||||||
|
}
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||||
return withRepliesAndMentions(roots);
|
return withRepliesAndMentions(roots);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser;
|
|||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||||
|
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
@@ -142,7 +143,8 @@ class DashboardServiceTest {
|
|||||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
));
|
));
|
||||||
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
when(commentService.findDataByIds(List.of(commentId)))
|
||||||
|
.thenReturn(Map.of(commentId, new CommentData(null, "preview text")));
|
||||||
|
|
||||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
@@ -162,8 +164,8 @@ class DashboardServiceTest {
|
|||||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
));
|
));
|
||||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
when(commentService.findDataByIds(List.of(commentId)))
|
||||||
.thenReturn(Map.of(commentId, annotationId));
|
.thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text")));
|
||||||
|
|
||||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
@@ -187,7 +189,62 @@ class DashboardServiceTest {
|
|||||||
assertThat(items).hasSize(1);
|
assertThat(items).hasSize(1);
|
||||||
assertThat(items.get(0).commentId()).isNull();
|
assertThat(items.get(0).commentId()).isNull();
|
||||||
assertThat(items.get(0).annotationId()).isNull();
|
assertThat(items.get(0).annotationId()).isNull();
|
||||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
verify(commentService, never()).findDataByIds(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getActivity commentPreview ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActivity_populates_commentPreview_for_COMMENT_ADDED_rows() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
|
||||||
|
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||||
|
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||||
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
|
));
|
||||||
|
when(commentService.findDataByIds(List.of(commentId)))
|
||||||
|
.thenReturn(Map.of(commentId, new CommentData(null, "Hello family!")));
|
||||||
|
|
||||||
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
|
assertThat(items.get(0).commentPreview()).isEqualTo("Hello family!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
|
||||||
|
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||||
|
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||||
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
|
));
|
||||||
|
|
||||||
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
|
assertThat(items.get(0).commentPreview()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActivity_leaves_commentPreview_null_when_comment_is_deleted() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID deletedCommentId = UUID.randomUUID();
|
||||||
|
|
||||||
|
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", deletedCommentId);
|
||||||
|
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||||
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
|
));
|
||||||
|
when(commentService.findDataByIds(List.of(deletedCommentId))).thenReturn(Map.of());
|
||||||
|
|
||||||
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
|
assertThat(items.get(0).commentPreview()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import org.raddatz.familienarchiv.notification.NotificationService;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -644,62 +645,99 @@ class CommentServiceTest {
|
|||||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
// ─── findDataByIds ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
void findDataByIds_returns_empty_map_when_input_is_empty() {
|
||||||
UUID commentA = UUID.randomUUID();
|
assertThat(commentService.findDataByIds(List.of())).isEmpty();
|
||||||
UUID annotationA = UUID.randomUUID();
|
|
||||||
UUID commentB = UUID.randomUUID();
|
|
||||||
UUID annotationB = UUID.randomUUID();
|
|
||||||
when(commentRepository.findAllById(List.of(commentA, commentB)))
|
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
|
|
||||||
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
|
|
||||||
.containsOnly(
|
|
||||||
java.util.Map.entry(commentA, annotationA),
|
|
||||||
java.util.Map.entry(commentB, annotationB)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
|
|
||||||
verify(commentRepository, never()).findAllById(anyList());
|
verify(commentRepository, never()).findAllById(anyList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
void findDataByIds_strips_html_and_extracts_plain_text() {
|
||||||
UUID known = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID knownAnnotation = UUID.randomUUID();
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
UUID missing = UUID.randomUUID();
|
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||||
when(commentRepository.findAllById(List.of(known, missing)))
|
.content("<p><strong>Hello</strong> world</p>").build()));
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(id));
|
||||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
|
||||||
.doesNotContainKey(missing);
|
assertThat(result.get(id).preview()).isEqualTo("Hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
void findDataByIds_truncates_at_exactly_120_chars() {
|
||||||
UUID legacy = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID block = UUID.randomUUID();
|
String text121 = "a".repeat(121);
|
||||||
UUID annotation = UUID.randomUUID();
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
.thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build()));
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
|
||||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||||
.containsOnly(java.util.Map.entry(block, annotation))
|
}
|
||||||
.doesNotContainKey(legacy);
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_preserves_content_at_exactly_120_chars() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
String text120 = "a".repeat(120);
|
||||||
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(id).content(text120).build()));
|
||||||
|
|
||||||
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_returns_empty_string_for_blank_content() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(id).content(" ").build()));
|
||||||
|
|
||||||
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_returns_empty_string_for_null_content() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(id).content(null).build()));
|
||||||
|
|
||||||
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_omits_deleted_comments_from_result_map() {
|
||||||
|
UUID present = UUID.randomUUID();
|
||||||
|
UUID deleted = UUID.randomUUID();
|
||||||
|
when(commentRepository.findAllById(List.of(present, deleted)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build()));
|
||||||
|
|
||||||
|
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(present, deleted));
|
||||||
|
|
||||||
|
assertThat(result).containsKey(present);
|
||||||
|
assertThat(result).doesNotContainKey(deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_preserves_annotationId_alongside_preview() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||||
|
.annotationId(annotationId).content("Text").build()));
|
||||||
|
|
||||||
|
CommentData data = commentService.findDataByIds(List.of(id)).get(id);
|
||||||
|
|
||||||
|
assertThat(data.annotationId()).isEqualTo(annotationId);
|
||||||
|
assertThat(data.preview()).isEqualTo("Text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findDataByIds_sets_null_annotationId_when_comment_has_no_annotation() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
|
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||||
|
.annotationId(null).content("Text").build()));
|
||||||
|
|
||||||
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stubBlock(UUID docId, UUID blockId) {
|
private void stubBlock(UUID docId, UUID blockId) {
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ const rowHref: string = $derived(
|
|||||||
<a
|
<a
|
||||||
href={rowHref}
|
href={rowHref}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
|
aria-label={variant === 'comment'
|
||||||
|
? item.commentPreview
|
||||||
|
? `${m.chronik_comment_added({ actor: actorName, doc: docTitle })} — ${item.commentPreview}`
|
||||||
|
: m.chronik_comment_added({ actor: actorName, doc: docTitle })
|
||||||
|
: undefined}
|
||||||
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
||||||
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
||||||
>
|
>
|
||||||
@@ -159,13 +164,11 @@ const rowHref: string = $derived(
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if variant === 'comment'}
|
{#if variant === 'comment'}
|
||||||
<!-- TODO(#454): add commentPreview to ActivityFeedItemDTO, then render here -->
|
|
||||||
<!-- SECURITY: render via {text} not {@html} when commentPreview arrives — XSS risk (#285) -->
|
|
||||||
<p
|
<p
|
||||||
data-testid="chronik-comment-preview"
|
data-testid="chronik-comment-preview"
|
||||||
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
||||||
>
|
>
|
||||||
„…“
|
{item.commentPreview ?? '„…"'}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,61 @@ describe('ChronikRow', () => {
|
|||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- commentPreview content ---
|
||||||
|
it('renders commentPreview text when variant is comment and commentPreview is present', async () => {
|
||||||
|
const item: ActivityFeedItemDTO = {
|
||||||
|
...baseItem,
|
||||||
|
kind: 'COMMENT_ADDED',
|
||||||
|
commentPreview: 'Hello family, great letter!'
|
||||||
|
};
|
||||||
|
render(ChronikRow, { item });
|
||||||
|
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||||
|
expect(preview).not.toBeNull();
|
||||||
|
expect(preview?.textContent).toContain('Hello family, great letter!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => {
|
||||||
|
const item: ActivityFeedItemDTO = {
|
||||||
|
...baseItem,
|
||||||
|
kind: 'COMMENT_ADDED',
|
||||||
|
commentPreview: undefined
|
||||||
|
};
|
||||||
|
render(ChronikRow, { item });
|
||||||
|
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||||
|
expect(preview).not.toBeNull();
|
||||||
|
expect(preview?.textContent?.trim()).toBe('„…"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render preview paragraph for non-comment variants', async () => {
|
||||||
|
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' };
|
||||||
|
render(ChronikRow, { item });
|
||||||
|
expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link has aria-label containing preview text for comment variant with preview', async () => {
|
||||||
|
const item: ActivityFeedItemDTO = {
|
||||||
|
...baseItem,
|
||||||
|
kind: 'COMMENT_ADDED',
|
||||||
|
commentPreview: 'A wonderful letter from grandma'
|
||||||
|
};
|
||||||
|
render(ChronikRow, { item });
|
||||||
|
const link = document.querySelector('a[aria-label]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link still has aria-label for comment variant when commentPreview is absent', async () => {
|
||||||
|
const item: ActivityFeedItemDTO = {
|
||||||
|
...baseItem,
|
||||||
|
kind: 'COMMENT_ADDED',
|
||||||
|
commentPreview: undefined
|
||||||
|
};
|
||||||
|
render(ChronikRow, { item });
|
||||||
|
const link = document.querySelector('a[aria-label]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.getAttribute('aria-label')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// --- robustness: title rendering for edge cases ---
|
// --- robustness: title rendering for edge cases ---
|
||||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||||
|
|||||||
@@ -2402,6 +2402,8 @@ export interface components {
|
|||||||
* @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
* @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
||||||
*/
|
*/
|
||||||
annotationId?: string;
|
annotationId?: string;
|
||||||
|
/** @description Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments. */
|
||||||
|
commentPreview?: string;
|
||||||
};
|
};
|
||||||
InvitePrefillDTO: {
|
InvitePrefillDTO: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user