Files
finance/src/components/AddTransactionForm.tsx
GitHub Copilot 7e5ed585f7 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
2025-05-05 17:41:39 +00:00

272 lines
8.1 KiB
TypeScript

import { useStore } from '@nanostores/react';
import type React from 'react';
import { useEffect, useState } from 'react';
// Import store atoms and actions
import {
cancelEditingTransaction,
currentAccountId as currentAccountIdStore,
transactionSaved,
transactionToEdit as transactionToEditStore,
triggerRefresh,
} from '../stores/transactionStore';
import type { Transaction } from '../types';
export default function AddTransactionForm() {
// --- Read state from store ---
const currentAccountId = useStore(currentAccountIdStore);
const transactionToEdit = useStore(transactionToEditStore);
// --- State Variables ---
const [date, setDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState('');
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(() => {
if (!transactionToEdit) {
setDate(new Date().toISOString().split('T')[0]);
}
}, [transactionToEdit]);
// Effect to populate form when editing
useEffect(() => {
if (transactionToEdit) {
setEditingId(transactionToEdit.id);
try {
const dateObj = new Date(transactionToEdit.date);
if (!Number.isNaN(dateObj.getTime())) {
setDate(dateObj.toISOString().split('T')[0]);
} else {
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate('');
}
} catch (e) {
console.error('Error parsing date for editing:', e);
setDate('');
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
setError(null);
setSuccessMessage(null);
} else if (!isLoading) {
resetForm();
}
}, [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 = () => {
setEditingId(null);
setDate(new Date().toISOString().split('T')[0]);
setDescription('');
setAmount('');
setError(null);
setSuccessMessage(null);
};
const validateForm = (): string[] => {
const errors: string[] = [];
if (!description || description.trim().length < 2) {
errors.push('Description must be at least 2 characters long');
}
if (!amount) {
errors.push('Amount is required');
} else {
const amountNum = Number.parseFloat(amount);
if (Number.isNaN(amountNum)) {
errors.push('Amount must be a valid number');
} else if (amountNum === 0) {
errors.push('Amount cannot be zero');
}
}
if (!date) {
errors.push('Date is required');
} else {
try {
const dateObj = new Date(`${date}T00:00:00`);
if (Number.isNaN(dateObj.getTime())) {
errors.push('Invalid date format');
}
} catch (e) {
errors.push('Invalid date format');
}
}
return errors;
};
// --- Event Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccessMessage(null);
if (isLoading || !currentAccountId) {
if (!currentAccountId) setError('No account selected.');
return;
}
const validationErrors = validateForm();
if (validationErrors.length > 0) {
setError(validationErrors.join('. '));
return;
}
setIsLoading(true);
try {
const transactionData = {
accountId: currentAccountId,
date: date,
description: description.trim(),
amount: Number.parseFloat(amount),
};
const method = editingId ? 'PUT' : 'POST';
const url = editingId ? `/api/transactions/${editingId}` : '/api/transactions';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(transactionData),
});
if (!response.ok) {
let errorMsg = `Failed to ${isEditMode ? 'update' : 'create'} transaction`;
try {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
} catch (jsonError) {
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
const savedTransaction: Transaction = await response.json();
// 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) {
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
setError(errorMessage);
setSuccessMessage(null);
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
resetForm();
cancelEditingTransaction();
};
// --- JSX ---
return (
<form id="add-transaction-form-react" role="form" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
{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>
<input
type="date"
id="txn-date-react"
name="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="txn-description-react">Description</label>
<input
type="text"
id="txn-description-react"
name="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={2}
maxLength={100}
placeholder="e.g. Groceries"
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="txn-amount-react">Amount</label>
<input
type="number"
id="txn-amount-react"
name="amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
step="0.01"
required
placeholder="e.g. -25.50 or 1200.00"
disabled={isLoading}
/>
<small className="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
</div>
<div className="button-group">
<button
type="submit"
className={`form-submit-btn ${isLoading ? 'loading' : ''}`}
disabled={isLoading}
>
{isLoading ? 'Saving...' : isEditMode ? 'Update Transaction' : 'Save Transaction'}
</button>
{isEditMode && (
<button type="button" className="cancel-btn" onClick={handleCancel} disabled={isLoading}>
Cancel
</button>
)}
</div>
</form>
);
}