feat(5.22): Saved Searches / Smart Lists — reusable filters for list views

- SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
- Routes: GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
- Alembic migration 0029_saved_filters creates saved_filters table
- Frontend: SavedFilters.tsx component with save/load/delete UI
- Frontend: api/savedFilters.ts with React Query hooks
- Integrated into ContactsListPage as example
- i18n keys for savedFilters.* in de.json and en.json
- Tests: test_saved_filters.py (9 tests) + SavedFilters.test.tsx (3 tests)
- Registered SavedFilter in conftest.py
This commit is contained in:
Agent Zero
2026-07-23 23:44:50 +02:00
parent 2c9e74776e
commit 182af355d1
12 changed files with 663 additions and 0 deletions
@@ -0,0 +1,114 @@
/**
* SavedFilters component tests.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SavedFilters } from '@/components/SavedFilters';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}));
// Mock UI components
vi.mock('@/components/ui/Input', () => ({
Input: ({ value, onChange, label, ...props }: any) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={props['data-testid'] || 'input'}
value={value || ''}
onChange={onChange}
/>
</div>
),
}));
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, onClose, title, children }: any) =>
open ? (
<div data-testid="modal" role="dialog">
<h2>{title}</h2>
<button onClick={onClose}>Close</button>
{children}
</div>
) : null,
}));
vi.mock('@/components/ui/Button', () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
{children}
</button>
),
}));
// Mock saved filters API
vi.mock('@/api/savedFilters', () => ({
useSavedFilters: vi.fn(() => ({
data: [
{
id: 'filter-1',
name: 'Important Clients',
entity_type: 'contacts',
filter_criteria: { type: 'company' },
user_id: 'user-1',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
})),
useCreateSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useDeleteSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
describe('SavedFilters', () => {
it('renders saved filter buttons', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
expect(screen.getByTestId('saved-filters')).toBeInTheDocument();
expect(screen.getByText('Important Clients')).toBeInTheDocument();
});
it('opens save modal when save button is clicked', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('save-filter-btn'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('filter-name-input')).toBeInTheDocument();
});
it('calls onLoadFilter when a saved filter is clicked', () => {
const onLoadFilter = vi.fn();
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={onLoadFilter}
/>
);
fireEvent.click(screen.getByText('Important Clients'));
expect(onLoadFilter).toHaveBeenCalledWith({ type: 'company' });
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Saved filters API hooks — CRUD for reusable filter criteria.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiDelete } from './client';
export interface SavedFilter {
id: string;
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
user_id: string;
created_at: string;
updated_at: string;
}
export interface SavedFilterCreateInput {
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
}
export function useSavedFilters(entityType?: string) {
const params = new URLSearchParams();
if (entityType) params.set('entity_type', entityType);
return useQuery({
queryKey: ['saved-filters', entityType],
queryFn: () => apiGet<SavedFilter[]>(`/saved-filters?${params.toString()}`),
});
}
export function useCreateSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SavedFilterCreateInput) => apiPost<SavedFilter>('/saved-filters', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
export function useDeleteSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/saved-filters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
+119
View File
@@ -0,0 +1,119 @@
/**
* SavedFilters — filter-builder UI, save button, load saved filters.
* Integrates into list views (Contacts, Mail, Calendar, DMS).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { Bookmark, Trash2, Save } from 'lucide-react';
import {
useSavedFilters,
useCreateSavedFilter,
useDeleteSavedFilter,
type SavedFilter,
} from '@/api/savedFilters';
export interface SavedFiltersProps {
entityType: string;
currentCriteria: Record<string, any>;
onLoadFilter: (criteria: Record<string, any>) => void;
}
export function SavedFilters({ entityType, currentCriteria, onLoadFilter }: SavedFiltersProps) {
const { t } = useTranslation();
const toast = useToast();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [filterName, setFilterName] = useState('');
const { data: savedFilters = [] } = useSavedFilters(entityType);
const createMutation = useCreateSavedFilter();
const deleteMutation = useDeleteSavedFilter();
const handleSave = async () => {
if (!filterName.trim()) return;
try {
await createMutation.mutateAsync({
name: filterName.trim(),
entity_type: entityType,
filter_criteria: currentCriteria,
});
toast.success(t('savedFilters.saved'));
setFilterName('');
setSaveModalOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async (id: string) => {
try {
await deleteMutation.mutateAsync(id);
toast.success(t('savedFilters.deleted'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleLoad = (filter: SavedFilter) => {
onLoadFilter(filter.filter_criteria);
};
return (
<div className="flex items-center gap-2 flex-wrap" data-testid="saved-filters">
{/* Saved filter tabs/buttons */}
{savedFilters.map((filter) => (
<div
key={filter.id}
className="flex items-center gap-1 px-2 py-1 rounded-md bg-secondary-100 hover:bg-secondary-200 cursor-pointer group"
onClick={() => handleLoad(filter)}
data-testid={`saved-filter-${filter.id}`}
>
<Bookmark className="w-3 h-3 text-secondary-500" />
<span className="text-xs text-secondary-700">{filter.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(filter.id); }}
className="opacity-0 group-hover:opacity-100 text-danger-500 hover:text-danger-700"
aria-label={t('common.delete')}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
{/* Save current filter button */}
<Button
size="sm"
variant="ghost"
onClick={() => setSaveModalOpen(true)}
data-testid="save-filter-btn"
>
<Save className="w-3 h-3 mr-1" />
{t('savedFilters.save')}
</Button>
{/* Save modal */}
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} title={t('savedFilters.saveTitle')} size="sm">
<div className="space-y-4">
<Input
label={t('savedFilters.name')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder={t('savedFilters.namePlaceholder')}
data-testid="filter-name-input"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setSaveModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={!filterName.trim() || createMutation.isPending} data-testid="save-filter-confirm">
{t('common.save')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+10
View File
@@ -1022,5 +1022,15 @@
"priorityMedium": "Mittel",
"priorityHigh": "Hoch",
"priorityUrgent": "Dringend"
},
"savedFilters": {
"save": "Filter speichern",
"saveTitle": "Filter speichern",
"name": "Filtername",
"namePlaceholder": "z.B. Wichtige Kunden",
"saved": "Filter gespeichert",
"deleted": "Filter gelöscht",
"load": "Filter laden",
"noFilters": "Keine gespeicherten Filter"
}
}
+10
View File
@@ -1022,5 +1022,15 @@
"priorityMedium": "Medium",
"priorityHigh": "High",
"priorityUrgent": "Urgent"
},
"savedFilters": {
"save": "Save Filter",
"saveTitle": "Save Filter",
"name": "Filter Name",
"namePlaceholder": "e.g. Important Clients",
"saved": "Filter saved",
"deleted": "Filter deleted",
"load": "Load Filter",
"noFilters": "No saved filters"
}
}
+16
View File
@@ -16,6 +16,7 @@ import { ContactFolderTree, type ContactFilter } from '@/components/contacts/Con
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { SavedFilters } from '@/components/SavedFilters';
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
import {
useUnifiedContacts,
@@ -328,6 +329,21 @@ export function ContactsListPage() {
</button>
</div>
{/* Saved Filters */}
<div className="px-3 py-1.5 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
onLoadFilter={(criteria) => {
if (criteria.search) setSearch(criteria.search); else setSearch('');
if (criteria.sortBy) setSortBy(criteria.sortBy);
if (criteria.sortOrder) setSortOrder(criteria.sortOrder);
if (criteria.type) setSelectedFilter(criteria.type as ContactFilter);
setPage(1);
}}
/>
</div>
{/* List */}
<div className="flex-1 min-h-0">
<ContactList