Align group profile summary contract
This commit is contained in:
@@ -1,10 +1,31 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import GroupBadgePill from './GroupBadgePill'
|
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 }) {
|
export default function GroupProfileSummary({ contributions = [], href = null }) {
|
||||||
if (!Array.isArray(contributions) || contributions.length === 0) return 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 (
|
return (
|
||||||
<section className="mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8">
|
<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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-base font-semibold text-white">{entry.group?.name}</div>
|
<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}
|
{entry.summary ? <div className="mt-2 text-sm text-slate-300">{entry.summary}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,12 +56,21 @@ export default function GroupProfileSummary({ contributions = [], href = null })
|
|||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{entry.trusted ? <GroupBadgePill label="Trusted contributor" tone="sky" /> : null}
|
{entry.trusted ? <GroupBadgePill label="Trusted contributor" tone="sky" /> : null}
|
||||||
{entry.recent_release_titles?.length ? <GroupBadgePill label="Recent releases" tone="amber" /> : 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>
|
</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">
|
<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>{formatProfileNumber(entry.counts.credited_artworks)} artworks</span>
|
||||||
<span>{Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases</span>
|
<span>{formatProfileNumber(entry.counts.releases)} releases</span>
|
||||||
<span>{Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects</span>
|
<span>{formatProfileNumber(entry.counts.projects)} projects</span>
|
||||||
|
<span>{formatProfileNumber(entry.counts.review_actions)} reviews</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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())
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user