Merge pull request #16 from Compagnia-d-Arme-del-Santo-Luca/feat/people/personal-card
✨ feat(people): add employee details
|
After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 996 KiB After Width: | Height: | Size: 996 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 730 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 977 KiB After Width: | Height: | Size: 977 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 843 KiB After Width: | Height: | Size: 843 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 915 KiB After Width: | Height: | Size: 915 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import EmployeeDescriptionDialog, { EmployeeDescription } from './index'
|
||||||
|
|
||||||
|
describe('EmployeeDescriptionDialog', () => {
|
||||||
|
const base: EmployeeDescription = {
|
||||||
|
title: 'Sir Tester',
|
||||||
|
position: 'Tester General',
|
||||||
|
image: '/images/tester.jpg',
|
||||||
|
body: ['First paragraph', 'Second paragraph'],
|
||||||
|
captions: ['Caption A', 'Caption B'],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title, position, image and body paragraphs', () => {
|
||||||
|
render(<EmployeeDescriptionDialog open description={base} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(base.title)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(base.position!)).toBeInTheDocument()
|
||||||
|
const img = screen.getByAltText(base.title) as HTMLImageElement
|
||||||
|
expect(img).toBeInTheDocument()
|
||||||
|
expect(img.src).toContain(base.image)
|
||||||
|
expect(screen.getByText('First paragraph')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Second paragraph')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders captions when provided', () => {
|
||||||
|
render(<EmployeeDescriptionDialog open description={base} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Caption A')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Caption B')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when close button is clicked', () => {
|
||||||
|
const handleClose = vi.fn()
|
||||||
|
render(<EmployeeDescriptionDialog open description={base} onClose={handleClose} />)
|
||||||
|
|
||||||
|
const button = screen.getByLabelText('close')
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Dialog, DialogProps, DialogTitle, DialogContent, IconButton, Typography, Stack, Divider } from '@mui/material'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
|
||||||
|
export type EmployeeDescription = {
|
||||||
|
title: string
|
||||||
|
position?: string
|
||||||
|
image?: string
|
||||||
|
body?: string[]
|
||||||
|
captions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeDescriptionDialogProps = DialogProps & {
|
||||||
|
description: EmployeeDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmployeeDescriptionDialog: React.FC<EmployeeDescriptionDialogProps> = ({ description, ...props }) => (
|
||||||
|
<Dialog fullWidth {...props}>
|
||||||
|
<DialogTitle>
|
||||||
|
<Typography variant="h4" component="div">
|
||||||
|
{description.title}
|
||||||
|
</Typography>
|
||||||
|
{description.position && (
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{description.position}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={props.onClose as React.MouseEventHandler<HTMLButtonElement>}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
}}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<DialogContent sx={{ pt: 0, mb: 3, pb: 0 }}>
|
||||||
|
<img
|
||||||
|
srcSet={description.image}
|
||||||
|
src={description.image}
|
||||||
|
alt={description.title}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
{description.body &&
|
||||||
|
description.body.map((paragraph, idx) => (
|
||||||
|
<Typography key={idx} pb={1}>
|
||||||
|
{paragraph}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
{description.captions && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Stack color="text.secondary" mt={1} gap={1}>
|
||||||
|
{description.captions.map((caption, idx) => (
|
||||||
|
<Typography key={idx} variant="caption">
|
||||||
|
{caption}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default EmployeeDescriptionDialog
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
import EmplyeeCard from './index'
|
||||||
|
|
||||||
|
describe('EmplyeeCard', () => {
|
||||||
|
const name = 'John Doe'
|
||||||
|
const image = '/images/john.jpg'
|
||||||
|
const position = 'Blacksmith'
|
||||||
|
|
||||||
|
it('renders name, image and optional position', () => {
|
||||||
|
render(<EmplyeeCard name={name} image={image} position={position} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(name)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(position)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const img = screen.getByAltText(`${name} image`) as HTMLImageElement
|
||||||
|
expect(img).toBeInTheDocument()
|
||||||
|
expect(img.src).toContain(image)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClick with (event, description) payload', () => {
|
||||||
|
const description = { body: ['this is a description for John Doe'] }
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
render(<EmplyeeCard name={name} image={image} position={position} description={description} onClick={handleClick} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(name))
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(expect.any(Object), {
|
||||||
|
title: name,
|
||||||
|
image: image,
|
||||||
|
position: position,
|
||||||
|
...description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClick with the fields set in the description', () => {
|
||||||
|
const description = {
|
||||||
|
title: 'Jane Doe',
|
||||||
|
position: 'Barber',
|
||||||
|
image: '/images/jane.jpg',
|
||||||
|
body: ['this is a description for Jane Doe'],
|
||||||
|
}
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
render(<EmplyeeCard name={name} image={image} position={position} description={description} onClick={handleClick} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(name))
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(expect.any(Object), {
|
||||||
|
...description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import { Card, CardContent, CardMedia, CardProps, Typography } from '@mui/material'
|
import { FC, useMemo } from 'react'
|
||||||
import { FC } from 'react'
|
import { Card, CardActionArea, CardContent, CardMedia, CardProps, Typography } from '@mui/material'
|
||||||
|
|
||||||
export interface EmplyeeCardProps extends CardProps {
|
import { EmployeeDescription } from 'components/molecules/EmployeeDescription/index.js'
|
||||||
|
|
||||||
|
export type EmployeeCardProps = Omit<CardProps, 'onClick'> & {
|
||||||
name: string
|
name: string
|
||||||
image: string
|
image: string
|
||||||
position?: string
|
position?: string
|
||||||
|
description?: Partial<EmployeeDescription>
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>, description: EmployeeDescription) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmplyeeCard: FC<EmplyeeCardProps> = ({ name, position, image, sx, ...props }) => {
|
export const EmplyeeCard: FC<EmployeeCardProps> = ({ name, position, image, description = {}, sx, onClick, ...props }) => {
|
||||||
return (
|
const handleClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
<Card sx={{ flexShrink: 0, width: 270, height: 360, ...sx }} {...props}>
|
onClick(event, {
|
||||||
<CardMedia component="img" sx={{ minHeight: '140' }} image={image} alt={`${name} image`} />
|
title: name,
|
||||||
|
image: image,
|
||||||
|
position: position,
|
||||||
|
...description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const content = useMemo(
|
||||||
|
() => [
|
||||||
|
<CardMedia component="img" sx={{ minHeight: '140' }} image={image} alt={`${name} image`} />,
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography gutterBottom variant="h5" component="div">
|
<Typography gutterBottom variant="h5" component="div">
|
||||||
{name}
|
{name}
|
||||||
@@ -20,7 +32,13 @@ export const EmplyeeCard: FC<EmplyeeCardProps> = ({ name, position, image, sx, .
|
|||||||
{position}
|
{position}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>,
|
||||||
|
],
|
||||||
|
[name, position, image]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Card sx={{ flexShrink: 0, width: 270, height: 360, ...sx }} {...props} onClick={handleClick}>
|
||||||
|
<CardActionArea>{content}</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
import 'components/molecules/EmplyeeCard/index.js'
|
||||||
|
import EmplyeeStack from './index'
|
||||||
|
|
||||||
|
// Mock EmplyeeCard to make click handling deterministic in tests
|
||||||
|
vi.mock('components/molecules/EmplyeeCard/index.js', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
EmplyeeCard: (props: any) => (
|
||||||
|
<div
|
||||||
|
data-testid="mock-card"
|
||||||
|
onClick={e => props.onClick?.(e, { title: props.name, image: props.image, position: props.position, ...(props.description || {}) })}>
|
||||||
|
<img alt={`${props.name} image`} src={props.image} />
|
||||||
|
{props.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('EmplyeeStack', () => {
|
||||||
|
const items = [
|
||||||
|
{ name: 'Alice', image: '/images/alice.jpg', position: 'Armorer' },
|
||||||
|
{ name: 'Bob', image: '/images/bob.jpg', position: 'Fletcher' },
|
||||||
|
{ name: 'Charlie', image: '/images/charlie.jpg', position: 'Scribe' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('renders all provided cards (up to maxVisible)', () => {
|
||||||
|
render(<EmplyeeStack items={items} />)
|
||||||
|
|
||||||
|
const imgs = screen.getAllByRole('img')
|
||||||
|
expect(imgs).toHaveLength(items.length)
|
||||||
|
expect(imgs[0]).toHaveAttribute('alt', `${items[0].name} image`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders provided cards up to maxVisible', () => {
|
||||||
|
render(<EmplyeeStack items={items} maxVisible={2} />)
|
||||||
|
|
||||||
|
const imgs = screen.getAllByRole('img')
|
||||||
|
expect(imgs).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rotates the top card to the back on click', () => {
|
||||||
|
render(<EmplyeeStack items={items} />)
|
||||||
|
|
||||||
|
// initial order: Alice, Bob, Charlie
|
||||||
|
let imgs = screen.getAllByRole('img')
|
||||||
|
expect(imgs[0]).toHaveAttribute('alt', 'Alice image')
|
||||||
|
|
||||||
|
// clicking the shuffle IconButton rotates the deck
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[buttons.length - 1])
|
||||||
|
|
||||||
|
imgs = screen.getAllByRole('img')
|
||||||
|
// after rotation: Bob, Charlie, Alice
|
||||||
|
expect(imgs[0]).toHaveAttribute('alt', 'Bob image')
|
||||||
|
expect(imgs[1]).toHaveAttribute('alt', 'Charlie image')
|
||||||
|
expect(imgs[2]).toHaveAttribute('alt', 'Alice image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards and calls per-card onClick handlers with description payload', () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
const items = [
|
||||||
|
{ name: 'Alice', image: '/images/alice.jpg', position: 'Armorer', description: { body: ['A'] } },
|
||||||
|
{ name: 'Bob', image: '/images/bob.jpg', position: 'Fletcher' },
|
||||||
|
]
|
||||||
|
|
||||||
|
render(<EmplyeeStack items={items} onClick={handleClick} />)
|
||||||
|
|
||||||
|
const alice = screen.getByText('Alice')
|
||||||
|
fireEvent.click(alice)
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(expect.any(Object), {
|
||||||
|
title: 'Alice',
|
||||||
|
image: '/images/alice.jpg',
|
||||||
|
position: 'Armorer',
|
||||||
|
body: ['A'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
// src/components/StackedDeck.tsx
|
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box, IconButton } from '@mui/material'
|
||||||
import { EmplyeeCard, EmplyeeCardProps } from 'components/molecules/EmplyeeCard/index.js'
|
import ShuffleIcon from '@mui/icons-material/NextPlan'
|
||||||
|
|
||||||
|
import { EmplyeeCard, EmployeeCardProps } from 'components/molecules/EmplyeeCard/index.js'
|
||||||
|
import { EmployeeDescription } from 'components/molecules/EmployeeDescription/index.js'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: EmplyeeCardProps[]
|
items: EmployeeCardProps[]
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>, description: EmployeeDescription) => void
|
||||||
|
|
||||||
// Visual offsets
|
// Visual offsets
|
||||||
offsetStep?: number // px shift per card
|
offsetStep?: number // px shift per card
|
||||||
@@ -15,6 +18,7 @@ const EmplyeeStack: FC<Props> = ({
|
|||||||
offsetStep = 10, // px shift per card
|
offsetStep = 10, // px shift per card
|
||||||
maxVisible = 6, // how many cards we actually render (performance)
|
maxVisible = 6, // how many cards we actually render (performance)
|
||||||
items: initialItems,
|
items: initialItems,
|
||||||
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [items, setItems] = useState(initialItems)
|
const [items, setItems] = useState(initialItems)
|
||||||
|
|
||||||
@@ -28,20 +32,22 @@ const EmplyeeStack: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
if (items.length === 1) return <EmplyeeCard {...items[0]} onClick={onClick} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={handleTopClick}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: 270 + offsetStep * (initialItems.length - 1),
|
width: 270 + offsetStep * Math.min(initialItems.length - 1, maxVisible),
|
||||||
height: 360 + offsetStep * (initialItems.length - 1),
|
height: 360 + offsetStep * Math.min(initialItems.length - 1, maxVisible),
|
||||||
}}>
|
}}>
|
||||||
{items.slice(0, maxVisible).map((props, idx) => (
|
{items.slice(0, maxVisible).map((props, idx) => (
|
||||||
<EmplyeeCard
|
<EmplyeeCard
|
||||||
{...props}
|
{...props}
|
||||||
|
onClick={onClick}
|
||||||
key={idx}
|
key={idx}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: idx * offsetStep,
|
top: idx * offsetStep,
|
||||||
left: idx * offsetStep,
|
left: idx * offsetStep,
|
||||||
@@ -49,6 +55,21 @@ const EmplyeeStack: FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<IconButton
|
||||||
|
onClick={handleTopClick}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
'&:hover': { backgroundColor: 'background.paper' },
|
||||||
|
padding: 0,
|
||||||
|
right: Math.min(initialItems.length - 2, maxVisible - 1) * offsetStep,
|
||||||
|
top: -offsetStep,
|
||||||
|
zIndex: initialItems.length + 1,
|
||||||
|
rotate: '30deg',
|
||||||
|
}}>
|
||||||
|
<ShuffleIcon fontSize="large" />
|
||||||
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ const Description = (props: Props) => {
|
|||||||
<Typography variant="h4">{props.title}</Typography>
|
<Typography variant="h4">{props.title}</Typography>
|
||||||
{props.caption}
|
{props.caption}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid component={Typography} size={8}>
|
<Grid size={8}>{props.children}</Grid>
|
||||||
{props.children}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -73,9 +71,7 @@ const Description = (props: Props) => {
|
|||||||
<Typography variant="h4">{props.title}</Typography>
|
<Typography variant="h4">{props.title}</Typography>
|
||||||
{props.caption}
|
{props.caption}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid component={Typography} size={8}>
|
<Grid size={8}>{props.children}</Grid>
|
||||||
{props.children}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -98,7 +94,7 @@ const Description = (props: Props) => {
|
|||||||
})}>
|
})}>
|
||||||
<Typography variant="h4">{props.title}</Typography>
|
<Typography variant="h4">{props.title}</Typography>
|
||||||
{props.caption}
|
{props.caption}
|
||||||
<Typography mt={3}>{props.children}</Typography>
|
<Box mt={3}>{props.children}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
case 'left':
|
case 'left':
|
||||||
@@ -118,9 +114,11 @@ const Description = (props: Props) => {
|
|||||||
p: `${theme.spacing(10)} ${theme.spacing(5)} ${theme.spacing(5)} ${theme.spacing(5)}`,
|
p: `${theme.spacing(10)} ${theme.spacing(5)} ${theme.spacing(5)} ${theme.spacing(5)}`,
|
||||||
// p: '5.1em 3.1em 3.1em 3.1em',
|
// p: '5.1em 3.1em 3.1em 3.1em',
|
||||||
})}>
|
})}>
|
||||||
<Typography variant="h4">{props.title}</Typography>
|
<Typography variant="h4" mb={3}>
|
||||||
|
{props.title}
|
||||||
|
</Typography>
|
||||||
{props.caption}
|
{props.caption}
|
||||||
<Typography mt={3}>{props.children}</Typography>
|
<Box mt={3}>{props.children}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { screen, fireEvent } from '@testing-library/react'
|
||||||
|
|
||||||
|
import 'components/molecules/EmplyeeStack/index.js'
|
||||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||||
import ProjectsPage from '.'
|
import ProjectsPage from './index.js'
|
||||||
|
|
||||||
|
// Mock EmplyeeStack so tests can simulate a card click opening the description dialog
|
||||||
vi.stubGlobal('open', vi.fn())
|
vi.stubGlobal('open', vi.fn())
|
||||||
|
vi.mock('components/molecules/EmplyeeStack/index.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => (
|
||||||
|
<button
|
||||||
|
data-testid="mock-stack"
|
||||||
|
onClick={e => props.onClick?.(e, { title: 'Mock Person', position: 'Tester', image: '/img/mock.jpg', body: ['mock body'] })}>
|
||||||
|
MockStack
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('PeoplePage component', () => {
|
describe('PeoplePage component', () => {
|
||||||
test('renders without crashing and displays the correct project titles', () => {
|
test('renders without crashing and displays the correct project titles', () => {
|
||||||
render(<ProjectsPage />)
|
render(<ProjectsPage />)
|
||||||
|
|
||||||
// check the section titles
|
// page title renders and stacks are present (mocked)
|
||||||
expect(screen.getByText('Ferantino di Zampalocha')).toBeInTheDocument()
|
expect(screen.getByLabelText('page-title')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Galacino Galaci')).toBeInTheDocument()
|
const stacks = screen.getAllByTestId('mock-stack')
|
||||||
|
expect(stacks).not.toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens description dialog when a stack item is clicked', () => {
|
||||||
|
render(<ProjectsPage />)
|
||||||
|
|
||||||
|
const [btn] = screen.getAllByTestId('mock-stack')
|
||||||
|
fireEvent.click(btn)
|
||||||
|
|
||||||
|
// dialog should show the mocked person's title
|
||||||
|
expect(screen.getByText('Mock Person')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,214 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
|
|
||||||
import EmplyeeCard from 'components/molecules/EmplyeeCard/index.js'
|
|
||||||
import { Page } from 'components/templates/index.js'
|
import { Page } from 'components/templates/index.js'
|
||||||
import EmplyeeStack from '../../molecules/EmplyeeStack/index.js'
|
import { EmployeeCardProps } from 'components/molecules/EmplyeeCard/index.js'
|
||||||
|
import EmplyeeStack from 'components/molecules/EmplyeeStack/index.js'
|
||||||
|
import EmployeeDescriptionDialog, { EmployeeDescription } from 'components/molecules/EmployeeDescription/index.js'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const people: EmployeeCardProps[][] = [
|
||||||
|
// Bartolomeo Contrari e Zan Biancolin
|
||||||
|
[
|
||||||
|
// { name: "Alberto V d'Este", image: 'images/people/albertoV/avatar.jpg', position: 'Marchese' },
|
||||||
|
{
|
||||||
|
name: 'Bartolomeo Contrari',
|
||||||
|
image: 'images/people/bartolomeo_contrari/avatar.jpg',
|
||||||
|
position: 'Nobile',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
'Detto Boitus compare in un documento dotale per la figlia Agnese con il quale consegna a Turcolino Tramaione 10 lire di veneti grossi ed una casa in contrada San Paolo il cui valore è stato stimato 100 lire bolognesi.',
|
||||||
|
'Un documento dimostra che si trova fuori città: si era recato a Bologna pro negociis domini marchionis. Era evidentemente molto vicino alla famiglia Estensi.',
|
||||||
|
],
|
||||||
|
captions: ['SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Zan Biancolin',
|
||||||
|
image: 'images/people/zan_biancolin/avatar.jpg',
|
||||||
|
position: 'Cuoco',
|
||||||
|
description: {
|
||||||
|
body: ['Caxarolus, lardarolo. Era un venditore di formaggio e lardo.'],
|
||||||
|
captions: [
|
||||||
|
"SANFILIPPO, Carla M. L'onomastica ferrarese del primo Trecento e gli Instrumenta fidelitatis. Libreriauniversitaria.it edizioni, 2016.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Niccolò degli Obizzi e Ferantino
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Niccolò degli Obizzi',
|
||||||
|
image: 'images/people/niccolo_degli_obizzi/avatar.jpg',
|
||||||
|
position: 'Nobile',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
'Figlio di Giovanni degli Obizzi.',
|
||||||
|
"Comandante delle truppe del marchese Niccolò d'Este. Si distinse con il marchese nell'assedio di Reggio Emilia dove stava rinchiuso Ottobone Terzi, signore di Parma, principale istigatore della rivolta contro gli Estensi. In quella occasione diede a conoscere il suo coraggio e il valore del suo casato.",
|
||||||
|
"Accompagna Niccolò d'Este nei vari pellegrinaggi come Roma, Terra Santa e Santiago di Compostela. Nel faticoso pellegrinaggio in Terra Santa viene armato Cavaliere del Santo Sepolcro dallo stesso Marchese d'Este. In una tappa del pellegrinaggio a Santiago di Compostela viene armato Cavaliere anche dal Re di Francia.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ferantino',
|
||||||
|
image: 'images/people/ferantino/avatar.jpg',
|
||||||
|
position: 'Serragente',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
"Ferantino compare in un documento notarile redatto nell'agosto del 1371, con li quale la vedova di Francolino speziale, Beatrice, si trova in evidente difficoltà a soddisfare i creditori del defunto marito; a nome di due dei tre figli, nominati eredi universali, decide di alienare parte delle proprietà per ottenere la liquidità necessaria. Ne consegna una prima tranche a Ferantino di Ostellato, che le corrisponde il prezzo di 603 lire e 8 soldi di bolognini.",
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
'ASFe, A.N.A., matr. 13, not. Nicolò Sansilvestri, atti del 9 agosto 1371.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Galacino Galaci
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Galacino Galaci',
|
||||||
|
image: 'images/people/galacino_galaci/avatar.jpg',
|
||||||
|
position: 'Fante',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
"Le tracce documentarie relative a Galacino Galaci emergono all'inizio di gennaio 1394, in occasione dell'esecuzione del testamento di Lasia, figlia del fu Giovanni Beccaria, redatto dal notaio Agostino Caffarelli. I commissari testamentari ricordano che tra i legati figuravano cento lire da destinare secondo il loro giudizio. Avendo riconosciuto Isabetta, figlia di Galacino de Galaciis, iuvenis aetatis legitimae, e constatando che il padre, divitiis pauper, non disponeva dei mezzi necessari per maritarla, i commissari ritennero tale intervento iustam et bonam elemosinam e consegnarono a Galacino quarantatré lire marchesane pro dote et aliis necessitatibus pro maritando.",
|
||||||
|
"La somma, insolitamente elevata per un'elemosina dotale, potrebbe spiegarsi con un possibile legame di parentela tra i Galaci e la testatrice: lo stesso giorno, infatti, una Lasia del fu ser Francesco ricevette dodici lire da Antonio de Galaciis, qualificato come erede della defunta. La documentazione suggerisce inoltre che Galacino fosse Latius civis e privo di ricchezze in città; né lui né la figlia sono tuttavia esplicitamente definiti pauperes, elemento che contribuisce a spiegare l'entità del legato loro assegnato.",
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
'ASFe, ANA, matr. 17, not. Agostino Caffarelli, atto del 13 gennaio 1394 e successivo.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Zanino il Beccaio
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Zanino il Beccaio',
|
||||||
|
image: 'images/people/zannino_il_beccaio/avatar.jpg',
|
||||||
|
position: 'Fante',
|
||||||
|
description: {
|
||||||
|
image: 'images/people/zannino_il_beccaio/2025_04_12.jpg',
|
||||||
|
body: [
|
||||||
|
"Zanino compare come uno dei rappresentanti di Semiramide, vedova del notaio Giovanni de Richo ed erede del suo patrimonio. Il 5 novembre 1365, Zanino consegna 10 lire al frate domenicano Gregorio. L'atto specifica che si tratta de propriis denariis ipsius domine Simiramixie, pervenutile per eredità.",
|
||||||
|
'Zanino è uno dei vari intermediari di cui la donna si serve per adempiere, gradualmente e in modo formale, agli obblighi lasciati dal defunto.',
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
'BCAFe, Coll. Antonelli 868, not. Pietro Pialbene, b. 2, atto del 5 novembre 1365.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Gamberino di Bergantino
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Gamberino di Bergantino',
|
||||||
|
image: 'images/people/gamberino_di_bergantino/avatar.jpg',
|
||||||
|
position: 'Fante',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
'Le tracce relative a Gamberino di Bergantino emergono indirettamente dal testamento di Benvenuto Azzi, castaldo del marchese a Bergantino. Tra i legati figura la donazione a Martina di sex bonas pecudes, lascito di entità modesta che acquista tuttavia rilievo alla luce di alcuni elementi contestuali. Martina è infatti indicata come olim sua serva, e si può supporre che avesse già ricevuto quanto dovuto per i servizi prestati.',
|
||||||
|
"Circa due mesi prima della redazione del testamento, Benvenuto aveva consegnato a Zilio, padre del futuro marito di Martina, Gamberino anch'egli di Bergantino, beni mobili per un valore complessivo di novantotto lire. Le sei pecore menzionate nel testamento appaiono dunque come un'aggiunta rispetto a quanto già corrisposto, interpretabile come un dono ulteriore. È inoltre significativo che, nel dettare le proprie volontà corpore languens, Benvenuto si riferisca inizialmente a Martina come “figlia”, correggendo poi il termine in famula: una correzione formale che non annulla il valore simbolico dell'espressione originaria.",
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
"ASFe, ANA, Ivi, matr. 19, not. Giovanni Lucchi, atto dell'11 giugno 1383.",
|
||||||
|
'ASFe, ANA, Ivi, matr. 19, not. Giovanni Lucchi, atto del 19 agosto 1383.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Albertino della Muzzina
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Albertino della Muzzina',
|
||||||
|
image: 'images/people/albertino_della_muzzina/avatar.jpg',
|
||||||
|
position: 'Fante',
|
||||||
|
description: {
|
||||||
|
position: 'Falegname',
|
||||||
|
image: 'images/people/albertino_della_muzzina/2021_10_28.jpg',
|
||||||
|
|
||||||
|
body: [
|
||||||
|
"Le tracce del falegname Albertino, della contrada Muzzina, arrivano dal testamento che ha redatto il 21 febbraio 1379 davanti al notaio Pietro Pincerna, all'inteno del quale laconicamente instituit ellegit et esse voluit la moglie Isabetta quale erede di tutte le sue sostanze.",
|
||||||
|
],
|
||||||
|
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
'ASFe, ANA, matr. 7, not. Pietro Pincerna, atto del 21 febbraio 1379.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Benato Guerzi da Baura
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Benato Guerzi da Baura',
|
||||||
|
image: 'images/people/benato_guerzi_da_baura/avatar.jpg',
|
||||||
|
position: 'Fante',
|
||||||
|
description: {
|
||||||
|
image: 'images/people/benato_guerzi_da_baura/2025_04_12.jpg',
|
||||||
|
body: [
|
||||||
|
"Le tracce documentarie relative a Benato Guerzi emergono in relazione al testamento di Lanzone, figlio del fu Giovanni de Gloriis, redatto l'8 agosto 1378 davanti al notaio Natale Sovertari. Tra le disposizioni testamentarie figura un legato di venti lire bolognesi destinato a pauperibus domicelabus maritandis. La consegna del legato, annotata dallo stesso notaio tre anni più tardi pro anima, consente di identificare le beneficiarie.",
|
||||||
|
"L'esecuzione del testamento fu affidata al commissario Pietro Volpone, che, congiuntamente alla vedova Mina, provvide alla distribuzione delle somme, selezionando le nubili ritenute povere. Il 23 dicembre 1381, Agnese, figlia di Scrilinino Paganelli, proveniente da Baura e futura moglie di Benato Guerzi della stessa villa, ricevette quaranta soldi di aquilini.",
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
"ASFe, ANA, matr. 11, not. Natale Sovertari, atto dell'8 agosto 1378.",
|
||||||
|
'ASFe, ANA, matr. 11, not. Natale Sovertari, atto del 23 dicembre 1381.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Ghisola Casale da Castrocaro
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Ghisola Casale da Castrocaro',
|
||||||
|
image: 'images/people/ghisola_da_castrocaro/avatar.jpg',
|
||||||
|
position: 'Cuoca',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
'Figlia di un frate di un villaggio vicino a Castrocaro, vedova di Menguccio di Casale. Ha due figli, Betta ormai in età da marito e Biagio.',
|
||||||
|
"Ereditano una casa, un pezzo di terra coltivata a vigna di quindici pertiche, un pezzo di terra di quattro tornature, una di tre tornature e altri tre appezzamenti di terra rispettivamente di tre, di una e di mezza tornatura, più due pecore, due agnelli e un'asina.",
|
||||||
|
'Ghisola, vedova, doveva tutelare gli interessi del figlio minore e si doveva occupare del destino di Betta alla quale volle assegnare una dote. Fu destinata una piccola cassa con piccoli oggetti di un corredo del valore di 12 lire e un pezzo di terra.',
|
||||||
|
],
|
||||||
|
captions: ['MUZZARELLI, Maria Giuseppina. Guardaroba medievale : vesti e società dal XIII al XVI secolo. 1999.'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Perina
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Perina',
|
||||||
|
image: 'images/people/perina/avatar.jpg',
|
||||||
|
position: 'Moglie di Ferantino',
|
||||||
|
description: {
|
||||||
|
body: [
|
||||||
|
'Perina compare in un documento notarile che mette in risalto la presenza di capitali femminili del tutto esterni al controllo dei mariti.',
|
||||||
|
"Nell'agosto del 1371 la vedova di Francolino speziale, Beatrice, si trova in evidente difficoltà a soddisfare i creditori del defunto marito; a nome di due dei tre figli, nominati eredi universali, decide di alienare parte delle proprietà per ottenere la liquidità necessaria. Ne consegna una prima tranche a Ferantino di Ostellato, che le corrisponde il prezzo di 603 lire e 8 soldi di bolognini, e con un atto distinto, rogato dal notaio quello stesso giorno, vende un secondo gruppo di terre a Perina, moglie di Ferantino, la quale paga 75 lire bolognesi de propriis denariis dicte domine Perine.",
|
||||||
|
"In un regime di separazione dei beni, è importante che il notaio distingua le sostanze di Ferantino da quelle della moglie Perina e in questo caso si tratta evidentemente di denaro che la donna non ha portato all'interno del matrimonio con la propria dote (per la quale il marito avrebbe potuto agire in autonomia), ma che ha mantenuto un regime giuridico diverso, riservandosene tutti i diritti.",
|
||||||
|
],
|
||||||
|
captions: [
|
||||||
|
'SUPERBI, Silvia. In dotem pro dote et dotis nomine. Il sistema dotale tra norma e prassi nella Ferrara del XIV secolo. 2012.',
|
||||||
|
'ASFe, A.N.A., matr. 13, not. Nicolò Sansilvestri, atti del 9 agosto 1371.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
const emptyDialog: EmployeeDescription = { title: '' }
|
||||||
|
|
||||||
const ProjectsPage = () => {
|
const ProjectsPage = () => {
|
||||||
|
const [description, setDescription] = useState<EmployeeDescription | null>(null)
|
||||||
|
|
||||||
|
const handleCardClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>, description: EmployeeDescription) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDescription(description)
|
||||||
|
}
|
||||||
|
const handleClose = () => {
|
||||||
|
setDescription(null)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Page title="Personaggi">
|
<Page title="Personaggi">
|
||||||
<Typography pb={2}>
|
<Typography pb={2}>
|
||||||
@@ -13,28 +217,11 @@ const ProjectsPage = () => {
|
|||||||
comportamento sono fondamentali alla ricostruzione di uno spaccato della vita di un campo d'arme del tardo XIV Secolo.
|
comportamento sono fondamentali alla ricostruzione di uno spaccato della vita di un campo d'arme del tardo XIV Secolo.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box display="flex" gap={1} flexWrap="wrap" justifyContent="center">
|
<Box display="flex" gap={1} flexWrap="wrap" justifyContent="center">
|
||||||
<EmplyeeStack
|
{people.map((props, idx) => (
|
||||||
items={[
|
<EmplyeeStack key={idx} items={props} onClick={handleCardClick} />
|
||||||
// { name: "Alberto V d'Este", image: 'images/people/albertoV.jpg', position: 'Marchese' },
|
))}
|
||||||
{ name: 'Bartolomeo Contrari', image: 'images/people/bartolomeo_contrari.jpg', position: 'Nobile' },
|
|
||||||
{ name: 'Zan Biancolin', image: 'images/people/zanbiancolin.jpg', position: 'Cuoco' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<EmplyeeStack
|
|
||||||
items={[
|
|
||||||
{ name: 'Niccolò degli Obizzi', image: 'images/people/niccolo_degli_obizzi.jpg', position: 'Nobile' },
|
|
||||||
{ name: 'Ferantino di Zampalocha', image: 'images/people/ferantino_di_zampalocha.jpg', position: 'Serragente' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EmplyeeCard name="Galacino Galaci" image="images/people/galacino_galaci.jpg" position="Fante" />
|
|
||||||
<EmplyeeCard name="Zanino il Beccaio" image="images/people/zannino_il_beccaio.jpg" position="Fante" />
|
|
||||||
<EmplyeeCard name="Gamberino di Bergantino" image="images/people/gamberino_di_bergantino.jpg" position="Fante" />
|
|
||||||
<EmplyeeCard name="Albertino della Muzzina" image="images/people/albertino_della_muzzina.jpg" position="Fante" />
|
|
||||||
<EmplyeeCard name="Benato Guerzi da Baura" image="images/people/benato_guerzi_da_baura.jpg" position="Fante" />
|
|
||||||
<EmplyeeCard name="Ghisola Casale da Castrocaro" image="images/people/ghisola_da_castrocaro.jpg" position="Cuoca" />
|
|
||||||
<EmplyeeCard name="Perina" image="images/people/perina.jpg" position="Moglie di Ferantino" />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<EmployeeDescriptionDialog open={!!description} description={description ?? emptyDialog} onClose={handleClose} />
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||