Align group profile summary contract

This commit is contained in:
2026-08-30 08:48:03 +02:00
parent 7805baa17d
commit 5db36cb29f
2 changed files with 161 additions and 5 deletions
@@ -1,10 +1,31 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
import { formatProfileNumber } from '../../lib/profileFormatters'
export function normalizeGroupContribution(entry = {}) {
const counts = entry?.counts || {}
const role = entry?.role
return {
...entry,
roleLabel: typeof role === 'string' ? role : role?.label || null,
trusted: entry?.trusted_indicator ?? entry?.trusted ?? false,
counts: {
...counts,
credited_artworks: counts.credited_artworks ?? counts.artworks ?? 0,
releases: counts.releases ?? 0,
projects: counts.projects ?? 0,
review_actions: counts.review_actions ?? 0,
},
role_labels: Array.isArray(entry?.role_labels) ? entry.role_labels : [],
recent_release_titles: Array.isArray(entry?.recent_release_titles) ? entry.recent_release_titles : [],
}
}
export default function GroupProfileSummary({ contributions = [], href = null }) {
if (!Array.isArray(contributions) || contributions.length === 0) return null
const featured = contributions.slice(0, 3)
const featured = contributions.slice(0, 3).map(normalizeGroupContribution)
return (
<section className="mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8">
@@ -27,7 +48,7 @@ export default function GroupProfileSummary({ contributions = [], href = null })
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-white">{entry.group?.name}</div>
{entry.role?.label ? <div className="mt-1 text-sm text-slate-400">{entry.role.label}</div> : null}
{entry.roleLabel ? <div className="mt-1 text-sm text-slate-400">{entry.roleLabel}</div> : null}
{entry.summary ? <div className="mt-2 text-sm text-slate-300">{entry.summary}</div> : null}
</div>
</div>
@@ -35,12 +56,21 @@ export default function GroupProfileSummary({ contributions = [], href = null })
<div className="mt-4 flex flex-wrap gap-2">
{entry.trusted ? <GroupBadgePill label="Trusted contributor" tone="sky" /> : null}
{entry.recent_release_titles?.length ? <GroupBadgePill label="Recent releases" tone="amber" /> : null}
{entry.role_labels.map((label, index) => <GroupBadgePill key={`${label}-${index}`} label={label} tone="slate" />)}
</div>
{entry.recent_release_titles.length ? (
<div className="mt-3 text-xs text-slate-400">
<span className="font-semibold text-slate-300">Recent releases:</span>{' '}
{entry.recent_release_titles.join(', ')}
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entry.counts?.artworks || 0).toLocaleString('en-US')} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects</span>
<span>{formatProfileNumber(entry.counts.credited_artworks)} artworks</span>
<span>{formatProfileNumber(entry.counts.releases)} releases</span>
<span>{formatProfileNumber(entry.counts.projects)} projects</span>
<span>{formatProfileNumber(entry.counts.review_actions)} reviews</span>
</div>
</a>
))}
@@ -0,0 +1,126 @@
import React from 'react'
import { act, render, screen } from '@testing-library/react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { afterEach, describe, expect, it } from 'vitest'
import GroupProfileSummary from './GroupProfileSummary'
const backendFixture = {
group: {
id: 1,
name: 'Test Group',
slug: 'test-group',
headline: 'A public collaboration',
avatar_url: null,
profile_url: '/groups/test-group',
},
joined_at: '2026-08-01T12:00:00.000000Z',
role: 'member',
counts: {
credited_artworks: 12,
releases: 3,
projects: 2,
review_actions: 7,
},
trusted_indicator: true,
summary: 'Contributes across the group releases.',
role_labels: ['Coder', 'Designer'],
recent_release_titles: ['Release A', 'Release B'],
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('GroupProfileSummary backend contract alignment', () => {
it('renders the current backend-shaped contribution fixture', () => {
render(<GroupProfileSummary contributions={[backendFixture]} />)
expect(screen.getByText('Test Group')).toBeTruthy()
expect(screen.getByText('member')).toBeTruthy()
expect(screen.getByText('Contributes across the group releases.')).toBeTruthy()
expect(screen.getByText('Trusted contributor')).toBeTruthy()
expect(screen.getByText('Coder')).toBeTruthy()
expect(screen.getByText('Designer')).toBeTruthy()
expect(screen.getByText('Release A, Release B')).toBeTruthy()
expect(screen.getByText('12 artworks')).toBeTruthy()
expect(screen.getByText('3 releases')).toBeTruthy()
expect(screen.getByText('2 projects')).toBeTruthy()
expect(screen.getByText('7 reviews')).toBeTruthy()
expect(screen.getByRole('link', { name: /Test Group/ }).getAttribute('href')).toBe('/groups/test-group')
})
it('does not render the trusted badge when trusted_indicator is false', () => {
render(<GroupProfileSummary contributions={[{ ...backendFixture, trusted_indicator: false }]} />)
expect(screen.queryByText('Trusted contributor')).toBeNull()
})
it('renders zero counts instead of hiding falsy values', () => {
render(<GroupProfileSummary contributions={[{
...backendFixture,
trusted_indicator: false,
counts: { credited_artworks: 0, releases: 0, projects: 0, review_actions: 0 },
role_labels: [],
recent_release_titles: [],
}]} />)
expect(screen.getByText('0 artworks')).toBeTruthy()
expect(screen.getByText('0 releases')).toBeTruthy()
expect(screen.getByText('0 projects')).toBeTruthy()
expect(screen.getByText('0 reviews')).toBeTruthy()
expect(screen.queryByText('Trusted contributor')).toBeNull()
expect(screen.queryByText('Recent releases')).toBeNull()
})
it('keeps the empty state unchanged', () => {
const { container } = render(<GroupProfileSummary contributions={[]} />)
expect(container.innerHTML).toBe('')
})
it('handles missing optional fields without throwing', () => {
render(<GroupProfileSummary contributions={[{ group: { id: 2, name: 'Minimal Group', slug: 'minimal' } }]} />)
expect(screen.getByText('Minimal Group')).toBeTruthy()
expect(screen.getByText('0 artworks')).toBeTruthy()
expect(screen.getByText('0 releases')).toBeTruthy()
expect(screen.getByText('0 projects')).toBeTruthy()
expect(screen.getByText('0 reviews')).toBeTruthy()
})
it('supports the previous frontend-shaped aliases as a compatibility fallback', () => {
render(<GroupProfileSummary contributions={[{
group: { id: 3, name: 'Legacy Group', slug: 'legacy', profile_url: '/groups/legacy' },
role: { label: 'editor' },
trusted: true,
counts: { artworks: 4, releases: 1, projects: 0 },
}]} />)
expect(screen.getByText('editor')).toBeTruthy()
expect(screen.getByText('Trusted contributor')).toBeTruthy()
expect(screen.getByText('4 artworks')).toBeTruthy()
})
it('hydrates populated backend data without recoverable errors or markup drift', async () => {
const container = document.createElement('div')
const serverMarkup = renderToString(<GroupProfileSummary contributions={[backendFixture]} />)
container.innerHTML = serverMarkup
document.body.appendChild(container)
const recoverableErrors = []
const root = hydrateRoot(container, <GroupProfileSummary contributions={[backendFixture]} />, {
onRecoverableError: (error) => recoverableErrors.push(error),
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(container.innerHTML).toBe(serverMarkup)
expect(recoverableErrors).toHaveLength(0)
expect(container.textContent).toContain('12 artworks')
expect(container.textContent).toContain('Release A, Release B')
await act(async () => root.unmount())
})
})