feat(components): extract GraphModal from App
This commit is contained in:
79
react-app/src/components/GraphModal.jsx
Normal file
79
react-app/src/components/GraphModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
react-app/src/test/GraphModal.test.jsx
Normal file
65
react-app/src/test/GraphModal.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user