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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user