feat(filters): surface active saved filter and allow updating in place
The Saved control now shows the saved filter the current selection matches (or is based on), with an unsaved-changes indicator. When the filters are edited, the menu offers "Update" to save over the existing filter instead of only creating a new one. Adds the PATCH endpoint and an order-insensitive fingerprint to detect matches.
This commit is contained in:
parent
f18c3ba61e
commit
9fb6f67d89
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\StoreSavedFilterRequest;
|
use App\Http\Requests\Api\StoreSavedFilterRequest;
|
||||||
|
use App\Http\Requests\Api\UpdateSavedFilterRequest;
|
||||||
use App\Models\SavedFilter;
|
use App\Models\SavedFilter;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -33,6 +34,17 @@ class SavedFilterController extends Controller
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(UpdateSavedFilterRequest $request, SavedFilter $savedFilter): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($savedFilter->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
|
$savedFilter->update(['filters' => $request->validated('filters')]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $savedFilter->only(['id', 'name', 'filters']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, SavedFilter $savedFilter): JsonResponse
|
public function destroy(Request $request, SavedFilter $savedFilter): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($savedFilter->user_id === $request->user()->id, 403);
|
abort_unless($savedFilter->user_id === $request->user()->id, 403);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateSavedFilterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'filters' => ['required', 'array'],
|
||||||
|
'filters.date_from' => ['nullable', 'date'],
|
||||||
|
'filters.date_to' => ['nullable', 'date'],
|
||||||
|
'filters.amount_min' => ['nullable', 'numeric'],
|
||||||
|
'filters.amount_max' => ['nullable', 'numeric'],
|
||||||
|
'filters.category_ids' => ['nullable', 'array'],
|
||||||
|
'filters.category_ids.*' => ['string'],
|
||||||
|
'filters.account_ids' => ['nullable', 'array'],
|
||||||
|
'filters.account_ids.*' => ['string'],
|
||||||
|
'filters.label_ids' => ['nullable', 'array'],
|
||||||
|
'filters.label_ids.*' => ['string'],
|
||||||
|
'filters.creditor_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'filters.debtor_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'filters.search' => ['nullable', 'string', 'max:200'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,16 +18,25 @@ import {
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
deserializeFilters,
|
deserializeFilters,
|
||||||
|
filtersFingerprint,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
type SerializedFilters,
|
type SerializedFilters,
|
||||||
serializeFilters,
|
serializeFilters,
|
||||||
} from '@/lib/transaction-filter-serialization';
|
} from '@/lib/transaction-filter-serialization';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { type TransactionFilters } from '@/types/transaction';
|
import { type TransactionFilters } from '@/types/transaction';
|
||||||
import { type UUID } from '@/types/uuid';
|
import { type UUID } from '@/types/uuid';
|
||||||
import { __ } from '@/utils/i18n';
|
import { __ } from '@/utils/i18n';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Bookmark, Plus, Trash2 } from 'lucide-react';
|
import {
|
||||||
import { useEffect, useState } from 'react';
|
Bookmark,
|
||||||
|
BookmarkCheck,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface SavedFilter {
|
interface SavedFilter {
|
||||||
|
|
@ -43,12 +52,44 @@ interface SavedFiltersProps {
|
||||||
|
|
||||||
export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>([]);
|
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<UUID | null>(null);
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const canSave = hasActiveFilters(filters);
|
const canSave = hasActiveFilters(filters);
|
||||||
|
|
||||||
|
const currentFingerprint = useMemo(
|
||||||
|
() => filtersFingerprint(serializeFilters(filters)),
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The saved filter that exactly matches the current filters, if any.
|
||||||
|
const matchingSaved = useMemo(
|
||||||
|
() =>
|
||||||
|
savedFilters.find(
|
||||||
|
(savedFilter) =>
|
||||||
|
filtersFingerprint(savedFilter.filters) ===
|
||||||
|
currentFingerprint,
|
||||||
|
) ?? null,
|
||||||
|
[savedFilters, currentFingerprint],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep track of the saved filter we're working from: it follows an exact
|
||||||
|
// match, and otherwise sticks around (so edits can be saved over it) until
|
||||||
|
// the filters are cleared.
|
||||||
|
useEffect(() => {
|
||||||
|
if (matchingSaved) {
|
||||||
|
setActiveId(matchingSaved.id);
|
||||||
|
} else if (!canSave) {
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
}, [matchingSaved, canSave]);
|
||||||
|
|
||||||
|
const activeFilter =
|
||||||
|
savedFilters.find((savedFilter) => savedFilter.id === activeId) ?? null;
|
||||||
|
const isDirty = activeFilter !== null && matchingSaved === null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
|
@ -69,6 +110,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleLoad(savedFilter: SavedFilter) {
|
function handleLoad(savedFilter: SavedFilter) {
|
||||||
|
setActiveId(savedFilter.id);
|
||||||
onLoad(deserializeFilters(savedFilter.filters));
|
onLoad(deserializeFilters(savedFilter.filters));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,6 +119,9 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
setSavedFilters((current) =>
|
setSavedFilters((current) =>
|
||||||
current.filter((item) => item.id !== savedFilter.id),
|
current.filter((item) => item.id !== savedFilter.id),
|
||||||
);
|
);
|
||||||
|
if (activeId === savedFilter.id) {
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/saved-filters/${savedFilter.id}`);
|
await axios.delete(`/api/saved-filters/${savedFilter.id}`);
|
||||||
|
|
@ -87,6 +132,26 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdate(savedFilter: SavedFilter) {
|
||||||
|
try {
|
||||||
|
const response = await axios.patch<{ data: SavedFilter }>(
|
||||||
|
`/api/saved-filters/${savedFilter.id}`,
|
||||||
|
{ filters: serializeFilters(filters) },
|
||||||
|
);
|
||||||
|
|
||||||
|
setSavedFilters((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === savedFilter.id ? response.data.data : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setActiveId(savedFilter.id);
|
||||||
|
toast.success(__('Filter updated'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update saved filter:', error);
|
||||||
|
toast.error(__('Failed to update the saved filter'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
if (!trimmedName || isSaving) {
|
if (!trimmedName || isSaving) {
|
||||||
|
|
@ -108,6 +173,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
a.name.localeCompare(b.name),
|
a.name.localeCompare(b.name),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setActiveId(response.data.data.id);
|
||||||
setSaveDialogOpen(false);
|
setSaveDialogOpen(false);
|
||||||
setName('');
|
setName('');
|
||||||
toast.success(__('Filter saved'));
|
toast.success(__('Filter saved'));
|
||||||
|
|
@ -127,13 +193,34 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
{activeFilter ? (
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
variant="secondary"
|
||||||
aria-label={__('Saved filters')}
|
className="max-w-[200px]"
|
||||||
>
|
aria-label={__('Saved filters')}
|
||||||
<Bookmark className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<BookmarkCheck className="mr-1 h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{activeFilter.name}
|
||||||
|
</span>
|
||||||
|
{isDirty && (
|
||||||
|
<span
|
||||||
|
className="ml-1 text-muted-foreground"
|
||||||
|
title={__('Unsaved changes')}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label={__('Saved filters')}
|
||||||
|
>
|
||||||
|
<Bookmark className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
<DropdownMenuLabel>{__('Saved filters')}</DropdownMenuLabel>
|
<DropdownMenuLabel>{__('Saved filters')}</DropdownMenuLabel>
|
||||||
|
|
@ -150,8 +237,18 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
onSelect={() => handleLoad(savedFilter)}
|
onSelect={() => handleLoad(savedFilter)}
|
||||||
className="group justify-between gap-2"
|
className="group justify-between gap-2"
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
{savedFilter.name}
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 shrink-0',
|
||||||
|
matchingSaved?.id === savedFilter.id
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{savedFilter.name}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -170,6 +267,19 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{isDirty && activeFilter && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleUpdate(activeFilter);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
{__('Update “:name”', { name: activeFilter.name })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
|
|
@ -178,7 +288,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
{__('Save current filters…')}
|
{__('Save as new filter…')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,23 @@ export function deserializeFilters(
|
||||||
export function hasActiveFilters(filters: TransactionFilters): boolean {
|
export function hasActiveFilters(filters: TransactionFilters): boolean {
|
||||||
return Object.keys(serializeFilters(filters)).length > 0;
|
return Object.keys(serializeFilters(filters)).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable, order-insensitive fingerprint of a serialized filter set, so two
|
||||||
|
* filter sets that select the same things compare equal regardless of the
|
||||||
|
* order ids were added or which empty keys are present.
|
||||||
|
*/
|
||||||
|
export function filtersFingerprint(filters: SerializedFilters): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
date_from: filters.date_from ?? null,
|
||||||
|
date_to: filters.date_to ?? null,
|
||||||
|
amount_min: filters.amount_min ?? null,
|
||||||
|
amount_max: filters.amount_max ?? null,
|
||||||
|
category_ids: [...(filters.category_ids ?? [])].sort(),
|
||||||
|
account_ids: [...(filters.account_ids ?? [])].sort(),
|
||||||
|
label_ids: [...(filters.label_ids ?? [])].sort(),
|
||||||
|
creditor_name: filters.creditor_name ?? '',
|
||||||
|
debtor_name: filters.debtor_name ?? '',
|
||||||
|
search: filters.search ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,5 +65,6 @@ Route::middleware(['web', 'auth'])->group(function () {
|
||||||
// Saved filters (shared between transactions and analysis screens)
|
// Saved filters (shared between transactions and analysis screens)
|
||||||
Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index');
|
Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index');
|
||||||
Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store');
|
Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store');
|
||||||
|
Route::patch('saved-filters/{savedFilter}', [SavedFilterController::class, 'update'])->name('api.saved-filters.update');
|
||||||
Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
|
Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,38 @@ test('the same name can be reused by a different user', function () {
|
||||||
->assertCreated();
|
->assertCreated();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a user can update their own saved filter', function () {
|
||||||
|
$savedFilter = SavedFilter::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'filters' => ['search' => 'old'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->patchJson("/api/saved-filters/{$savedFilter->id}", [
|
||||||
|
'filters' => ['search' => 'new', 'category_ids' => ['food']],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
expect($response->json('data.filters.search'))->toBe('new');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('saved_filters', [
|
||||||
|
'id' => $savedFilter->id,
|
||||||
|
'name' => $savedFilter->name,
|
||||||
|
]);
|
||||||
|
expect($savedFilter->fresh()->filters)->toBe([
|
||||||
|
'search' => 'new',
|
||||||
|
'category_ids' => ['food'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user cannot update another user saved filter', function () {
|
||||||
|
$savedFilter = SavedFilter::factory()->create(['filters' => ['search' => 'old']]);
|
||||||
|
|
||||||
|
$this->patchJson("/api/saved-filters/{$savedFilter->id}", [
|
||||||
|
'filters' => ['search' => 'hacked'],
|
||||||
|
])->assertForbidden();
|
||||||
|
|
||||||
|
expect($savedFilter->fresh()->filters)->toBe(['search' => 'old']);
|
||||||
|
});
|
||||||
|
|
||||||
test('a user can delete their own saved filter', function () {
|
test('a user can delete their own saved filter', function () {
|
||||||
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
|
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue