mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
- 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
272 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|