Fix: Update button remaining disabled in transaction edit mode

This commit resolves an issue where the Update button in the transaction form
would remain disabled when attempting to edit a transaction. The problem was
in how the transactionStore was managing state updates during transaction editing.

Key changes:
- Enhanced startEditingTransaction function in transactionStore.ts to ensure proper reactivity
- Added clean copy creation of transaction objects to avoid reference issues
- Implemented a state update cycle with null value first to force reactivity
- Added a small timeout to ensure state changes are properly detected by components

The Transaction form now correctly enables the Update button when in edit mode,
regardless of account selection state.
This commit is contained in:
GitHub Copilot
2025-05-05 21:29:36 +00:00
parent d3855aa7e4
commit 07fbb82385
27 changed files with 2961 additions and 952 deletions

View File

@@ -1,238 +1,269 @@
import { useStore } from '@nanostores/react';
import type React from 'react';
import { useEffect, useState } from 'react';
// Import store atoms and actions
import React, { useEffect, useState } from 'react';
import {
cancelEditingTransaction,
currentAccountId as currentAccountIdStore,
currentAccountId,
loadTransactionsForAccount,
transactionSaved,
transactionToEdit as transactionToEditStore,
transactionToEdit,
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);
const accountId = useStore(currentAccountId);
const editingTransaction = useStore(transactionToEdit);
// --- State Variables ---
// Form state - initialize with empty values to avoid hydration mismatch
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 [category, setCategory] = useState('');
const [type, setType] = useState('WITHDRAWAL');
const [isSubmitting, setIsSubmitting] = 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
// Set initial date only on client-side after component mounts
useEffect(() => {
// Only set default date if not editing
if (!transactionToEdit) {
setDate(new Date().toISOString().split('T')[0]);
// Only run this effect on the client side to prevent hydration mismatch
if (!date) {
const today = new Date();
setDate(today.toISOString().split('T')[0]);
}
}, [transactionToEdit]); // Rerun if edit mode changes
}, []);
// Effect to populate form when editing
// Reset form when accountId changes or when switching from edit to add mode
useEffect(() => {
if (transactionToEdit) {
setEditingId(transactionToEdit.id);
// Format date correctly for input type="date"
if (!editingTransaction) {
resetForm();
}
}, [accountId, editingTransaction === null]);
// Populate form when editing a transaction
useEffect(() => {
if (editingTransaction) {
let dateStr: string;
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]);
if (editingTransaction.date instanceof Date) {
dateStr = editingTransaction.date.toISOString().split('T')[0];
} else {
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate(''); // Set to empty if invalid
// Handle string dates safely
const parsedDate = new Date(String(editingTransaction.date));
dateStr = Number.isNaN(parsedDate.getTime())
? new Date().toISOString().split('T')[0] // Fallback to today if invalid
: parsedDate.toISOString().split('T')[0];
}
} catch (e) {
console.error('Error parsing date for editing:', e);
setDate(''); // Set to empty on error
} catch (error) {
console.error('Error parsing date for edit:', error);
dateStr = new Date().toISOString().split('T')[0]; // Fallback to today
}
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();
}
}
}, [transactionToEdit, isLoading]); // Add isLoading dependency
// --- Helper Functions ---
setDate(dateStr);
setDescription(editingTransaction.description);
setAmount(String(Math.abs(editingTransaction.amount)));
setCategory(editingTransaction.category || '');
setType(editingTransaction.amount < 0 ? 'WITHDRAWAL' : 'DEPOSIT');
}
}, [editingTransaction]);
const resetForm = () => {
setEditingId(null);
setDate(new Date().toISOString().split('T')[0]);
// Get today's date in YYYY-MM-DD format for the date input
const today = new Date().toISOString().split('T')[0];
setDate(today);
setDescription('');
setAmount('');
setCategory('');
setType('WITHDRAWAL');
setError(null);
// Don't reset isLoading here, it's handled in submit/cancel
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`); // Treat input as local date
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();
if (!accountId) {
setError('No account selected');
return;
}
if (!date || !description || !amount) {
setError('Date, description and amount are required');
return;
}
setIsSubmitting(true);
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);
// Calculate final amount based on type
const finalAmount = type === 'DEPOSIT' ? Math.abs(Number(amount)) : -Math.abs(Number(amount));
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
description: description.trim(),
amount: Number.parseFloat(amount),
};
let response;
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) {
// Ignore if response is not JSON
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
if (editingTransaction) {
// Update existing transaction
response = await fetch(`/api/transactions/${editingTransaction.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
});
} else {
// Create new transaction
response = await fetch('/api/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
});
}
const savedTransaction: Transaction = await response.json();
transactionSaved(savedTransaction); // Call store action instead of prop callback
resetForm(); // Reset form on success
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Transaction operation failed');
}
const savedTransaction = await response.json();
// Handle success
setSuccessMessage(
editingTransaction
? 'Transaction updated successfully!'
: 'Transaction added successfully!',
);
// Reset form
resetForm();
// Clear editing state
if (editingTransaction) {
cancelEditingTransaction();
}
// Notify about saved transaction
transactionSaved(savedTransaction);
// Reload transactions to ensure the list is up to date
await loadTransactionsForAccount(accountId);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
setError(err instanceof Error ? err.message : 'An unknown error occurred');
console.error('Transaction error:', err);
} finally {
setIsLoading(false);
setIsSubmitting(false);
}
};
const handleCancel = () => {
if (editingTransaction) {
cancelEditingTransaction();
}
resetForm();
cancelEditingTransaction(); // Call store action instead of prop callback
};
// --- JSX ---
return (
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
<div className="transaction-form-container">
<h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3>
{successMessage && <div className="success-message">{successMessage}</div>}
{error && <div className="error-message">{error}</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}>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="txn-date">Date:</label>
<input
type="date"
id="txn-date"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className="form-group">
<label htmlFor="txn-description">Description:</label>
<input
type="text"
id="txn-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Grocery store"
required
/>
</div>
<div className="form-group amount-group">
<label htmlFor="txn-amount">Amount:</label>
<div className="amount-input-group">
<select value={type} onChange={(e) => setType(e.target.value)} disabled={isSubmitting}>
<option value="WITHDRAWAL">-</option>
<option value="DEPOSIT">+</option>
</select>
<span className="currency-symbol">$</span>
<input
type="number"
id="txn-amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={isSubmitting}
step="0.01"
min="0"
placeholder="0.00"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="txn-category">Category (optional):</label>
<input
type="text"
id="txn-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Food, Bills, etc."
/>
</div>
<div className="form-actions">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="cancel-btn"
>
Cancel
</button>
)}
</div>
</form>
<button
type="submit"
// Allow submitting if we're editing a transaction, even if no account is currently selected
disabled={isSubmitting || (!editingTransaction && !accountId)}
className="submit-btn"
>
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -10,7 +10,7 @@ const { account } = Astro.props;
---
<main class="main-content">
<header class="main-header">
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.last4})</span></h1>
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.accountNumber.slice(-3)})</span></h1>
</header>
<TransactionTable client:load />
</main>

View File

@@ -1,31 +1,41 @@
---
import type { Account } from '../types';
import AccountSummary from './AccountSummary.tsx'; // Import the React component instead of the Astro one
import AddTransactionForm from './AddTransactionForm.tsx';
import type { Account } from "../types";
import AccountSummary from "./AccountSummary.tsx"; // Import the React component instead of the Astro one
import AddTransactionForm from "./AddTransactionForm.tsx";
interface Props {
accounts: Account[];
initialAccount: Account;
accounts: Account[];
initialAccount: Account;
}
const { accounts, initialAccount } = Astro.props;
---
<aside class="sidebar">
<div class="sidebar-header">
<h2>My finances</h2>
{/* Add button to toggle form visibility */}
<button id="toggle-add-txn-btn" aria-expanded="false" aria-controls="add-transaction-section">
<button
id="toggle-add-txn-btn"
aria-expanded="false"
aria-controls="add-transaction-section"
>
+ New Txn
</button>
</div>
<nav class="account-nav">
<h3>Accounts</h3>
<select id="account-select" name="account">
{accounts.map(account => (
<option value={account.id} selected={account.id === initialAccount.id}>
{account.name} (***{account.last4})
</option>
))}
{
accounts.map((account) => (
<option
value={account.id}
selected={account.id === initialAccount.id}
>
{account.name} (***{account.accountNumber.slice(-3)})
</option>
))
}
</select>
</nav>
@@ -34,31 +44,38 @@ const { accounts, initialAccount } = Astro.props;
{/* Section to contain the React form, initially hidden */}
<section id="add-transaction-section" class="collapsible collapsed">
{/*
{
/*
Use the React component here.
It now gets its state (currentAccountId, transactionToEdit)
directly from the Nano Store.
*/}
<AddTransactionForm client:load />
*/
}
<AddTransactionForm client:load />
</section>
</aside>
{/* Keep the script for toggling visibility for now */}
<script>
const toggleButton = document.getElementById('toggle-add-txn-btn');
const formSection = document.getElementById('add-transaction-section');
const toggleButton = document.getElementById("toggle-add-txn-btn");
const formSection = document.getElementById("add-transaction-section");
if (toggleButton && formSection) {
toggleButton.addEventListener('click', () => {
const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', String(!isExpanded));
formSection.classList.toggle('collapsed');
formSection.classList.toggle('expanded');
toggleButton.addEventListener("click", () => {
const isExpanded =
toggleButton.getAttribute("aria-expanded") === "true";
toggleButton.setAttribute("aria-expanded", String(!isExpanded));
formSection.classList.toggle("collapsed");
formSection.classList.toggle("expanded");
// Optional: Focus first field when expanding
if (!isExpanded) {
// Cast the result to HTMLElement before calling focus
(formSection.querySelector('input, select, textarea') as HTMLElement)?.focus();
(
formSection.querySelector(
"input, select, textarea",
) as HTMLElement
)?.focus();
}
});
}
</script>
</script>

View File

@@ -1,55 +1,82 @@
import { useStore } from '@nanostores/react';
import { useStore } from '@nanostores/react';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
currentAccountId as currentAccountIdStore,
currentTransactions as currentTransactionsStore,
refreshKey,
startEditingTransaction,
triggerRefresh,
loadTransactionsForAccount,
} from '../stores/transactionStore';
import type { Transaction } from '../types';
import { formatCurrency, formatDate } from '../utils';
type TransactionTableProps = {};
export default function TransactionTable({}: TransactionTableProps) {
export default function TransactionTable() {
const currentAccountId = useStore(currentAccountIdStore);
const refreshCounter = useStore(refreshKey);
const transactions = useStore(currentTransactionsStore);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Fetch transactions when account ID changes or refresh is triggered
const fetchTransactions = useCallback(async () => {
if (!currentAccountId) {
setTransactions([]);
console.log('TransactionTable: No account selected, skipping transaction load');
return;
}
const fetchTransactions = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/accounts/${currentAccountId}/transactions`);
if (!response.ok) {
throw new Error('Failed to fetch transactions');
}
const data: Transaction[] = await response.json();
setTransactions(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
setTransactions([]);
} finally {
setIsLoading(false);
}
};
setIsLoading(true);
setError(null);
fetchTransactions();
try {
console.log(`TransactionTable: Loading transactions for account ${currentAccountId}`);
await loadTransactionsForAccount(currentAccountId);
console.log('TransactionTable: Transactions loaded successfully');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
console.error('TransactionTable: Error loading transactions:', errorMessage);
setError(errorMessage);
} finally {
setIsLoading(false);
}
}, [currentAccountId]);
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
// Effect for loading transactions when account changes or refresh is triggered
useEffect(() => {
fetchTransactions();
}, [fetchTransactions, refreshCounter]);
// Safe sort function that handles invalid dates gracefully
const safeSort = (transactions: Transaction[]) => {
if (!Array.isArray(transactions)) {
console.warn('Expected transactions array but received:', transactions);
return [];
}
return [...transactions].sort((a, b) => {
try {
// Safely parse dates with validation
const dateA = a.date ? new Date(a.date).getTime() : 0;
const dateB = b.date ? new Date(b.date).getTime() : 0;
// If either date is invalid, use a fallback approach
if (Number.isNaN(dateA) || Number.isNaN(dateB)) {
console.warn('Found invalid date during sort:', { a: a.date, b: b.date });
// Sort by ID as fallback or keep original order
return (b.id || '').localeCompare(a.id || '');
}
return dateB - dateA; // Newest first
} catch (error) {
console.error('Error during transaction sort:', error);
return 0; // Keep original order on error
}
});
};
// Format transactions to display in table - with better error handling
const sortedTransactions = Array.isArray(transactions) ? safeSort(transactions) : [];
const handleDelete = async (txnId: string) => {
if (!confirm('Are you sure you want to delete this transaction?')) {
@@ -75,16 +102,10 @@ export default function TransactionTable({}: TransactionTableProps) {
}
console.log(`Transaction ${txnId} deleted successfully.`);
setTransactions((currentTransactions) =>
currentTransactions.filter((txn) => txn.id !== txnId),
);
triggerRefresh();
triggerRefresh(); // This will reload transactions
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
console.error('Delete error:', error);
} finally {
}
};
@@ -107,7 +128,7 @@ export default function TransactionTable({}: TransactionTableProps) {
// Helper function to render loading state
const renderLoading = () => (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '2rem' }}>
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem' }}>
Loading transactions...
</td>
</tr>
@@ -117,11 +138,12 @@ export default function TransactionTable({}: TransactionTableProps) {
const renderEmpty = () => (
<tr>
<td
colSpan={4}
colSpan={5}
style={{
textAlign: 'center',
fontStyle: 'italic',
color: '#777',
padding: '2rem 1rem',
}}
>
No transactions found for this account.
@@ -129,15 +151,16 @@ export default function TransactionTable({}: TransactionTableProps) {
</tr>
);
// Helper function to render transaction rows
// Helper function to render transaction rows with better error handling
const renderRows = () =>
sortedTransactions.map((txn) => (
<tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td>
<td>{txn.description}</td>
<td>{txn.description || 'No description'}</td>
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
{formatCurrency(txn.amount)}
</td>
<td>{txn.category || 'Uncategorized'}</td>
<td>
<button
type="button"
@@ -162,7 +185,7 @@ export default function TransactionTable({}: TransactionTableProps) {
return (
<div id="transaction-section" className={isLoading ? 'loading' : ''}>
{error && (
<div className="error-message" style={{ padding: '1rem' }}>
<div className="error-message" style={{ padding: '1rem', marginBottom: '1rem' }}>
Error loading transactions: {error}
</div>
)}
@@ -172,17 +195,16 @@ export default function TransactionTable({}: TransactionTableProps) {
<th>Date</th>
<th>Description</th>
<th className="amount-col">Amount</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="transaction-table-body">
{isLoading
? renderLoading()
: error
? null // Error message is shown above the table
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
</tbody>
</table>
</div>