Skip to content

Commit 07fd662

Browse files
authored
Merge pull request #1253 from nomsoscript/feat/portfolio-chart-widget-loading-i18n
feat(frontend): improve portfolio chart loading and i18n
2 parents 8363dae + 0aedb56 commit 07fd662

5 files changed

Lines changed: 370 additions & 334 deletions

File tree

frontend/messages/en.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,5 +573,15 @@
573573
"successTitle": "KYC Submitted Successfully!",
574574
"successDescription": "Your KYC verification has been submitted and is under review.",
575575
"dash": ""
576+
},
577+
"portfolioChartWidget": {
578+
"title": "Portfolio Value",
579+
"allocation": "Allocation",
580+
"trend": "Trend",
581+
"loading": "Loading portfolio chart...",
582+
"historyLoading": "Loading performance history...",
583+
"emptyAllocation": "No allocation data available yet.",
584+
"emptyAssets": "No portfolio assets available yet.",
585+
"emptyHistory": "No performance history available yet."
576586
}
577587
}

frontend/messages/es.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,5 +573,15 @@
573573
"successTitle": "KYC enviado exitosamente!",
574574
"successDescription": "Tu verificacion KYC ha sido enviada y esta en revision.",
575575
"dash": ""
576+
},
577+
"portfolioChartWidget": {
578+
"title": "Valor del portafolio",
579+
"allocation": "Distribucion",
580+
"trend": "Tendencia",
581+
"loading": "Cargando grafico del portafolio...",
582+
"historyLoading": "Cargando historial de rendimiento...",
583+
"emptyAllocation": "Todavia no hay datos de asignacion.",
584+
"emptyAssets": "Todavia no hay activos en el portafolio.",
585+
"emptyHistory": "Todavia no hay historial de rendimiento."
576586
}
577587
}

frontend/messages/pt.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,5 +573,15 @@
573573
"successTitle": "KYC enviado com sucesso!",
574574
"successDescription": "Sua verificacao KYC foi enviada e esta em revisao.",
575575
"dash": ""
576+
},
577+
"portfolioChartWidget": {
578+
"title": "Valor do portafolio",
579+
"allocation": "Distribuicao",
580+
"trend": "Tendencia",
581+
"loading": "Carregando grafico do portafolio...",
582+
"historyLoading": "Carregando historico de desempenho...",
583+
"emptyAllocation": "Ainda nao ha dados de alocacao.",
584+
"emptyAssets": "Ainda nao ha ativos no portafolio.",
585+
"emptyHistory": "Ainda nao ha historico de desempenho."
576586
}
577587
}
Lines changed: 89 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
11
import React from 'react';
22
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import '@testing-library/jest-dom/vitest';
34
import { describe, it, expect, vi, beforeEach } from 'vitest';
45
import { PortfolioChartWidget, PortfolioAsset } from './PortfolioChartWidget';
56

6-
// Mock recharts to avoid canvas issues in tests
7+
const translations = {
8+
en: {
9+
portfolioChartWidget: {
10+
title: 'Portfolio Value',
11+
allocation: 'Allocation',
12+
trend: 'Trend',
13+
loading: 'Loading portfolio chart...',
14+
historyLoading: 'Loading performance history...',
15+
emptyAllocation: 'No allocation data available yet.',
16+
emptyAssets: 'No portfolio assets available yet.',
17+
emptyHistory: 'No performance history available yet.',
18+
},
19+
},
20+
es: {
21+
portfolioChartWidget: {
22+
title: 'Valor del portafolio',
23+
allocation: 'Distribucion',
24+
trend: 'Tendencia',
25+
loading: 'Cargando grafico del portafolio...',
26+
historyLoading: 'Cargando historial de rendimiento...',
27+
emptyAllocation: 'Todavia no hay datos de asignacion.',
28+
emptyAssets: 'Todavia no hay activos en el portafolio.',
29+
emptyHistory: 'Todavia no hay historial de rendimiento.',
30+
},
31+
},
32+
} as const;
33+
34+
let mockLocale: keyof typeof translations = 'en';
35+
36+
vi.mock('next-intl', () => ({
37+
useLocale: () => mockLocale,
38+
useTranslations: (namespace: keyof (typeof translations)['en']) => (key: string) =>
39+
translations[mockLocale][namespace][key as keyof (typeof translations)['en'][typeof namespace]] ?? key,
40+
}));
41+
742
vi.mock('recharts', () => ({
843
PieChart: ({ children }: any) => <div data-testid="pie-chart">{children}</div>,
944
Pie: ({ children, onClick, data }: any) => (
10-
<div
11-
data-testid="pie"
12-
onClick={() => onClick && onClick(data[0])}
13-
>
45+
<div data-testid="pie" onClick={() => onClick && data?.[0] && onClick(data[0])}>
1446
{children}
1547
</div>
1648
),
@@ -49,6 +81,11 @@ describe('PortfolioChartWidget', () => {
4981
},
5082
];
5183

84+
const historyData = [
85+
{ timestamp: Date.UTC(2026, 0, 1), value: 3200 },
86+
{ timestamp: Date.UTC(2026, 0, 2), value: 4000 },
87+
];
88+
5289
const defaultProps = {
5390
assets: mockAssets,
5491
totalValue: 4000,
@@ -58,103 +95,53 @@ describe('PortfolioChartWidget', () => {
5895

5996
beforeEach(() => {
6097
vi.clearAllMocks();
98+
mockLocale = 'en';
6199
});
62100

63-
it('renders the component with portfolio value', () => {
101+
it('renders the localized title and portfolio value', () => {
64102
render(<PortfolioChartWidget {...defaultProps} />);
65103

66104
expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
67105
expect(screen.getByText('$4,000.00')).toBeInTheDocument();
68106
});
69107

70-
it('displays the correct currency format', () => {
71-
render(
72-
<PortfolioChartWidget
73-
{...defaultProps}
74-
totalValue={5000}
75-
currency="EUR"
76-
/>
77-
);
78-
79-
// The component should format currency, checking for the value in the DOM
80-
const portfolioValue = screen.getByText(/Portfolio Value/i).parentElement;
81-
expect(portfolioValue).toBeInTheDocument();
82-
});
83-
84108
it('renders all assets in the list', () => {
85109
render(<PortfolioChartWidget {...defaultProps} />);
86110

87111
expect(screen.getByText('XLM')).toBeInTheDocument();
88112
expect(screen.getByText('USDC')).toBeInTheDocument();
89-
});
90-
91-
it('displays asset percentages correctly', () => {
92-
render(<PortfolioChartWidget {...defaultProps} />);
93-
94-
const percentageElements = screen.getAllByText(/50\.0%/);
95-
expect(percentageElements.length).toBeGreaterThan(0);
96-
});
97-
98-
it('displays asset amounts', () => {
99-
render(<PortfolioChartWidget {...defaultProps} />);
100-
101113
expect(screen.getByText('1000.0000 XLM')).toBeInTheDocument();
102-
expect(screen.getByText('500.0000 USDC')).toBeInTheDocument();
103114
});
104115

105-
it('switches between chart types when buttons are clicked', async () => {
106-
render(<PortfolioChartWidget {...defaultProps} />);
116+
it('switches to the history view when history data is available', async () => {
117+
render(<PortfolioChartWidget {...defaultProps} historyData={historyData} />);
107118

108-
const trendButton = screen.getByText('Trend');
109-
fireEvent.click(trendButton);
119+
fireEvent.click(screen.getByRole('button', { name: 'Trend' }));
110120

111121
await waitFor(() => {
112122
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
113123
});
114-
115-
const allocationButton = screen.getByText('Allocation');
116-
fireEvent.click(allocationButton);
117-
118-
await waitFor(() => {
119-
expect(screen.getByTestId('pie-chart')).toBeInTheDocument();
120-
});
121124
});
122125

123-
it('calls onAssetClick when an asset is clicked', () => {
124-
const onAssetClick = vi.fn();
125-
render(
126-
<PortfolioChartWidget
127-
{...defaultProps}
128-
onAssetClick={onAssetClick}
129-
/>
130-
);
126+
it('shows an accessible loading state and disables chart toggles', () => {
127+
render(<PortfolioChartWidget {...defaultProps} loading />);
131128

132-
const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
133-
if (assetElement) {
134-
fireEvent.click(assetElement);
135-
}
136-
137-
// Should be called (exact behavior depends on component implementation)
138-
expect(screen.getByText('XLM')).toBeInTheDocument();
129+
expect(screen.getByRole('status')).toHaveTextContent('Loading portfolio chart...');
130+
expect(screen.getByRole('button', { name: 'Allocation' })).toBeDisabled();
131+
expect(screen.getByRole('button', { name: 'Trend' })).toBeDisabled();
139132
});
140133

141-
it('toggles asset selection on click', () => {
142-
render(<PortfolioChartWidget {...defaultProps} />);
143-
144-
const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
134+
it('shows an empty history state when no trend data exists', async () => {
135+
render(<PortfolioChartWidget {...defaultProps} historyData={[]} />);
145136

146-
if (assetElement) {
147-
fireEvent.click(assetElement);
148-
// Check that the element has selected styling (bg-blue-50)
149-
expect(assetElement).toHaveClass('bg-blue-50');
137+
fireEvent.click(screen.getByRole('button', { name: 'Trend' }));
150138

151-
fireEvent.click(assetElement);
152-
// The selection might be toggled off
153-
expect(assetElement).toBeInTheDocument();
154-
}
139+
await waitFor(() => {
140+
expect(screen.getByText('No performance history available yet.')).toBeInTheDocument();
141+
});
155142
});
156143

157-
it('renders with empty assets array', () => {
144+
it('shows the empty assets state when no assets are provided', () => {
158145
render(
159146
<PortfolioChartWidget
160147
assets={[]}
@@ -164,127 +151,61 @@ describe('PortfolioChartWidget', () => {
164151
/>
165152
);
166153

167-
expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
168-
expect(screen.getByText('$0.00')).toBeInTheDocument();
154+
expect(screen.getByText('No allocation data available yet.')).toBeInTheDocument();
155+
expect(screen.getByText('No portfolio assets available yet.')).toBeInTheDocument();
169156
});
170157

171-
it('assigns colors from palette to assets without color', () => {
172-
const assetsWithoutColor: PortfolioAsset[] = [
173-
{
174-
id: '1',
175-
symbol: 'XLM',
176-
name: 'Stellar',
177-
amount: 100,
178-
value: 1000,
179-
percentage: 50,
180-
},
181-
{
182-
id: '2',
183-
symbol: 'USDC',
184-
name: 'USD Coin',
185-
amount: 100,
186-
value: 1000,
187-
percentage: 50,
188-
},
189-
];
158+
it('renders a locale-aware translation and currency format', () => {
159+
mockLocale = 'es';
160+
const formattedValue = new Intl.NumberFormat('es-ES', {
161+
style: 'currency',
162+
currency: 'EUR',
163+
minimumFractionDigits: 2,
164+
maximumFractionDigits: 2,
165+
}).format(5000);
190166

191167
render(
192-
<PortfolioChartWidget
193-
assets={assetsWithoutColor}
194-
totalValue={2000}
195-
showAnimation={false}
196-
/>
197-
);
198-
199-
expect(screen.getByText('XLM')).toBeInTheDocument();
200-
expect(screen.getByText('USDC')).toBeInTheDocument();
201-
});
202-
203-
it('handles custom className', () => {
204-
const { container } = render(
205168
<PortfolioChartWidget
206169
{...defaultProps}
207-
className="custom-class"
170+
totalValue={5000}
171+
currency="EUR"
208172
/>
209173
);
210174

211-
const mainDiv = container.querySelector('.custom-class');
212-
expect(mainDiv).toBeInTheDocument();
175+
expect(screen.getByText('Valor del portafolio')).toBeInTheDocument();
176+
expect(screen.getByText(formattedValue)).toBeInTheDocument();
213177
});
214178

215-
it('respects showAnimation prop', () => {
216-
const { rerender } = render(
179+
it('handles asset selection and onAssetClick callbacks', () => {
180+
const onAssetClick = vi.fn();
181+
render(
217182
<PortfolioChartWidget
218183
{...defaultProps}
219-
showAnimation={true}
184+
onAssetClick={onAssetClick}
220185
/>
221186
);
222187

223-
expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
188+
const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
189+
expect(assetElement).toBeInTheDocument();
224190

225-
rerender(
226-
<PortfolioChartWidget
227-
{...defaultProps}
228-
showAnimation={false}
229-
/>
230-
);
191+
if (assetElement) {
192+
fireEvent.click(assetElement);
193+
expect(assetElement).toHaveClass('bg-blue-50');
194+
}
231195

232-
expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
196+
expect(onAssetClick).toHaveBeenCalledWith(mockAssets[0]);
233197
});
234198

235-
it('handles currency formatting for different currencies', () => {
236-
const { rerender } = render(
237-
<PortfolioChartWidget
238-
{...defaultProps}
239-
currency="USD"
240-
totalValue={1000}
241-
/>
242-
);
243-
244-
expect(screen.getByText('$1,000.00')).toBeInTheDocument();
245-
246-
rerender(
199+
it('renders an error message when provided', () => {
200+
render(
247201
<PortfolioChartWidget
248202
{...defaultProps}
249-
currency="GBP"
250-
totalValue={1000}
203+
error="Unable to load the latest portfolio snapshot."
251204
/>
252205
);
253206

254-
// Should render with different currency formatting
255-
expect(screen.getByText(/Portfolio Value/)).toBeInTheDocument();
256-
});
257-
258-
it('handles large portfolio values', () => {
259-
const largeAssets: PortfolioAsset[] = [
260-
{
261-
id: '1',
262-
symbol: 'BTC',
263-
name: 'Bitcoin',
264-
amount: 0.5,
265-
value: 20000,
266-
percentage: 100,
267-
},
268-
];
269-
270-
const { container } = render(
271-
<PortfolioChartWidget
272-
assets={largeAssets}
273-
totalValue={20000}
274-
showAnimation={false}
275-
/>
207+
expect(screen.getByRole('alert')).toHaveTextContent(
208+
'Unable to load the latest portfolio snapshot.'
276209
);
277-
278-
// Find the total portfolio value (first $20,000.00 in the portfolio value section)
279-
const portfolioValueTexts = screen.getAllByText('$20,000.00');
280-
expect(portfolioValueTexts.length).toBeGreaterThan(0);
281-
});
282-
283-
it('displays asset color indicators', () => {
284-
render(<PortfolioChartWidget {...defaultProps} />);
285-
286-
const colorDots = screen.getAllByTestId('cell').length;
287-
// Should have color cells for each asset
288-
expect(colorDots).toBeGreaterThanOrEqual(0);
289210
});
290211
});

0 commit comments

Comments
 (0)