Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/components/Chart.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,62 @@
margin-top: 0;
}

.chart-wrapper {
position: relative;
}

.chart-svg {
display: block;
width: 100%;
height: auto;
}

.chart-bar {
cursor: pointer;
transition: fill 0.15s ease;
}

.chart-tooltip {
position: absolute;
bottom: 100%;
left: 0;
transform: translateX(-50%);
z-index: 10;
padding: 0.35rem 0.6rem;
border-radius: 6px;
background: #0b1120;
border: 1px solid #334155;
color: var(--color-text);
font-size: 0.75rem;
white-space: nowrap;
pointer-events: none;
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}

.chart-tooltip-value {
font-weight: 600;
font-size: 0.8rem;
}

.chart-tooltip-label {
font-size: 0.65rem;
color: var(--color-muted, #94a3b8);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}

@media (prefers-contrast: more) {
.chart-tooltip {
border-width: 2px;
background: #000;
}
}

.download-button {
margin-top: 1rem;
}
45 changes: 31 additions & 14 deletions src/components/Chart.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import './Chart.css';
import Button from './Button.jsx';

export default function Chart({ data, title }) {
export default function Chart({ data, title, formatValue }) {
const chartRef = useRef(null);
const [hoveredIndex, setHoveredIndex] = useState(null);

const downloadChart = () => {
const svg = chartRef.current;
Expand All @@ -21,22 +22,38 @@ export default function Chart({ data, title }) {
};

const maxValue = Math.max(...data.map(d => d.value), 1);
const barCount = data.length;

return (
<div className="chart-container">
<h3 className="chart-title">{title}</h3>
<svg ref={chartRef} className="chart-svg" viewBox="0 0 100 100">
{data.map((d, i) => (
<rect
key={i}
x={i * (100 / data.length)}
y={100 - (d.value / maxValue) * 100}
width={100 / data.length - 2}
height={(d.value / maxValue) * 100}
fill="#6366f1"
/>
))}
</svg>
<div className="chart-wrapper">
<svg ref={chartRef} className="chart-svg" viewBox="0 0 100 100">
{data.map((d, i) => (
<rect
key={i}
x={i * (100 / barCount)}
y={100 - (d.value / maxValue) * 100}
width={100 / barCount - 2}
height={(d.value / maxValue) * 100}
fill={hoveredIndex === i ? '#4f46e5' : '#6366f1'}
className="chart-bar"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
/>
))}
</svg>
{hoveredIndex !== null && (
<div className="chart-tooltip" role="tooltip" style={{ left: `${(hoveredIndex + 0.5) * (100 / barCount)}%` }}>
<div className="chart-tooltip-value">
{formatValue ? formatValue(data[hoveredIndex]) : data[hoveredIndex].value}
</div>
{data[hoveredIndex].label && (
<div className="chart-tooltip-label">{data[hoveredIndex].label}</div>
)}
</div>
)}
</div>
<div className="download-button">
<Button onClick={downloadChart}>Download SVG</Button>
</div>
Expand Down
50 changes: 50 additions & 0 deletions src/components/Chart.stories.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Chart from './Chart.jsx'
import { formatAmount } from '../utils/format.js'

export default {
title: 'Components/Chart',
component: Chart,
tags: ['autodocs'],
argTypes: {
title: { control: 'text' },
data: { control: 'object' },
formatValue: { control: false }
}
}

const sampleData = [
{ value: 200, label: 'amina@example.com', currency: 'USD' },
{ value: 120, label: 'GBQAZ7Z3X7...', currency: 'USD' },
{ value: 450, label: 'chidi@example.com', currency: 'USD' },
{ value: 80, label: 'devi@example.com', currency: 'USD' },
{ value: 310, label: 'emeka@example.com', currency: 'USD' }
]

export const Default = {
args: {
title: 'Recent Transfer Amounts',
data: [
{ value: 200 },
{ value: 120 },
{ value: 450 },
{ value: 80 },
{ value: 310 }
]
}
}

export const WithFormattedValues = {
args: {
title: 'Recent Transfer Amounts',
data: sampleData,
formatValue: (d) => formatAmount(d.value, d.currency)
}
}

export const SingleBar = {
args: {
title: 'Single Transfer',
data: [{ value: 250, label: 'juan@example.com', currency: 'USD' }],
formatValue: (d) => formatAmount(d.value, d.currency)
}
}
4 changes: 3 additions & 1 deletion src/pages/Transfers.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import Chart from '../components/Chart.jsx'
import { formatAmount } from '../utils/format.js'
import TransferRow from '../components/TransferRow.jsx'
import Skeleton from '../components/Skeleton.jsx'
import ErrorMessage from '../components/ErrorMessage.jsx'
Expand Down Expand Up @@ -157,7 +158,8 @@ export default function Transfers() {
title="Recent Transfer Amounts"
data={filteredTransfers
.slice(0, 5)
.map((t) => ({ value: parseFloat(t.sendAmount) }))}
.map((t) => ({ value: parseFloat(t.sendAmount), label: t.recipient, currency: t.from }))}
formatValue={(d) => formatAmount(d.value, d.currency)}
/>
{filteredTransfers.map((t) => (
<TransferRow key={t.id} transfer={t} locale={locale} />
Expand Down
61 changes: 60 additions & 1 deletion test/components/Chart.test.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,70 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import Chart from '../../src/components/Chart.jsx'

describe('Chart component', () => {
it('renders correctly', () => {
it('renders title', () => {
const data = [{ value: 10 }, { value: 20 }]
render(<Chart data={data} title="Test Chart" />)
expect(screen.getByText('Test Chart')).toBeInTheDocument()
})

it('renders the correct number of bars', () => {
const data = [{ value: 10 }, { value: 20 }, { value: 30 }]
const { container } = render(<Chart data={data} title="Test" />)
const bars = container.querySelectorAll('.chart-bar')
expect(bars).toHaveLength(3)
})

it('shows tooltip on bar hover', async () => {
const user = userEvent.setup()
const data = [{ value: 10 }, { value: 20 }]
const { container } = render(<Chart data={data} title="Test" />)
const bars = container.querySelectorAll('.chart-bar')

await user.hover(bars[0])
expect(screen.getByRole('tooltip')).toBeInTheDocument()

await user.unhover(bars[0])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})

it('displays raw value in tooltip by default', async () => {
const user = userEvent.setup()
const data = [{ value: 42 }, { value: 20 }]
const { container } = render(<Chart data={data} title="Test" />)
const bars = container.querySelectorAll('.chart-bar')

await user.hover(bars[0])
expect(screen.getByRole('tooltip')).toHaveTextContent('42')
})

it('displays formatted value when formatValue is provided', async () => {
const user = userEvent.setup()
const data = [{ value: 42, currency: 'USD' }, { value: 20 }]
const { container } = render(
<Chart
data={data}
title="Test"
formatValue={(d) => `$${d.value.toFixed(2)} ${d.currency || ''}`.trim()}
/>
)
const bars = container.querySelectorAll('.chart-bar')

await user.hover(bars[0])
expect(screen.getByRole('tooltip')).toHaveTextContent('$42.00 USD')
})

it('displays label in tooltip when data items have a label', async () => {
const user = userEvent.setup()
const data = [{ value: 100, label: 'alice@example.com' }, { value: 200 }]
const { container } = render(<Chart data={data} title="Test" />)
const bars = container.querySelectorAll('.chart-bar')

await user.hover(bars[0])
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent('100')
expect(tooltip).toHaveTextContent('alice@example.com')
})
})
Loading