feat(chronik): deep-link mentions and comments to the specific comment #301
@@ -15,4 +15,6 @@ public interface ActivityFeedRow {
|
||||
boolean isYouParticipated();
|
||||
int getCount();
|
||||
Instant getHappenedAtUntil();
|
||||
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
|
||||
UUID getCommentId();
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
AND n.reference_id = (ag.payload->>'commentId')::uuid
|
||||
) AS youParticipated,
|
||||
ag.count AS count,
|
||||
ag.happened_at_until AS happenedAtUntil
|
||||
ag.happened_at_until AS happenedAtUntil,
|
||||
(ag.payload->>'commentId')::uuid AS commentId
|
||||
FROM aggregated ag
|
||||
LEFT JOIN users u ON u.id = ag.actor_id
|
||||
ORDER BY ag.happened_at DESC
|
||||
|
||||
@@ -17,5 +17,17 @@ public record ActivityFeedItemDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
|
||||
@Nullable OffsetDateTime happenedAtUntil
|
||||
@Nullable OffsetDateTime happenedAtUntil,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID commentId,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID annotationId
|
||||
) {}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
@@ -32,6 +33,7 @@ public class DashboardService {
|
||||
private final DocumentService documentService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
private final UserService userService;
|
||||
private final CommentService commentService;
|
||||
|
||||
public DashboardResumeDTO getResume(UUID userId) {
|
||||
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
|
||||
@@ -125,6 +127,15 @@ public class DashboardService {
|
||||
log.warn("Activity: failed to bulk-load document titles", e);
|
||||
}
|
||||
|
||||
List<UUID> commentIds = rows.stream()
|
||||
.map(ActivityFeedRow::getCommentId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
||||
? Map.of()
|
||||
: commentService.findAnnotationIdsByIds(commentIds);
|
||||
|
||||
return rows.stream().map(row -> {
|
||||
ActivityActorDTO actor = row.getActorId() != null
|
||||
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
|
||||
@@ -133,6 +144,8 @@ public class DashboardService {
|
||||
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
|
||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||
: null;
|
||||
UUID commentId = row.getCommentId();
|
||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
||||
return new ActivityFeedItemDTO(
|
||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||
actor,
|
||||
@@ -142,7 +155,9 @@ public class DashboardService {
|
||||
row.isYouMentioned(),
|
||||
row.isYouParticipated(),
|
||||
row.getCount(),
|
||||
happenedAtUntil
|
||||
happenedAtUntil,
|
||||
commentId,
|
||||
annotationId
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -29,6 +31,15 @@ public class CommentService {
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
|
||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, UUID> result = new HashMap<>();
|
||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||
return withRepliesAndMentions(roots);
|
||||
|
||||
@@ -271,6 +271,45 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_commentId_for_COMMENT_ADDED_events() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_commentId_for_MENTION_CREATED_events() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"),
|
||||
Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_commentId_is_null_for_non_comment_kinds() {
|
||||
insertUserAndDocs();
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1"));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void youMentioned_is_false_when_mention_created_payload_targets_different_user() {
|
||||
insertUserAndDocs();
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
@@ -17,6 +18,7 @@ import org.raddatz.familienarchiv.service.UserService;
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -33,6 +35,7 @@ class DashboardServiceTest {
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TranscriptionService transcriptionService;
|
||||
@Mock UserService userService;
|
||||
@Mock CommentService commentService;
|
||||
|
||||
@InjectMocks DashboardService dashboardService;
|
||||
|
||||
@@ -94,7 +97,72 @@ class DashboardServiceTest {
|
||||
verify(documentService, never()).getDocumentById(docId);
|
||||
}
|
||||
|
||||
// ─── getActivity comment/annotation enrichment ────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActivity_populatesCommentId_forCommentEvents() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).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.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_populatesAnnotationId_viaCommentService() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).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.findAnnotationIdsByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, annotationId));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).annotationId()).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leavesBothNull_forNonCommentKinds() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).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);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isNull();
|
||||
assertThat(items.get(0).annotationId()).isNull();
|
||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
|
||||
return mockFeedRow(docId, kind, null);
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind, UUID commentId) {
|
||||
return new ActivityFeedRow() {
|
||||
public String getKind() { return kind; }
|
||||
public UUID getActorId() { return null; }
|
||||
@@ -107,6 +175,7 @@ class DashboardServiceTest {
|
||||
public boolean isYouParticipated() { return false; }
|
||||
public int getCount() { return 1; }
|
||||
public Instant getHappenedAtUntil() { return null; }
|
||||
public UUID getCommentId() { return commentId; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +641,64 @@ class CommentServiceTest {
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
||||
UUID commentA = UUID.randomUUID();
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID knownAnnotation = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(known, missing)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
||||
.doesNotContainKey(missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
||||
UUID legacy = UUID.randomUUID();
|
||||
UUID block = UUID.randomUUID();
|
||||
UUID annotation = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
||||
.containsOnly(java.util.Map.entry(block, annotation))
|
||||
.doesNotContainKey(legacy);
|
||||
}
|
||||
|
||||
private void stubBlock(UUID docId, UUID blockId) {
|
||||
when(transcriptionService.getBlock(docId, blockId))
|
||||
.thenReturn(TranscriptionBlock.builder()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { notificationStore } from '$lib/stores/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
@@ -31,9 +32,11 @@ function closeDropdown() {
|
||||
|
||||
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||
await stream.markRead(notification);
|
||||
const url = notification.annotationId
|
||||
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||
const url = buildCommentHref(
|
||||
notification.documentId,
|
||||
notification.referenceId,
|
||||
notification.annotationId
|
||||
);
|
||||
closeDropdown();
|
||||
goto(url);
|
||||
}
|
||||
|
||||
82
frontend/src/lib/components/NotificationBell.svelte.spec.ts
Normal file
82
frontend/src/lib/components/NotificationBell.svelte.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import type { NotificationItem } from '$lib/utils/notifications';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
|
||||
const gotoMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
||||
|
||||
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||
|
||||
vi.mock('$lib/stores/notifications.svelte', () => ({
|
||||
notificationStore: {
|
||||
get notifications() {
|
||||
return mockNotificationList.value;
|
||||
},
|
||||
get unreadCount() {
|
||||
return mockNotificationList.value.length;
|
||||
},
|
||||
markRead: mockMarkRead,
|
||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
markAllRead: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
gotoMock.mockClear();
|
||||
mockMarkRead.mockClear();
|
||||
mockNotificationList.value = [];
|
||||
});
|
||||
|
||||
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 'n1',
|
||||
type: 'REPLY',
|
||||
documentId: 'doc-1',
|
||||
referenceId: 'ref-1',
|
||||
annotationId: null,
|
||||
read: false,
|
||||
createdAt: '2026-04-21T10:00:00Z',
|
||||
actorName: 'Anna',
|
||||
documentTitle: 'Test Doc',
|
||||
...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', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import type { NotificationItem } from '$lib/stores/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
|
||||
interface Props {
|
||||
unread: NotificationItem[];
|
||||
@@ -18,7 +19,7 @@ function verb(type: NotificationItem['type'], actor: string): string {
|
||||
}
|
||||
|
||||
function href(n: NotificationItem): string {
|
||||
return `/documents/${n.documentId}?commentId=${n.referenceId}`;
|
||||
return buildCommentHref(n.documentId, n.referenceId, n.annotationId);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -114,6 +114,25 @@ describe('ChronikFuerDichBox', () => {
|
||||
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
||||
});
|
||||
|
||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [
|
||||
notif({
|
||||
id: 'n-link',
|
||||
documentId: 'doc-42',
|
||||
referenceId: 'comment-7',
|
||||
annotationId: 'annot-9'
|
||||
})
|
||||
],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'x' })],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
@@ -96,10 +97,16 @@ const verbParts: { before: string; after: string } = $derived.by(() => {
|
||||
after: verbText.slice(idx + SENTINEL.length)
|
||||
};
|
||||
});
|
||||
|
||||
const rowHref: string = $derived(
|
||||
item.commentId
|
||||
? buildCommentHref(item.documentId, item.commentId, item.annotationId ?? null)
|
||||
: `/documents/${item.documentId}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/documents/{item.documentId}"
|
||||
href={rowHref}
|
||||
data-variant={variant}
|
||||
class="group flex items-start gap-3 rounded-sm p-3 transition-colors hover:bg-canvas 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' : ''}"
|
||||
|
||||
@@ -133,6 +133,59 @@ describe('ChronikRow', () => {
|
||||
expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920');
|
||||
});
|
||||
|
||||
// --- deep-link href for comment events ---
|
||||
it('links to /documents/:id?commentId=…&annotationId=… for COMMENT_ADDED', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentId: 'comment-7',
|
||||
annotationId: 'annot-9'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-1?commentId=comment-7&annotationId=annot-9"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links to /documents/:id?commentId=…&annotationId=… for MENTION_CREATED', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'MENTION_CREATED',
|
||||
youMentioned: true,
|
||||
commentId: 'comment-8',
|
||||
annotationId: 'annot-11'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-1?commentId=comment-8&annotationId=annot-11"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to bare document href when commentId is absent on a comment row', async () => {
|
||||
// Back-compat for old/missing backend payloads. Still navigates sensibly.
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[href="/documents/doc-1"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links to commentId-only URL when commentId is set but annotationId is absent', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentId: 'comment-7'
|
||||
// annotationId absent — comment on a non-annotation block
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[href="/documents/doc-1?commentId=comment-7"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- robustness: title rendering for edge cases ---
|
||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||
|
||||
@@ -2043,6 +2043,17 @@ export interface components {
|
||||
count: number;
|
||||
/** Format: date-time */
|
||||
happenedAtUntil?: string;
|
||||
// MANUALLY ADDED — re-run `npm run generate:api` after the next backend deployment and re-add these fields if they are dropped
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
||||
*/
|
||||
commentId?: string;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
||||
*/
|
||||
annotationId?: string;
|
||||
};
|
||||
InvitePrefillDTO: {
|
||||
firstName: string;
|
||||
|
||||
14
frontend/src/lib/utils/commentDeepLink.spec.ts
Normal file
14
frontend/src/lib/utils/commentDeepLink.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildCommentHref } from './commentDeepLink';
|
||||
|
||||
describe('buildCommentHref', () => {
|
||||
it('includes both commentId and annotationId when annotationId is present', () => {
|
||||
const href = buildCommentHref('doc-1', 'comment-2', 'annot-3');
|
||||
expect(href).toBe('/documents/doc-1?commentId=comment-2&annotationId=annot-3');
|
||||
});
|
||||
|
||||
it('omits annotationId when null', () => {
|
||||
const href = buildCommentHref('doc-1', 'comment-2', null);
|
||||
expect(href).toBe('/documents/doc-1?commentId=comment-2');
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/utils/commentDeepLink.ts
Normal file
8
frontend/src/lib/utils/commentDeepLink.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function buildCommentHref(
|
||||
documentId: string,
|
||||
commentId: string,
|
||||
annotationId: string | null
|
||||
): string {
|
||||
const base = `/documents/${documentId}?commentId=${commentId}`;
|
||||
return annotationId ? `${base}&annotationId=${annotationId}` : base;
|
||||
}
|
||||
Reference in New Issue
Block a user