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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- "Copy details" button on each route card — copies the route path, fee,
and time as plain text to the clipboard (resolves #21).
- Loading state while routes are being calculated: short artificial delay
plus spinner, stylable ahead of Phase 2's real network calls (resolves #16).
- Unit test for `availableCurrencies()` (resolves #27).
- Component tests for `RouteList` covering the not-searched, empty, and
populated states (resolves #25).
Expand Down
19 changes: 16 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ export default function App() {
const [searched, setSearched] = useState(false)
const [lastQuery, setLastQuery] = useState(null)
const [engineError, setEngineError] = useState(null)
const [loading, setLoading] = useState(false)

const currencies = availableCurrencies()

function handleSubmit({ fromCurrency, toCurrency, amount }) {
async function handleSubmit({ fromCurrency, toCurrency, amount }) {
if (loading) return
setEngineError(null)
setSearched(false)
setLoading(true)
try {
await new Promise((resolve) => setTimeout(resolve, 500))
const found = findRoutesCached({ fromCurrency, toCurrency, amount, anchors: mockAnchors })
setRoutes(found)
setLastQuery({ fromCurrency, toCurrency, amount })
setSearched(true)
} catch (err) {
setEngineError(err.message)
setSearched(false)
} finally {
setLoading(false)
}
}

Expand All @@ -51,7 +57,14 @@ export default function App() {
</p>
)}

<RouteList routes={routes} searched={searched} />
{loading ? (
<div className="loading" role="status">
<span className="spinner" aria-hidden="true" />
Finding routes…
</div>
) : (
<RouteList routes={routes} searched={searched} />
)}
</main>

<footer>
Expand Down
23 changes: 23 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ header h1 {
padding: 1rem;
}

.loading {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--muted);
padding: 1rem 0;
}

.spinner {
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

.route-list {
list-style: none;
margin: 0;
Expand Down
54 changes: 54 additions & 0 deletions tests/App.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import App from '../src/App'

function setup() {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<App />)
})
return { container, root }
}

function cleanup(root) {
act(() => {
root.unmount()
})
}

beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})

afterEach(() => {
vi.useRealTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = false
document.body.innerHTML = ''
})

describe('App loading state', () => {
it('shows a loading indicator while routes are being calculated, then results', async () => {
const { container, root } = setup()
const form = container.querySelector('.remittance-form')

await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await Promise.resolve()
})

expect(container.querySelector('.loading')).toBeTruthy()
expect(container.querySelector('.loading').textContent).toContain('Finding routes')

await act(async () => {
vi.advanceTimersByTime(500)
await Promise.resolve()
})

expect(container.querySelector('.loading')).toBeFalsy()
cleanup(root)
})
})