feat(components): extract GraphModal from App

This commit is contained in:
2026-06-23 16:02:13 -04:00
parent 4727a7454e
commit b87f5ea5aa
2 changed files with 144 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import React, { useState, useEffect } from 'react'
import {
Box, Typography, IconButton, Modal, Paper,
FormControl, InputLabel, Select, MenuItem,
} from '@mui/material'
import { PointsHistoryChart } from './PointsHistoryChart.jsx'
import { sizeShort } from '../utils/format.js'
export function GraphModal({ row, open, onClose }) {
const [graphSize, setGraphSize] = useState(null)
useEffect(() => {
if (row) setGraphSize(row.size)
}, [row])
if (!row) return null
const sizes = row.sizes || []
const activeSize = graphSize || row.size
const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0]
const history = activeSizeData?.history || []
return (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 0, sm: 2 }, width: '100%', height: '100%' }}>
<Paper sx={{
maxWidth: { xs: '100%', sm: 720 },
width: '100%',
height: { xs: '100%', sm: 'auto' },
maxHeight: { xs: '100%', sm: '90vh' },
overflow: 'auto', p: { xs: 1, sm: 3 },
borderRadius: { xs: 0, sm: 2 },
display: 'flex', flexDirection: 'column',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={700} sx={{ fontSize: { xs: '1rem', sm: '1.1rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.72rem', sm: '0.8rem' } }}>{row.faction_name}</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: 'text.secondary', flexShrink: 0 }} aria-label="✕"></IconButton>
</Box>
{sizes.length > 1 && (
<FormControl size="small" sx={{ mb: 2, minWidth: 140 }}>
<InputLabel>Model count</InputLabel>
<Select
value={activeSize}
label="Model count"
onChange={(e) => setGraphSize(e.target.value)}
renderValue={(v) => sizeShort(v) + ' models'}
>
{sizes.map((s) => (
<MenuItem key={s.size} value={s.size}>{sizeShort(s.size)} models</MenuItem>
))}
</Select>
</FormControl>
)}
{history.length > 0 ? (
<PointsHistoryChart history={history} />
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No historical data for this unit.
</Typography>
)}
{history.length > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[0].version}: {history[0].pts}pts
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[history.length - 1].version}: {history[history.length - 1].pts}pts
</Typography>
</Box>
)}
</Paper>
</Modal>
)
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import fixture from './fixtures/data.json'
import { GraphModal } from '../components/GraphModal.jsx'
const arco = fixture.units.find(u => u.name === 'Arco-flagellants')
const palatine = fixture.units.find(u => u.name === 'Palatine')
const newUnit = fixture.units.find(u => u.name === 'New Unit X')
function renderModal(row, onClose = vi.fn()) {
return render(<GraphModal row={row} open={true} onClose={onClose} />)
}
describe('GraphModal', () => {
it('renders the unit name and faction in the header', () => {
renderModal(palatine)
// Modal renders; the name appears in the dialog
expect(screen.getByText('Palatine')).toBeInTheDocument()
expect(screen.getByText('Adepta Sororitas')).toBeInTheDocument()
})
it('shows the size dropdown for multi-size units and updates the chart', async () => {
const user = userEvent.setup()
renderModal(arco)
// The size dropdown should be present (MUI Select)
const selects = screen.getAllByRole('combobox')
expect(selects.length).toBeGreaterThan(0)
// The chart should render at least one circle per data point on the small size (3 points).
// Note: MUI's Select renders an arrow SVG (viewBox 0 0 24 24) before the chart SVG,
// so we filter for the chart SVG (the one with circles) instead of taking the first.
const chartSvg = Array.from(document.querySelectorAll('svg')).find(s => s.querySelector('circle'))
expect(chartSvg).toBeInTheDocument()
expect(chartSvg.querySelectorAll('circle').length).toBe(3)
})
it('shows "No historical data" when the unit has empty history', () => {
renderModal(newUnit)
expect(screen.getByText(/no historical data/i)).toBeInTheDocument()
})
it('calls onClose when the close button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderModal(palatine, onClose)
// The close button is a small IconButton with ✕ text
await user.click(screen.getByRole('button', { name: '✕' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('renders nothing when row is null', () => {
const { container } = render(<GraphModal row={null} open={true} onClose={() => {}} />)
// No header text should be present
expect(screen.queryByText('Palatine')).not.toBeInTheDocument()
// container should be effectively empty (no dialog rendered with meaningful content)
expect(container.textContent).toBe('')
})
it('shows first/last version footer for multi-point history', () => {
renderModal(palatine) // 2 history points
// Footer should show "MFM 1.14: 50pts" and "MFM (current): 40pts"
expect(screen.getByText(/MFM 1\.14: 50pts/)).toBeInTheDocument()
expect(screen.getByText(/MFM \(current\): 40pts/)).toBeInTheDocument()
})
})