mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
refactor(C216-FOLLOWUP-DRY): excerpt e chips compartilhados nos cards de fala (F3) (Related #1325)
This commit is contained in:
parent
ce808be72a
commit
72a692f586
5 changed files with 272 additions and 80 deletions
32
src/components/campaign/speech/SpeechExcerpt.tsx
Normal file
32
src/components/campaign/speech/SpeechExcerpt.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
|
||||
|
||||
/**
|
||||
* C216-FOLLOWUP-DRY — the truncated excerpt shared by the speech cards and the
|
||||
* C192 provenance block: a leading `… ` and a trailing ` …` around the
|
||||
* highlighted parts, nothing when there is no excerpt. `quoted` wraps the
|
||||
* provenance excerpt in typographic quotes; `className` keeps each site's own
|
||||
* line-height/margin (the sites differ on purpose).
|
||||
*/
|
||||
export const SpeechExcerpt = ({
|
||||
excerpt,
|
||||
className,
|
||||
quoted = false,
|
||||
}: {
|
||||
excerpt: SpeechHighlightedExcerpt
|
||||
className: string
|
||||
quoted?: boolean
|
||||
}) => {
|
||||
if (excerpt.parts.length === 0) return null
|
||||
|
||||
const prefix = excerpt.truncatedStart ? '… ' : ''
|
||||
const suffix = excerpt.truncatedEnd ? ' …' : ''
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{quoted ? `“${prefix}` : prefix}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{quoted ? `${suffix}”` : suffix}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
|
|||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
|
||||
import { SpeechNestedCuts } from '@/components/campaign/speech/SpeechNestedCuts'
|
||||
import {
|
||||
SpeechResultChips,
|
||||
type SpeechChipGroup,
|
||||
} from '@/components/campaign/speech/SpeechResultChips'
|
||||
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
|
||||
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
|
@ -20,18 +23,6 @@ const MAX_KEYWORD_CHIPS = 3
|
|||
|
||||
const MetaSeparator = () => <span aria-hidden="true">·</span>
|
||||
|
||||
const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
|
||||
if (excerpt.parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{excerpt.truncatedStart ? '… ' : null}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{excerpt.truncatedEnd ? ' …' : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* C192 — the theme result provenance: the real excerpt that matched the theme,
|
||||
* with the expanded term highlighted. It replaces the common excerpt (never
|
||||
|
|
@ -45,11 +36,11 @@ const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt
|
|||
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
|
||||
Por que apareceu
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-foreground/90">
|
||||
{`“${excerpt.truncatedStart ? '… ' : ''}`}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{`${excerpt.truncatedEnd ? ' …' : ''}”`}
|
||||
</p>
|
||||
<SpeechExcerpt
|
||||
excerpt={excerpt}
|
||||
className="mt-1 text-sm leading-6 text-foreground/90"
|
||||
quoted
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -210,14 +201,17 @@ export const SpeechResultCard = ({
|
|||
)
|
||||
}
|
||||
|
||||
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
|
||||
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
|
||||
const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS)
|
||||
const hiddenChips =
|
||||
speech.topics.length -
|
||||
visibleTopics.length +
|
||||
(speech.scopes.length - visibleScopes.length) +
|
||||
(speech.keywords.length - visibleKeywords.length)
|
||||
const chipGroups: SpeechChipGroup[] = [
|
||||
{ key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
|
||||
{ key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
|
||||
{
|
||||
key: 'keywords',
|
||||
items: speech.keywords,
|
||||
max: MAX_KEYWORD_CHIPS,
|
||||
variant: 'outline',
|
||||
className: 'font-normal text-muted-foreground',
|
||||
},
|
||||
]
|
||||
const isTheme = speech.themeMatchTerm !== null
|
||||
// C192 — a theme card only offers the trecho seek when the speech really
|
||||
// contains the literal query (a stopword-only segment hit must not claim it).
|
||||
|
|
@ -228,32 +222,12 @@ export const SpeechResultCard = ({
|
|||
const bodyNode = isTheme ? (
|
||||
<SpeechThemeProvenance excerpt={speech.excerpt} />
|
||||
) : (
|
||||
<SpeechExcerpt excerpt={speech.excerpt} />
|
||||
)
|
||||
const chips = (
|
||||
<>
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
<SpeechExcerpt
|
||||
excerpt={speech.excerpt}
|
||||
className="text-sm leading-relaxed text-foreground/90"
|
||||
/>
|
||||
)
|
||||
const chips = <SpeechResultChips groups={chipGroups} />
|
||||
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
|
||||
|
||||
return (
|
||||
|
|
|
|||
50
src/components/campaign/speech/SpeechResultChips.tsx
Normal file
50
src/components/campaign/speech/SpeechResultChips.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
|
||||
/**
|
||||
* One chip group: `max` bounds how many of its items the card shows; the hidden
|
||||
* ones are summed with the other groups' into a single `+N`. The muted group is
|
||||
* the keyword vocabulary only the Câmara card has.
|
||||
*/
|
||||
export type SpeechChipGroup = {
|
||||
key: string
|
||||
items: readonly { value: string; label: string }[] | readonly string[]
|
||||
max: number
|
||||
variant?: 'secondary' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C216-FOLLOWUP-DRY — the chips line shared by the speech cards (C154/C216):
|
||||
* the leading few per group plus one `+N` badge summing every hidden item. The
|
||||
* container and the empty guard stay with the caller.
|
||||
*/
|
||||
export const SpeechResultChips = ({ groups }: { groups: readonly SpeechChipGroup[] }) => {
|
||||
const hidden = groups.reduce(
|
||||
(total, group) => total + Math.max(0, group.items.length - group.max),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.flatMap((group) =>
|
||||
group.items.slice(0, group.max).map((item) => {
|
||||
const chip = typeof item === 'string' ? { value: item, label: item } : item
|
||||
return (
|
||||
<Badge
|
||||
key={`${group.key}:${chip.value}`}
|
||||
variant={group.variant ?? 'secondary'}
|
||||
className={group.className ?? 'font-normal'}
|
||||
>
|
||||
{chip.label}
|
||||
</Badge>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
{hidden > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hidden}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { PlayIcon, VideoIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
|
||||
import {
|
||||
SpeechResultChips,
|
||||
type SpeechChipGroup,
|
||||
} from '@/components/campaign/speech/SpeechResultChips'
|
||||
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
|
||||
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
|
|
@ -21,10 +24,10 @@ const MAX_SCOPE_CHIPS = 2
|
|||
export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => {
|
||||
const { excerpt, thumbnailUrl, watchHref } = speech
|
||||
const metaLine = [speech.dateLabel, speech.durationLabel].filter(Boolean).join(' · ')
|
||||
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
|
||||
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
|
||||
const hiddenChips =
|
||||
speech.topics.length - visibleTopics.length + (speech.scopes.length - visibleScopes.length)
|
||||
const chipGroups: SpeechChipGroup[] = [
|
||||
{ key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
|
||||
{ key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
|
||||
]
|
||||
const hasChips = Boolean(speech.topics.length || speech.scopes.length)
|
||||
|
||||
return (
|
||||
|
|
@ -65,31 +68,11 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
|
|||
</div>
|
||||
<h3 className="mt-2 text-sm font-semibold">{speech.title}</h3>
|
||||
|
||||
{excerpt.parts.length > 0 ? (
|
||||
<p className="mt-2 text-sm leading-6 text-foreground/90">
|
||||
{excerpt.truncatedStart ? '… ' : null}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{excerpt.truncatedEnd ? ' …' : null}
|
||||
</p>
|
||||
) : null}
|
||||
<SpeechExcerpt excerpt={excerpt} className="mt-2 text-sm leading-6 text-foreground/90" />
|
||||
|
||||
{hasChips ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
<SpeechResultChips groups={chipGroups} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
153
tests/unit/speechResultCards.unit.spec.ts
Normal file
153
tests/unit/speechResultCards.unit.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
|
||||
|
||||
import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
|
||||
import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard'
|
||||
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
|
||||
import type {
|
||||
SpeechListItemViewModel,
|
||||
WebSpeechListItemViewModel,
|
||||
} from '@/utilities/speech/speechViewModels'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
refresh: () => {},
|
||||
forward: () => {},
|
||||
back: () => {},
|
||||
prefetch: () => Promise.resolve(),
|
||||
})
|
||||
|
||||
const renderWithAppRouter = (element: Parameters<typeof renderToStaticMarkup>[0]) =>
|
||||
renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element))
|
||||
|
||||
const excerpt = (overrides: Partial<SpeechHighlightedExcerpt> = {}): SpeechHighlightedExcerpt => ({
|
||||
parts: [
|
||||
{ text: 'um trecho sobre ', highlighted: false },
|
||||
{ text: 'saúde', highlighted: true },
|
||||
],
|
||||
truncatedStart: true,
|
||||
truncatedEnd: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const speech = (overrides: Partial<SpeechListItemViewModel> = {}): SpeechListItemViewModel => ({
|
||||
id: 1,
|
||||
speechAtLabel: '11/08/2026',
|
||||
type: 'Discurso',
|
||||
durationLabel: '2min',
|
||||
presidingOfficer: null,
|
||||
excerpt: excerpt(),
|
||||
matchKind: 'segment',
|
||||
topics: [
|
||||
{ value: 'saude', label: 'Saúde' },
|
||||
{ value: 'educacao', label: 'Educação' },
|
||||
{ value: 'cultura', label: 'Cultura' },
|
||||
{ value: 'esporte', label: 'Esporte' },
|
||||
],
|
||||
scopes: [
|
||||
{ value: 'bahia', label: 'Bahia' },
|
||||
{ value: 'brasil', label: 'Brasil' },
|
||||
{ value: 'internacional', label: 'Internacional' },
|
||||
],
|
||||
keywords: ['sus', 'vacina', 'atenção básica', 'orçamento'],
|
||||
municipalities: [],
|
||||
thumbnailUrl: null,
|
||||
watchHref: '/campanha/comunicacao/acervo/1',
|
||||
officialTextUrl: null,
|
||||
cuts: [],
|
||||
matchedTextSearch: true,
|
||||
themeMatchTerm: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const webSpeech = (
|
||||
overrides: Partial<WebSpeechListItemViewModel> = {},
|
||||
): WebSpeechListItemViewModel => ({
|
||||
id: 9,
|
||||
title: 'Fala na internet',
|
||||
platform: { value: 'youtube', label: 'YouTube' },
|
||||
dateLabel: '12/09/2026',
|
||||
durationLabel: '5min',
|
||||
excerpt: excerpt(),
|
||||
topics: [
|
||||
{ value: 'saude', label: 'Saúde' },
|
||||
{ value: 'educacao', label: 'Educação' },
|
||||
{ value: 'cultura', label: 'Cultura' },
|
||||
{ value: 'esporte', label: 'Esporte' },
|
||||
],
|
||||
scopes: [
|
||||
{ value: 'bahia', label: 'Bahia' },
|
||||
{ value: 'brasil', label: 'Brasil' },
|
||||
{ value: 'internacional', label: 'Internacional' },
|
||||
],
|
||||
thumbnailUrl: null,
|
||||
watchHref: '/campanha/comunicacao/acervo/internet/9',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('speech result cards excerpt', () => {
|
||||
it('renders the truncated excerpt with the highlighted part', () => {
|
||||
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
|
||||
|
||||
expect(html).toContain('text-sm leading-relaxed text-foreground/90')
|
||||
expect(html).toContain('… ')
|
||||
expect(html).toContain(' …')
|
||||
expect(html).toContain('<mark')
|
||||
expect(html).toContain('saúde')
|
||||
})
|
||||
|
||||
it('omits the excerpt when there is nothing to show', () => {
|
||||
const html = renderWithAppRouter(
|
||||
createElement(SpeechResultCard, {
|
||||
speech: speech({
|
||||
excerpt: { parts: [], truncatedStart: false, truncatedEnd: false },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).not.toContain('um trecho sobre')
|
||||
})
|
||||
|
||||
it('wraps the theme provenance excerpt in quotes', () => {
|
||||
const html = renderWithAppRouter(
|
||||
createElement(SpeechResultCard, { speech: speech({ themeMatchTerm: 'saúde' }) }),
|
||||
)
|
||||
|
||||
expect(html).toContain('Por que apareceu')
|
||||
expect(html).toContain('“')
|
||||
expect(html).toContain('”')
|
||||
})
|
||||
|
||||
it('keeps the web card own excerpt classes', () => {
|
||||
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
|
||||
|
||||
expect(html).toContain('mt-2 text-sm leading-6 text-foreground/90')
|
||||
})
|
||||
})
|
||||
|
||||
describe('speech result cards chips', () => {
|
||||
it('caps each group and sums every hidden chip on the Câmara card', () => {
|
||||
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
|
||||
|
||||
expect(html).toContain('Saúde')
|
||||
expect(html).toContain('Bahia')
|
||||
expect(html).toContain('sus')
|
||||
expect(html).not.toContain('Esporte')
|
||||
expect(html).not.toContain('Internacional')
|
||||
expect(html).not.toContain('orçamento')
|
||||
expect(html).toContain('+3')
|
||||
})
|
||||
|
||||
it('sums only the groups the web card has', () => {
|
||||
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
|
||||
|
||||
expect(html).toContain('+2')
|
||||
expect(html).not.toContain('sus')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue