Skip to content

Commit 478e822

Browse files
authored
Merge pull request #1610 from madhavi-0701/feat/frontend-enhancements
feat: add polling to BackendCapabilitiesBanner, persist OnboardingChe…
2 parents 310e8df + d0ce70b commit 478e822

12 files changed

Lines changed: 577 additions & 464 deletions

frontend/src/App.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,7 @@ function App() {
307307
return (
308308
<div className={`App min-h-screen ${contentTopPad}`}>
309309
<RealtimeStatusBanner />
310-
<BackendCapabilitiesBanner
311-
notices={notices}
312-
loadError={loadError}
313-
loading={readinessLoading}
314-
belowRealtimeBar={false}
315-
/>
310+
<BackendCapabilitiesBanner belowRealtimeBar={false} />
316311
{showApiCompatibilityBanner && apiCompatibility ? (
317312
<div
318313
className={`fixed left-0 right-0 z-40 border-b px-4 py-3 text-sm ${
Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,133 @@
1-
import { describe, it, expect, afterEach } from 'vitest'
1+
import { describe, it, expect, afterEach, vi } from 'vitest'
22
import { render, screen, cleanup, within } from '@testing-library/react'
33
import BackendCapabilitiesBanner from './BackendCapabilitiesBanner'
44
import type { CapabilityNotice } from '../hooks/useReadinessReport'
55

6+
vi.mock('../hooks/queries/useReadinessQuery', () => ({
7+
useReadinessQuery: vi.fn(),
8+
}))
9+
10+
import { useReadinessQuery } from '../hooks/queries/useReadinessQuery'
11+
612
afterEach(cleanup)
713

8-
const defaultProps = { loadError: false, loading: false, belowRealtimeBar: false }
14+
const mockQuery = useReadinessQuery as unknown as ReturnType<typeof vi.fn>
15+
16+
function mockReturn(overrides: Partial<ReturnType<typeof useReadinessQuery>> = {}) {
17+
mockQuery.mockReturnValue({
18+
notices: [],
19+
loadError: false,
20+
loading: false,
21+
report: null,
22+
refresh: vi.fn(),
23+
...overrides,
24+
})
25+
}
926

1027
function notice(id: string, kind: CapabilityNotice['kind'] = 'disabled', text = 'Some issue.'): CapabilityNotice {
1128
return { id, kind, text }
1229
}
1330

1431
describe('BackendCapabilitiesBanner', () => {
32+
afterEach(() => {
33+
mockQuery.mockReset()
34+
})
35+
1536
it('renders nothing when no notices and no error', () => {
16-
const { container } = render(<BackendCapabilitiesBanner {...defaultProps} notices={[]} />)
37+
mockReturn()
38+
const { container } = render(<BackendCapabilitiesBanner />)
1739
expect(container.firstChild).toBeNull()
1840
})
1941

2042
it('renders load-error message when loadError is true and no notices', () => {
21-
render(<BackendCapabilitiesBanner {...defaultProps} loadError notices={[]} />)
43+
mockReturn({ loadError: true, notices: [] })
44+
render(<BackendCapabilitiesBanner />)
2245
expect(screen.getByRole('status')).toHaveTextContent(/could not load backend service status/i)
2346
})
2447

2548
it('renders notice text', () => {
26-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited', 'DB is down.')]} />)
49+
mockReturn({ notices: [notice('database', 'limited', 'DB is down.')] })
50+
render(<BackendCapabilitiesBanner />)
2751
expect(screen.getByText(/DB is down\./)).toBeInTheDocument()
2852
})
2953

3054
it('attaches a doc link for database notice', () => {
31-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
55+
mockReturn({ notices: [notice('database', 'limited')] })
56+
render(<BackendCapabilitiesBanner />)
3257
const link = screen.getByRole('link', { name: /database setup/i })
3358
expect(link).toHaveAttribute('href', expect.stringContaining('#database-setup'))
3459
expect(link).toHaveAttribute('target', '_blank')
3560
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
3661
})
3762

3863
it('attaches a doc link for queue-workers notice', () => {
39-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('queue-workers')]} />)
64+
mockReturn({ notices: [notice('queue-workers')] })
65+
render(<BackendCapabilitiesBanner />)
4066
const link = screen.getByRole('link', { name: /redis \/ worker setup/i })
4167
expect(link).toHaveAttribute('href', expect.stringContaining('CONTRIBUTING'))
4268
})
4369

4470
it('attaches a doc link for indexer notice', () => {
45-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('indexer')]} />)
71+
mockReturn({ notices: [notice('indexer')] })
72+
render(<BackendCapabilitiesBanner />)
4673
const link = screen.getByRole('link', { name: /environment setup/i })
4774
expect(link).toHaveAttribute('href', expect.stringContaining('ENVIRONMENT'))
4875
})
4976

5077
it('attaches a doc link for auto-rebalancer notice', () => {
51-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('auto-rebalancer')]} />)
78+
mockReturn({ notices: [notice('auto-rebalancer')] })
79+
render(<BackendCapabilitiesBanner />)
5280
const link = screen.getByRole('link', { name: /environment setup/i })
5381
expect(link).toBeInTheDocument()
5482
})
5583

5684
it('renders multiple notices each with their own link', () => {
5785
const notices = [notice('database', 'limited'), notice('queue-workers')]
58-
const { container } = render(<BackendCapabilitiesBanner {...defaultProps} notices={notices} />)
86+
mockReturn({ notices })
87+
const { container } = render(<BackendCapabilitiesBanner />)
5988
const banner = container.querySelector('[role="status"]')!
6089
expect(within(banner as HTMLElement).getByRole('link', { name: /database setup/i })).toBeInTheDocument()
6190
expect(within(banner as HTMLElement).getByRole('link', { name: /redis \/ worker setup/i })).toBeInTheDocument()
6291
})
6392

6493
it('applies top-14 class when belowRealtimeBar is true', () => {
65-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} belowRealtimeBar />)
94+
mockReturn({ notices: [notice('database', 'limited')] })
95+
render(<BackendCapabilitiesBanner belowRealtimeBar />)
6696
expect(screen.getByRole('status').className).toContain('top-14')
6797
})
6898

6999
it('applies top-0 class when belowRealtimeBar is false', () => {
70-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
100+
mockReturn({ notices: [notice('database', 'limited')] })
101+
render(<BackendCapabilitiesBanner />)
71102
expect(screen.getByRole('status').className).toContain('top-0')
72103
})
73104

74105
it('uses amber styling when any notice is limited', () => {
75-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
106+
mockReturn({ notices: [notice('database', 'limited')] })
107+
render(<BackendCapabilitiesBanner />)
76108
expect(screen.getByRole('status').className).toContain('amber')
77109
})
78110

79111
it('uses slate styling when all notices are disabled', () => {
80-
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('queue-workers', 'disabled')]} />)
112+
mockReturn({ notices: [notice('queue-workers', 'disabled')] })
113+
render(<BackendCapabilitiesBanner />)
81114
expect(screen.getByRole('status').className).not.toContain('amber')
82115
})
116+
117+
it('updates banner after a simulated capability change on subsequent poll', () => {
118+
mockReturn({ notices: [notice('database', 'limited', 'Initial issue.')] })
119+
const { unmount } = render(<BackendCapabilitiesBanner />)
120+
expect(screen.getByText(/Initial issue\./)).toBeInTheDocument()
121+
unmount()
122+
123+
mockQuery.mockReturnValue({
124+
notices: [notice('database', 'limited', 'Updated issue.')],
125+
loadError: false,
126+
loading: false,
127+
report: null,
128+
refresh: vi.fn(),
129+
})
130+
render(<BackendCapabilitiesBanner />)
131+
expect(screen.getByText(/Updated issue\./)).toBeInTheDocument()
132+
})
83133
})

frontend/src/components/BackendCapabilitiesBanner.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1+
import { useRef } from 'react'
12
import { Info, AlertTriangle, ExternalLink } from 'lucide-react'
3+
import { useReadinessQuery } from '../hooks/queries/useReadinessQuery'
24
import type { CapabilityNotice } from '../hooks/useReadinessReport'
35

46
type Props = {
5-
notices: CapabilityNotice[]
6-
loadError: boolean
7-
loading: boolean
8-
belowRealtimeBar: boolean
7+
belowRealtimeBar?: boolean
98
}
109

1110
interface NoticeHint {
@@ -40,15 +39,32 @@ const NOTICE_HINTS: Record<string, NoticeHint> = {
4039
},
4140
}
4241

43-
export default function BackendCapabilitiesBanner({ notices, loadError, loading, belowRealtimeBar }: Props) {
44-
const show = loadError || notices.length > 0
42+
function noticesEqual(a: CapabilityNotice[], b: CapabilityNotice[]): boolean {
43+
if (a.length !== b.length) return false
44+
for (let i = 0; i < a.length; i++) {
45+
if (a[i].id !== b[i].id || a[i].kind !== b[i].kind || a[i].text !== b[i].text) return false
46+
}
47+
return true
48+
}
49+
50+
export default function BackendCapabilitiesBanner({ belowRealtimeBar = false }: Props) {
51+
const { notices, loadError, loading } = useReadinessQuery()
52+
const lastNotices = useRef<CapabilityNotice[]>(notices)
53+
54+
if (!noticesEqual(notices, lastNotices.current)) {
55+
lastNotices.current = notices
56+
}
57+
58+
const stableNotices = lastNotices.current
59+
const show = loadError || stableNotices.length > 0
60+
4561
if (!show && !loading) {
4662
return null
4763
}
4864

4965
const positionClass = belowRealtimeBar ? 'top-14' : 'top-0'
5066

51-
if (loadError && notices.length === 0) {
67+
if (loadError && stableNotices.length === 0) {
5268
return (
5369
<div
5470
className={`fixed left-0 right-0 z-[38] border-b border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900/80 dark:text-slate-200 ${positionClass}`}
@@ -66,11 +82,11 @@ export default function BackendCapabilitiesBanner({ notices, loadError, loading,
6682
)
6783
}
6884

69-
if (notices.length === 0) {
85+
if (stableNotices.length === 0) {
7086
return null
7187
}
7288

73-
const hasLimited = notices.some((n) => n.kind === 'limited')
89+
const hasLimited = stableNotices.some((n) => n.kind === 'limited')
7490

7591
return (
7692
<div
@@ -89,7 +105,7 @@ export default function BackendCapabilitiesBanner({ notices, loadError, loading,
89105
: 'A few optional backend features are turned off for this environment. Nothing is wrong with your wallet — this is expected when Redis, workers, or certain flags are not enabled.'}
90106
</p>
91107
<ul className="space-y-1.5 leading-snug">
92-
{notices.map((n) => {
108+
{stableNotices.map((n) => {
93109
const hint = NOTICE_HINTS[n.id]
94110
return (
95111
<li key={n.id} className="flex gap-2">

frontend/src/components/CorrelationHeatmap.test.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
2-
import { describe, expect, it } from 'vitest'
3-
import { fireEvent, render, screen } from '@testing-library/react'
2+
import { describe, expect, it, afterEach } from 'vitest'
3+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
45
import CorrelationHeatmap, { correlationColor } from './CorrelationHeatmap'
56

67
const assets = ['XLM', 'BTC', 'ETH']
@@ -23,9 +24,19 @@ const matrices = {
2324
],
2425
}
2526

27+
const queryClient = new QueryClient({
28+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
29+
})
30+
31+
function Wrapper({ children }: { children: React.ReactNode }) {
32+
return React.createElement(QueryClientProvider, { client: queryClient }, children)
33+
}
34+
35+
afterEach(cleanup)
36+
2637
describe('CorrelationHeatmap', () => {
2738
it('renders diagonal cells as 1.0', () => {
28-
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
39+
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })
2940

3041
expect(screen.getByTestId('correlation-cell-0-0')).toHaveTextContent('1.0')
3142
expect(screen.getByTestId('correlation-cell-1-1')).toHaveTextContent('1.0')
@@ -39,15 +50,15 @@ describe('CorrelationHeatmap', () => {
3950
})
4051

4152
it('shows exact coefficient and pair names in the tooltip', async () => {
42-
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
53+
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })
4354

4455
fireEvent.mouseEnter(screen.getByTestId('correlation-cell-0-1'))
4556

4657
expect(await screen.findByRole('tooltip')).toHaveTextContent('XLM / BTC: -0.50')
4758
})
4859

4960
it('switches matrices when the time range changes', () => {
50-
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
61+
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })
5162

5263
expect(screen.getByTestId('correlation-cell-0-1')).toHaveTextContent('-0.50')
5364

@@ -64,9 +75,18 @@ describe('CorrelationHeatmap', () => {
6475
manyAssets.map((__, columnIndex) => (rowIndex === columnIndex ? 1 : 0.1)),
6576
)
6677

67-
render(<CorrelationHeatmap assets={manyAssets} correlations={{ '30D': matrix }} />)
78+
render(<CorrelationHeatmap assets={manyAssets} correlations={{ '30D': matrix }} />, { wrapper: Wrapper })
6879

6980
expect(screen.getAllByRole('gridcell')).toHaveLength(100)
7081
expect(screen.queryByText('A11')).not.toBeInTheDocument()
7182
})
83+
84+
it('opens a drill-down modal when a cell is clicked with correct asset pair context', async () => {
85+
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })
86+
87+
fireEvent.click(screen.getByTestId('correlation-cell-0-1'))
88+
89+
expect(screen.getByText(/correlation detail/i)).toBeInTheDocument()
90+
expect(screen.getByRole('heading', { name: /correlation detail/i })).toBeInTheDocument()
91+
})
7292
})

0 commit comments

Comments
 (0)