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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user