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