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
+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>
);
}