feat(#81): improve discussion discoverability with count badge and empty state #82
@@ -291,5 +291,7 @@
|
||||
"enrich_skip": "Überspringen",
|
||||
"enrich_done_heading": "Alles erledigt!",
|
||||
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||
"enrich_back_to_list": "Zurück zur Liste"
|
||||
"enrich_back_to_list": "Zurück zur Liste",
|
||||
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
||||
"comment_start_discussion": "Diskussion starten →"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,7 @@
|
||||
"enrich_skip": "Skip",
|
||||
"enrich_done_heading": "All done!",
|
||||
"enrich_done_body": "All documents have been processed.",
|
||||
"enrich_back_to_list": "Back to list"
|
||||
"enrich_back_to_list": "Back to list",
|
||||
"comment_empty_hint": "No comments yet – start the discussion!",
|
||||
"comment_start_discussion": "Start discussion →"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,7 @@
|
||||
"enrich_skip": "Omitir",
|
||||
"enrich_done_heading": "¡Todo listo!",
|
||||
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||
"enrich_back_to_list": "Volver a la lista"
|
||||
"enrich_back_to_list": "Volver a la lista",
|
||||
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
||||
"comment_start_discussion": "Iniciar discusión →"
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ function cancelReply() {
|
||||
onMount(() => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
} else {
|
||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -245,6 +248,24 @@ onMount(() => {
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if comments.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<svg
|
||||
class="h-10 w-10 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
|
||||
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeComment(id: string, content = 'Hello'): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'doc-1',
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false
|
||||
};
|
||||
|
||||
describe('CommentThread – empty state', () => {
|
||||
it('shows empty state hint when there are no comments', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show empty state hint when comments exist', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommentThread – onCountChange', () => {
|
||||
it('calls onCountChange with initial SSR count on mount', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, {
|
||||
...baseProps,
|
||||
initialComments: [makeComment('c-1'), makeComment('c-2')],
|
||||
onCountChange
|
||||
});
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onCountChange with 0 when no initial comments', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('counts replies in the total', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
|
||||
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,12 @@ const tabs: { id: DocumentPanelTab; label: () => string }[] = [
|
||||
];
|
||||
|
||||
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
|
||||
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
|
||||
|
||||
function handleCountChange(count: number) {
|
||||
discussionCount = count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -131,6 +137,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
aria-pressed={activeTab === tab.id && open}
|
||||
>
|
||||
{tab.label()}
|
||||
{#if tab.id === 'discussion'}
|
||||
<span
|
||||
data-testid="discussion-count-badge"
|
||||
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
|
||||
>{discussionCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -165,6 +178,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={handleCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeComment(id: string): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content: 'Hello',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const doc = { id: 'doc-1', title: 'Test' };
|
||||
|
||||
const baseProps = {
|
||||
doc,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
height: 300,
|
||||
activeTab: 'discussion' as const
|
||||
};
|
||||
|
||||
describe('DocumentBottomPanel – discussion badge', () => {
|
||||
it('always shows a badge on the Discussion tab', async () => {
|
||||
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('shows the correct count when comments exist', async () => {
|
||||
render(DocumentBottomPanel, {
|
||||
...baseProps,
|
||||
comments: [makeComment('c-1'), makeComment('c-2')],
|
||||
open: true
|
||||
});
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,11 @@ type Props = {
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
@@ -20,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ $effect(() => {
|
||||
{#if data.incompleteCount > 0}
|
||||
<a
|
||||
href="/enrich"
|
||||
class="mb-6 flex items-center justify-between rounded-sm border border-brand-mint/40 bg-brand-mint/10 px-6 py-4 transition-colors hover:bg-brand-mint/20"
|
||||
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
@@ -100,16 +100,16 @@ $effect(() => {
|
||||
class="h-6 w-6 opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.enrich_needs_metadata_title()}
|
||||
</p>
|
||||
<p class="mt-0.5 font-serif text-sm text-brand-navy/70">
|
||||
<p class="mt-0.5 font-serif text-sm text-ink-2">
|
||||
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
|
||||
>
|
||||
{m.enrich_needs_metadata_cta()} →
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user