From bc3fec11a9c9e1f7e95e69de2474b10643f666c2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 15 Apr 2026 14:23:25 +0200 Subject: [PATCH] refactor(comments): extract CommentMessage component from CommentThread (#198) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/CommentMessage.svelte | 111 ++++++++++++++++++ .../components/CommentMessage.svelte.spec.ts | 85 ++++++++++++++ .../src/lib/components/CommentThread.svelte | 110 +++-------------- frontend/src/lib/types.ts | 10 ++ frontend/src/lib/utils/comment.spec.ts | 40 +++++++ frontend/src/lib/utils/comment.ts | 5 + 6 files changed, 265 insertions(+), 96 deletions(-) create mode 100644 frontend/src/lib/components/CommentMessage.svelte create mode 100644 frontend/src/lib/components/CommentMessage.svelte.spec.ts create mode 100644 frontend/src/lib/utils/comment.spec.ts create mode 100644 frontend/src/lib/utils/comment.ts diff --git a/frontend/src/lib/components/CommentMessage.svelte b/frontend/src/lib/components/CommentMessage.svelte new file mode 100644 index 00000000..f32e7d7d --- /dev/null +++ b/frontend/src/lib/components/CommentMessage.svelte @@ -0,0 +1,111 @@ + + +
+ +
+ {getInitials(message.authorName)} +
+ + +
+ +
+ {message.authorName} + {#if wasEdited} + {relativeTime(message.updatedAt)} {m.comment_edited_label()} + {:else} + {relativeTime(message.createdAt)} + {/if} +
+ + + {#if parsed.quote} +
+ “{parsed.quote}” +
+ {/if} + + + {#if isEditing} + +
Enter speichern · Esc abbrechen
+ {:else} + + +
{ if (isOwn) onEdit(); }}> +

+ + {@html renderBody(parsed.body, message.mentionDTOs ?? [])} +

+ {#if isOwn} + + {/if} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/CommentMessage.svelte.spec.ts b/frontend/src/lib/components/CommentMessage.svelte.spec.ts new file mode 100644 index 00000000..9c1f7d75 --- /dev/null +++ b/frontend/src/lib/components/CommentMessage.svelte.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import CommentMessage from './CommentMessage.svelte'; +import type { FlatMessage } from '$lib/types'; + +afterEach(cleanup); + +const baseMsg: FlatMessage = { + id: 'msg-1', + authorId: 'user-1', + authorName: 'Anna Müller', + content: 'Hello world', + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + updatedAt: new Date(Date.now() - 5 * 60_000).toISOString() +}; + +function defaultProps(overrides: Partial[1]> = {}) { + return { + message: baseMsg, + isOwn: false, + isEditing: false, + editText: '', + onEdit: vi.fn(), + onDelete: vi.fn(), + onEditTextChange: vi.fn(), + onEditKeydown: vi.fn(), + ...overrides + }; +} + +describe('CommentMessage', () => { + it('renders author name', async () => { + render(CommentMessage, defaultProps()); + await expect.element(page.getByText('Anna Müller')).toBeInTheDocument(); + }); + + it('renders initials in avatar', async () => { + render(CommentMessage, defaultProps()); + await expect.element(page.getByText('AM')).toBeInTheDocument(); + }); + + it('renders message body', async () => { + render(CommentMessage, defaultProps()); + await expect.element(page.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders quoted section when content contains a quote', async () => { + render( + CommentMessage, + defaultProps({ + message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' } + }) + ); + await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument(); + await expect.element(page.getByText('My reply')).toBeInTheDocument(); + }); + + it('does not show delete button for messages not owned by current user', async () => { + render(CommentMessage, defaultProps({ isOwn: false })); + await expect.element(page.getByRole('button')).not.toBeInTheDocument(); + }); + + it('shows delete button for own messages', async () => { + render(CommentMessage, defaultProps({ isOwn: true })); + await expect.element(page.getByRole('button')).toBeInTheDocument(); + }); + + it('calls onDelete when delete button is clicked', async () => { + const onDelete = vi.fn(); + render(CommentMessage, defaultProps({ isOwn: true, onDelete })); + await userEvent.click(page.getByRole('button')); + expect(onDelete).toHaveBeenCalled(); + }); + + it('shows edit textarea when isEditing is true', async () => { + render( + CommentMessage, + defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' }) + ); + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toBeInTheDocument(); + await expect.element(textarea).toHaveValue('current edit text'); + }); +}); diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 1d5ce5e5..f943c671 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -1,13 +1,10 @@