From 5db36cb29fd996c24d7fc8c6a362d7851e53c12b Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 30 Aug 2026 08:48:03 +0200 Subject: [PATCH] Align group profile summary contract --- .../components/groups/GroupProfileSummary.jsx | 40 +++++- .../groups/GroupProfileSummary.test.jsx | 126 ++++++++++++++++++ 2 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 resources/js/components/groups/GroupProfileSummary.test.jsx diff --git a/resources/js/components/groups/GroupProfileSummary.jsx b/resources/js/components/groups/GroupProfileSummary.jsx index 75840e28..9457929b 100644 --- a/resources/js/components/groups/GroupProfileSummary.jsx +++ b/resources/js/components/groups/GroupProfileSummary.jsx @@ -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 (
@@ -27,7 +48,7 @@ export default function GroupProfileSummary({ contributions = [], href = null })
{entry.group?.name}
- {entry.role?.label ?
{entry.role.label}
: null} + {entry.roleLabel ?
{entry.roleLabel}
: null} {entry.summary ?
{entry.summary}
: null}
@@ -35,12 +56,21 @@ export default function GroupProfileSummary({ contributions = [], href = null })
{entry.trusted ? : null} {entry.recent_release_titles?.length ? : null} + {entry.role_labels.map((label, index) => )}
+ {entry.recent_release_titles.length ? ( +
+ Recent releases:{' '} + {entry.recent_release_titles.join(', ')} +
+ ) : null} +
- {Number(entry.counts?.artworks || 0).toLocaleString('en-US')} artworks - {Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases - {Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects + {formatProfileNumber(entry.counts.credited_artworks)} artworks + {formatProfileNumber(entry.counts.releases)} releases + {formatProfileNumber(entry.counts.projects)} projects + {formatProfileNumber(entry.counts.review_actions)} reviews
))} diff --git a/resources/js/components/groups/GroupProfileSummary.test.jsx b/resources/js/components/groups/GroupProfileSummary.test.jsx new file mode 100644 index 00000000..91fd3725 --- /dev/null +++ b/resources/js/components/groups/GroupProfileSummary.test.jsx @@ -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() + + 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() + + expect(screen.queryByText('Trusted contributor')).toBeNull() + }) + + it('renders zero counts instead of hiding falsy values', () => { + render() + + 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() + + expect(container.innerHTML).toBe('') + }) + + it('handles missing optional fields without throwing', () => { + render() + + 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() + + 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() + container.innerHTML = serverMarkup + document.body.appendChild(container) + const recoverableErrors = [] + const root = hydrateRoot(container, , { + 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()) + }) +})