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,40 @@
|
||||
"""saved_filters table
|
||||
|
||||
Revision ID: 0029_saved_filters
|
||||
Revises: 0028_user_preferences
|
||||
Create Date: 2025-07-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
# revision identifiers
|
||||
revision = "0029_saved_filters"
|
||||
down_revision = "0028_user_preferences"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"saved_filters",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||
sa.Column("filter_criteria", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
||||
)
|
||||
op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"])
|
||||
op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_saved_filters_tenant_entity", table_name="saved_filters")
|
||||
op.drop_index("ix_saved_filters_tenant_user", table_name="saved_filters")
|
||||
op.drop_table("saved_filters")
|
||||
@@ -48,6 +48,7 @@ from app.routes import (
|
||||
system_settings,
|
||||
attachments,
|
||||
custom_fields,
|
||||
saved_filters,
|
||||
)
|
||||
|
||||
|
||||
@@ -306,6 +307,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(addresses.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
|
||||
# ── Register plugin routes (before SPA catch-all) ──────────────────
|
||||
registry = get_registry()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SavedFilter model — tenant-scoped, user-scoped saved filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class SavedFilter(Base, TenantMixin):
|
||||
"""Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS)."""
|
||||
|
||||
__tablename__ = "saved_filters"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
||||
Index("ix_saved_filters_tenant_user", "tenant_id", "user_id"),
|
||||
Index("ix_saved_filters_tenant_entity", "tenant_id", "entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # contacts, mail, calendar, dms
|
||||
filter_criteria: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Saved filters routes — CRUD for reusable filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.saved_filter import SavedFilter
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
|
||||
|
||||
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
|
||||
|
||||
|
||||
class SavedFilterCreate(BaseModel):
|
||||
"""Schema for creating a saved filter."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
|
||||
filter_criteria: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SavedFilterUpdate(BaseModel):
|
||||
"""Schema for updating a saved filter."""
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
filter_criteria: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _filter_to_dict(f: SavedFilter) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"entity_type": f.entity_type,
|
||||
"filter_criteria": f.filter_criteria,
|
||||
"user_id": str(f.user_id),
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def list_saved_filters(
|
||||
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List saved filters for the current user, optionally filtered by entity_type."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
query = select(SavedFilter).where(
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
if entity_type:
|
||||
query = query.where(SavedFilter.entity_type == entity_type)
|
||||
query = query.order_by(SavedFilter.name)
|
||||
|
||||
result = await db.execute(query)
|
||||
filters = result.scalars().all()
|
||||
return [_filter_to_dict(f) for f in filters]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def create_saved_filter(
|
||||
body: SavedFilterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new saved filter for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Check uniqueness within user+entity
|
||||
existing = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.entity_type == body.entity_type,
|
||||
SavedFilter.name == body.name,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"})
|
||||
|
||||
saved = SavedFilter(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
filter_criteria=body.filter_criteria,
|
||||
)
|
||||
db.add(saved)
|
||||
await db.flush()
|
||||
return _filter_to_dict(saved)
|
||||
|
||||
|
||||
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def delete_saved_filter(
|
||||
filter_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a saved filter (soft-delete)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
fid = uuid.UUID(filter_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.id == fid,
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
saved = result.scalar_one_or_none()
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime, timezone
|
||||
saved.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,7 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
|
||||
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
|
||||
from app.plugins.builtins.tasks.models import Task # noqa: F401
|
||||
from app.models.saved_filter import SavedFilter # noqa: F401
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Saved filters tests — CRUD for reusable filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterList:
|
||||
"""GET /api/v1/saved-filters"""
|
||||
|
||||
async def test_list_saved_filters_returns_200(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters returns 200 with list."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
async def test_list_saved_filters_with_entity_type(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters?entity_type=contacts filters by entity."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/saved-filters?entity_type=contacts", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
for item in resp.json():
|
||||
assert item["entity_type"] == "contacts"
|
||||
|
||||
async def test_list_saved_filters_requires_auth(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters without auth returns 401."""
|
||||
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterCreate:
|
||||
"""POST /api/v1/saved-filters"""
|
||||
|
||||
async def test_create_saved_filter_returns_201(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters creates a filter and returns 201."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={
|
||||
"name": "Important Clients",
|
||||
"entity_type": "contacts",
|
||||
"filter_criteria": {"type": "company", "search": "important"},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Important Clients"
|
||||
assert data["entity_type"] == "contacts"
|
||||
assert data["filter_criteria"]["type"] == "company"
|
||||
|
||||
async def test_create_saved_filter_duplicate_returns_409(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters with duplicate name returns 409."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create first
|
||||
await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
# Create duplicate
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_create_saved_filter_invalid_entity_returns_422(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters with invalid entity_type returns 422."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "Test", "entity_type": "invalid", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterDelete:
|
||||
"""DELETE /api/v1/saved-filters/{id}"""
|
||||
|
||||
async def test_delete_saved_filter_returns_204(self, client: AsyncClient, db_session):
|
||||
"""DELETE /saved-filters/{id} soft-deletes the filter."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create
|
||||
create_resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "To Delete", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
filter_id = create_resp.json()["id"]
|
||||
# Delete
|
||||
resp = await client.delete(f"/api/v1/saved-filters/{filter_id}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 204
|
||||
# Verify gone from list
|
||||
list_resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert not any(f["id"] == filter_id for f in list_resp.json())
|
||||
|
||||
async def test_delete_saved_filter_not_found_returns_404(self, client: AsyncClient, db_session):
|
||||
"""DELETE non-existent filter returns 404."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.delete(
|
||||
"/api/v1/saved-filters/00000000-0000-0000-0000-000000000000",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_delete_saved_filter_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
|
||||
"""DELETE with invalid UUID returns 400."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.delete("/api/v1/saved-filters/not-a-uuid", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user