feat: add testing infrastructure and improve component feedback

- Added React testing setup with JSDOM
- Added component tests for AddTransactionForm and TransactionTable
- Improved error handling and success messages in components
- Updated test configuration and dependencies
- Added CSS for error and success states
This commit is contained in:
GitHub Copilot
2025-05-05 17:41:39 +00:00
parent d3855aa7e4
commit 7e5ed585f7
19 changed files with 1299 additions and 1096 deletions

View File

@@ -1,12 +1,9 @@
import { useStore } from '@nanostores/react';
import type React from 'react';
import { useEffect, useState } from 'react';
import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore';
import type { Account } from '../types';
import { formatCurrency } from '../utils';
type AccountSummaryProps = {};
export default function AccountSummary() {
const currentAccountId = useStore(currentAccountIdStore);
const refreshCounter = useStore(refreshKey);
@@ -16,7 +13,7 @@ export default function AccountSummary() {
useEffect(() => {
if (!currentAccountId) {
setAccount(null); // Clear account details if no account selected
setAccount(null);
setError(null);
setIsLoading(false);
return;
@@ -40,30 +37,31 @@ export default function AccountSummary() {
}
};
fetchDetails();
}, [currentAccountId]);
// Add a small delay to ensure the API has processed any changes
const timeoutId = setTimeout(() => {
fetchDetails();
}, 100);
// Determine content based on state
let balanceContent: React.ReactNode;
return () => clearTimeout(timeoutId);
}, [currentAccountId, refreshCounter]); // Dependent on both account ID and refresh counter
let balanceContent = null;
if (isLoading) {
balanceContent = <span className="loading-inline">Loading...</span>;
} else if (error) {
balanceContent = <span className="error-message">Error</span>;
balanceContent = <span className="error-message">Error: {error}</span>;
} else if (account) {
balanceContent = formatCurrency(account.balance);
} else {
balanceContent = 'N/A'; // Or some placeholder
balanceContent = 'N/A';
}
return (
<div className="account-summary">
<h4>Account Summary</h4>
{/* Keep the ID for potential external manipulation if needed, though ideally not */}
<p>
Balance: <span id="account-balance">{balanceContent}</span>
</p>
{/* Display error details if needed */}
{/* {error && <small className="error-message">{error}</small>} */}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
currentAccountId as currentAccountIdStore,
transactionSaved,
transactionToEdit as transactionToEditStore,
triggerRefresh,
} from '../stores/transactionStore';
import type { Transaction } from '../types';
@@ -22,50 +23,52 @@ export default function AddTransactionForm() {
const [editingId, setEditingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isEditMode = !!editingId;
// --- Effects ---
// Effect to set default date on mount
useEffect(() => {
// Only set default date if not editing
if (!transactionToEdit) {
setDate(new Date().toISOString().split('T')[0]);
}
}, [transactionToEdit]); // Rerun if edit mode changes
}, [transactionToEdit]);
// Effect to populate form when editing
useEffect(() => {
if (transactionToEdit) {
setEditingId(transactionToEdit.id);
// Format date correctly for input type="date"
try {
const dateObj = new Date(transactionToEdit.date);
// Check if date is valid before formatting
if (!Number.isNaN(dateObj.getTime())) {
// Directly format the date object (usually interpreted as UTC midnight)
// into the YYYY-MM-DD format required by the input.
// No timezone adjustment needed here.
setDate(dateObj.toISOString().split('T')[0]);
} else {
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate(''); // Set to empty if invalid
setDate('');
}
} catch (e) {
console.error('Error parsing date for editing:', e);
setDate(''); // Set to empty on error
setDate('');
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
setError(null); // Clear errors when starting edit
} else {
// Reset form if transactionToEdit becomes null (e.g., after saving or cancelling)
// but only if not already resetting via handleCancel or handleSubmit
if (!isLoading) {
resetForm();
}
setError(null);
setSuccessMessage(null);
} else if (!isLoading) {
resetForm();
}
}, [transactionToEdit, isLoading]); // Add isLoading dependency
}, [transactionToEdit, isLoading]);
// Clear success message after 5 seconds
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => {
setSuccessMessage(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [successMessage]);
// --- Helper Functions ---
const resetForm = () => {
@@ -74,7 +77,7 @@ export default function AddTransactionForm() {
setDescription('');
setAmount('');
setError(null);
// Don't reset isLoading here, it's handled in submit/cancel
setSuccessMessage(null);
};
const validateForm = (): string[] => {
@@ -96,7 +99,7 @@ export default function AddTransactionForm() {
errors.push('Date is required');
} else {
try {
const dateObj = new Date(`${date}T00:00:00`); // Treat input as local date
const dateObj = new Date(`${date}T00:00:00`);
if (Number.isNaN(dateObj.getTime())) {
errors.push('Invalid date format');
}
@@ -111,6 +114,7 @@ export default function AddTransactionForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccessMessage(null);
if (isLoading || !currentAccountId) {
if (!currentAccountId) setError('No account selected.');
@@ -126,11 +130,9 @@ export default function AddTransactionForm() {
setIsLoading(true);
try {
// Ensure date is sent in a consistent format (e.g., YYYY-MM-DD)
// The API should handle parsing this.
const transactionData = {
accountId: currentAccountId,
date: date, // Send as YYYY-MM-DD string
date: date,
description: description.trim(),
amount: Number.parseFloat(amount),
};
@@ -150,17 +152,39 @@ export default function AddTransactionForm() {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
} catch (jsonError) {
// Ignore if response is not JSON
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
const savedTransaction: Transaction = await response.json();
transactionSaved(savedTransaction); // Call store action instead of prop callback
resetForm(); // Reset form on success
// First notify about the saved transaction
transactionSaved(savedTransaction);
// Then explicitly trigger a refresh to ensure balance updates
triggerRefresh();
// Set success message before clearing form
setSuccessMessage(
isEditMode ? 'Transaction updated successfully' : 'Transaction created successfully',
);
// Only reset the form after the success message is shown
setTimeout(() => {
resetForm();
// Optionally collapse the form after success
const addTransactionSection = document.getElementById('add-transaction-section');
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn');
if (addTransactionSection?.classList.contains('expanded')) {
addTransactionSection.classList.replace('expanded', 'collapsed');
toggleAddTxnBtn?.setAttribute('aria-expanded', 'false');
}
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
setError(errorMessage);
setSuccessMessage(null);
} finally {
setIsLoading(false);
}
@@ -168,14 +192,23 @@ export default function AddTransactionForm() {
const handleCancel = () => {
resetForm();
cancelEditingTransaction(); // Call store action instead of prop callback
cancelEditingTransaction();
};
// --- JSX ---
return (
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
<form id="add-transaction-form-react" role="form" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
{error && <div className="error-message">{error}</div>}
{error && (
<div className="error-message" data-testid="error-message">
{error}
</div>
)}
{successMessage && (
<div className="success-message" data-testid="success-message">
{successMessage}
</div>
)}
<div className="form-group">
<label htmlFor="txn-date-react">Date</label>

View File

@@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react';
import { useStore } from '@nanostores/react';
import React, { useState, useEffect } from 'react';
import {
currentAccountId as currentAccountIdStore,
@@ -45,7 +44,7 @@ export default function TransactionTable({}: TransactionTableProps) {
};
fetchTransactions();
}, [currentAccountId]);
}, [currentAccountId, refreshCounter]);
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
@@ -76,15 +75,16 @@ export default function TransactionTable({}: TransactionTableProps) {
console.log(`Transaction ${txnId} deleted successfully.`);
// Remove from local state
setTransactions((currentTransactions) =>
currentTransactions.filter((txn) => txn.id !== txnId),
);
// Trigger refresh to update balances and table
triggerRefresh();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
console.error('Delete error:', error);
} finally {
}
};