feat: implement optimistic updates for transaction handling and improve category selection in combobox components for enhanced user experience

This commit is contained in:
Julien Froidefond
2025-12-08 07:44:02 +01:00
parent 1263ac9c52
commit 2177ae7b4a
4 changed files with 80 additions and 84 deletions

View File

@@ -341,6 +341,20 @@ export default function TransactionsPage() {
const updatedTransaction = { ...transaction, categoryId };
// Optimistic update: update the cache immediately
queryClient.setQueryData<typeof transactionsData>(
["transactions", transactionParams],
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
),
};
},
);
try {
await fetch("/api/banking/transactions", {
method: "PUT",
@@ -350,6 +364,8 @@ export default function TransactionsPage() {
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
// Revert optimistic update on error
invalidateTransactions();
}
};
@@ -385,8 +401,23 @@ export default function TransactionsPage() {
selectedTransactions.has(t.id),
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
setSelectedTransactions(new Set());
// Optimistic update: update the cache immediately
queryClient.setQueryData<typeof transactionsData>(
["transactions", transactionParams],
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t,
),
};
},
);
try {
await Promise.all(
transactionsToUpdate.map((t) =>
@@ -400,6 +431,8 @@ export default function TransactionsPage() {
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
// Revert optimistic update on error
invalidateTransactions();
}
};

View File

@@ -171,7 +171,7 @@ export function AccountFilterCombobox({
<div key={folder.id}>
{/* Folder row */}
<CommandItem
value={`folder-${currentPath}`}
value={`folder-${folder.id}`}
onSelect={() => handleSelectFolder(folder.id)}
style={{ paddingLeft: `${paddingLeft}px` }}
className="font-medium"
@@ -182,12 +182,9 @@ export function AccountFilterCombobox({
{isFolderPartiallySelected(folder.id) && (
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
)}
<Check
className={cn(
"h-4 w-4",
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0",
)}
/>
{isFolderSelected(folder.id) && (
<Check className="h-4 w-4" />
)}
</div>
</CommandItem>
@@ -198,7 +195,7 @@ export function AccountFilterCombobox({
return (
<CommandItem
key={account.id}
value={`${currentPath} ${account.name}`}
value={account.id}
onSelect={() => handleSelect(account.id)}
style={{ paddingLeft: `${paddingLeft + 16}px` }}
className="min-w-0"
@@ -210,12 +207,9 @@ export function AccountFilterCombobox({
({formatCurrency(total)})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id) ? "opacity-100" : "opacity-0",
)}
/>
{value.includes(account.id) && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
);
})}
@@ -292,7 +286,7 @@ export function AccountFilterCombobox({
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<Command value={isAll ? "all" : value.join(",")}>
<CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucun compte trouvé.</CommandEmpty>
@@ -312,12 +306,9 @@ export function AccountFilterCombobox({
)
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4",
isAll ? "opacity-100" : "opacity-0",
)}
/>
{isAll && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
</CommandGroup>
@@ -333,7 +324,7 @@ export function AccountFilterCombobox({
return (
<CommandItem
key={account.id}
value={`sans-dossier ${account.name}`}
value={account.id}
onSelect={() => handleSelect(account.id)}
className="min-w-0"
>
@@ -346,14 +337,9 @@ export function AccountFilterCombobox({
({formatCurrency(total)})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id)
? "opacity-100"
: "opacity-0",
)}
/>
{value.includes(account.id) && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
);
})}

View File

@@ -105,7 +105,7 @@ export function CategoryCombobox({
align={align}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<Command value={value || ""}>
<CommandInput placeholder="Rechercher une catégorie..." />
<CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -118,19 +118,16 @@ export function CategoryCombobox({
<span className="text-muted-foreground">
Aucune catégorie
</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === null ? "opacity-100" : "opacity-0",
)}
/>
{value === null && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
</CommandGroup>
<CommandGroup>
{parentCategories.map((parent) => (
<div key={parent.id}>
<CommandItem
value={`${parent.name}`}
value={parent.id}
onSelect={() => handleSelect(parent.id)}
>
<CategoryIcon
@@ -139,17 +136,14 @@ export function CategoryCombobox({
size={16}
/>
<span className="font-medium">{parent.name}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0",
)}
/>
{value === parent.id && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
{childrenByParent[parent.id]?.map((child) => (
<CommandItem
key={child.id}
value={`${parent.name} ${child.name}`}
value={child.id}
onSelect={() => handleSelect(child.id)}
className="pl-8"
>
@@ -159,12 +153,9 @@ export function CategoryCombobox({
size={16}
/>
<span>{child.name}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0",
)}
/>
{value === child.id && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</div>
@@ -207,7 +198,7 @@ export function CategoryCombobox({
align={align}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<Command value={value || ""}>
<CommandInput placeholder="Rechercher une catégorie..." />
<CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -227,7 +218,7 @@ export function CategoryCombobox({
{parentCategories.map((parent) => (
<div key={parent.id}>
<CommandItem
value={`${parent.name}`}
value={parent.id}
onSelect={() => handleSelect(parent.id)}
>
<CategoryIcon
@@ -246,7 +237,7 @@ export function CategoryCombobox({
{childrenByParent[parent.id]?.map((child) => (
<CommandItem
key={child.id}
value={`${parent.name} ${child.name}`}
value={child.id}
onSelect={() => handleSelect(child.id)}
className="pl-8"
>

View File

@@ -193,7 +193,7 @@ export function CategoryFilterCombobox({
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<Command value={isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")}>
<CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -212,15 +212,12 @@ export function CategoryFilterCombobox({
({filteredTransactions.length})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
isAll ? "opacity-100" : "opacity-0",
)}
/>
{isAll && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
<CommandItem
value="uncategorized non catégorisé"
value="uncategorized"
onSelect={() => handleSelect("uncategorized")}
className="min-w-0"
>
@@ -231,19 +228,16 @@ export function CategoryFilterCombobox({
({categoryCounts["uncategorized"]})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
isUncategorized ? "opacity-100" : "opacity-0",
)}
/>
{isUncategorized && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
</CommandGroup>
<CommandGroup heading="Catégories">
{parentCategories.map((parent) => (
<div key={parent.id}>
<CommandItem
value={`${parent.name}`}
value={parent.id}
onSelect={() => handleSelect(parent.id)}
className="min-w-0"
>
@@ -261,17 +255,14 @@ export function CategoryFilterCombobox({
({categoryCounts[parent.id]})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(parent.id) ? "opacity-100" : "opacity-0",
)}
/>
{value.includes(parent.id) && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
{childrenByParent[parent.id]?.map((child) => (
<CommandItem
key={child.id}
value={`${parent.name} ${child.name}`}
value={child.id}
onSelect={() => handleSelect(child.id)}
className="pl-8 min-w-0"
>
@@ -289,14 +280,9 @@ export function CategoryFilterCombobox({
({categoryCounts[child.id]})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(child.id)
? "opacity-100"
: "opacity-0",
)}
/>
{value.includes(child.id) && (
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</div>