feat: saved_views backend API + model + migration + frontend hooks

This commit is contained in:
Agent Zero
2026-07-28 14:36:52 +02:00
parent fa28e67fb6
commit 58b163ba78
7 changed files with 335 additions and 25 deletions
+39
View File
@@ -0,0 +1,39 @@
"""Create saved_views table
Revision ID: 0047_saved_views
Revises: 0046_plugin_allowlist
Create Date: 2026-07-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "0047_saved_views"
down_revision = "0046_plugin_allowlist"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"saved_views",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("view_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views", ["tenant_id", "user_id", "entity_type", "name"])
op.create_index("ix_saved_views_tenant_user", "saved_views", ["tenant_id", "user_id"])
op.create_index("ix_saved_views_tenant_entity", "saved_views", ["tenant_id", "entity_type"])
def downgrade() -> None:
op.drop_index("ix_saved_views_tenant_entity", table_name="saved_views")
op.drop_index("ix_saved_views_tenant_user", table_name="saved_views")
op.drop_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views")
op.drop_table("saved_views")
+2
View File
@@ -56,6 +56,7 @@ from app.routes import (
custom_field_definitions,
custom_fields,
saved_filters,
saved_views,
webhooks,
backups,
)
@@ -394,6 +395,7 @@ def create_app() -> FastAPI:
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
app.include_router(saved_views.router)
app.include_router(webhooks.router)
app.include_router(errors.router)
+2
View File
@@ -25,6 +25,7 @@ from app.models.backup import Backup
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.webhook import Webhook
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.saved_view import SavedView
__all__ = [
"Tenant",
@@ -63,4 +64,5 @@ __all__ = [
"Workflow",
"WorkflowInstance",
"WorkflowStepHistory",
"SavedView",
]
+36
View File
@@ -0,0 +1,36 @@
"""SavedView model — tenant-scoped, user-scoped saved view configurations."""
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 SavedView(Base, TenantMixin):
"""Saved view — reusable view configuration (filter+sort+group+viewMode+folder) for list views."""
__tablename__ = "saved_views"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_views_tenant_user_entity_name"),
Index("ix_saved_views_tenant_user", "tenant_id", "user_id"),
Index("ix_saved_views_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
view_config: 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
)
+172
View File
@@ -0,0 +1,172 @@
"""Saved views routes — CRUD for reusable view configurations."""
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_view import SavedView
router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
class SavedViewCreate(BaseModel):
"""Schema for creating a saved view."""
name: str = Field(..., min_length=1, max_length=100)
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
view_config: dict[str, Any] = Field(default_factory=dict)
class SavedViewUpdate(BaseModel):
"""Schema for updating a saved view."""
name: str | None = Field(default=None, min_length=1, max_length=100)
view_config: dict[str, Any] | None = None
def _view_to_dict(v: SavedView) -> dict[str, Any]:
return {
"id": str(v.id),
"name": v.name,
"entity_type": v.entity_type,
"view_config": v.view_config,
"user_id": str(v.user_id),
"created_at": v.created_at.isoformat() if v.created_at else None,
"updated_at": v.updated_at.isoformat() if v.updated_at else None,
}
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
async def list_saved_views(
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 views 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(SavedView).where(
SavedView.tenant_id == tenant_id,
SavedView.user_id == user_id,
SavedView.deleted_at.is_(None),
)
if entity_type:
query = query.where(SavedView.entity_type == entity_type)
query = query.order_by(SavedView.name)
result = await db.execute(query)
views = result.scalars().all()
return [_view_to_dict(v) for v in views]
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
async def create_saved_view(
body: SavedViewCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new saved view 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(SavedView).where(
SavedView.tenant_id == tenant_id,
SavedView.user_id == user_id,
SavedView.entity_type == body.entity_type,
SavedView.name == body.name,
SavedView.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "View name already exists", "code": "duplicate"})
saved = SavedView(
tenant_id=tenant_id,
user_id=user_id,
name=body.name,
entity_type=body.entity_type,
view_config=body.view_config,
)
db.add(saved)
await db.flush()
return _view_to_dict(saved)
@router.put("/{view_id}", dependencies=[Depends(require_permission("contacts:read"))])
async def update_saved_view(
view_id: str,
body: SavedViewUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a saved view."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
vid = uuid.UUID(view_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None
result = await db.execute(
select(SavedView).where(
SavedView.id == vid,
SavedView.tenant_id == tenant_id,
SavedView.user_id == user_id,
SavedView.deleted_at.is_(None),
)
)
saved = result.scalar_one_or_none()
if saved is None:
raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"})
if body.name is not None:
saved.name = body.name
if body.view_config is not None:
saved.view_config = body.view_config
await db.flush()
return _view_to_dict(saved)
@router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
async def delete_saved_view(
view_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a saved view (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
vid = uuid.UUID(view_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None
result = await db.execute(
select(SavedView).where(
SavedView.id == vid,
SavedView.tenant_id == tenant_id,
SavedView.user_id == user_id,
SavedView.deleted_at.is_(None),
)
)
saved = result.scalar_one_or_none()
if saved is None:
raise HTTPException(404, detail={"detail": "Saved view 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)
+52
View File
@@ -0,0 +1,52 @@
/**
* Saved Views API hooks — CRUD for reusable view configurations.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from './client';
export interface SavedView {
id: string;
name: string;
entity_type: string;
view_config: Record<string, any>;
user_id: string;
created_at?: string;
updated_at?: string;
}
export function useSavedViews(entityType?: string) {
const params = new URLSearchParams();
if (entityType) params.set('entity_type', entityType);
const qs = params.toString() ? `?${params.toString()}` : '';
return useQuery({
queryKey: ['saved-views', entityType],
queryFn: () => apiGet<SavedView[]>(`/saved-views${qs}`),
});
}
export function useCreateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; entity_type: string; view_config: Record<string, any> }) =>
apiPost<SavedView>('/saved-views', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }),
});
}
export function useUpdateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: { name?: string; view_config?: Record<string, any> } }) =>
apiPut<SavedView>(`/saved-views/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }),
});
}
export function useDeleteSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/saved-views/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }),
});
}
+32 -25
View File
@@ -24,6 +24,7 @@ import { FilterPanel, applyFilters, emptyFilterState, type FilterState, type Sav
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
import { SaveViewDialog, type SaveViewSelection } from '@/components/contacts/SaveViewDialog';
import { useSavedViews, useCreateSavedView, useDeleteSavedView } from '@/api/savedViews';
import {
useUnifiedContacts,
useUnifiedContact,
@@ -52,12 +53,17 @@ export function ContactsListPage() {
const [viewsOpen, setViewsOpen] = useState(true);
const [foldersOpen, setFoldersOpen] = useState(true);
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>([]);
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
const openWindow = useWindowStore((s) => s.openWindow);
// Saved views from API (durable, per-user)
const { data: apiSavedViews } = useSavedViews('contacts');
const createViewMut = useCreateSavedView();
const deleteViewMut = useDeleteSavedView();
const savedViews = apiSavedViews ?? [];
// Debounce search
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
@@ -209,42 +215,43 @@ export function ContactsListPage() {
setSaveViewDialogOpen(true);
}, []);
// Actually save the view with selection
// Actually save the view with selection — via API
const handleSaveViewConfirm = useCallback((name: string, selection: SaveViewSelection) => {
const id = `view-${Date.now()}`;
setSavedViews((prev) => [...prev, {
id,
name,
filterState: { ...filterState },
sortState: { ...sortState },
groupState: { ...groupState },
const viewConfig = {
filterState,
sortState,
groupState,
selectedFilter,
viewMode,
multiSelectFolders: [...multiSelectFolders],
multiSelectFolders,
selection,
}]);
setActiveViewId(id);
}, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders]);
};
createViewMut.mutate({ name, entity_type: 'contacts', view_config: viewConfig }, {
onSuccess: (data) => setActiveViewId(data.id),
});
}, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders, createViewMut]);
// Apply a saved custom view — only selected components
const applySavedView = useCallback((view: { id: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }) => {
if (view.selection.folder) {
setSelectedFilter(view.selectedFilter);
setMultiSelectFolders(view.multiSelectFolders || []);
// Apply a saved custom view — only selected components (from API view_config)
const applySavedView = useCallback((view: { id: string; view_config: any }) => {
const cfg = view.view_config;
if (!cfg) return;
if (cfg.selection?.folder) {
setSelectedFilter(cfg.selectedFilter);
setMultiSelectFolders(cfg.multiSelectFolders || []);
}
if (view.selection.viewMode) setViewMode(view.viewMode);
if (view.selection.filter) setFilterState({ ...view.filterState });
if (view.selection.group) setGroupState({ ...view.groupState });
if (view.selection.sort) setSortState({ ...view.sortState });
if (cfg.selection?.viewMode) setViewMode(cfg.viewMode);
if (cfg.selection?.filter) setFilterState({ ...cfg.filterState });
if (cfg.selection?.group) setGroupState({ ...cfg.groupState });
if (cfg.selection?.sort) setSortState({ ...cfg.sortState });
setActiveViewId(view.id);
setPage(1);
}, []);
// Delete a saved custom view
// Delete a saved custom view — via API
const deleteSavedView = useCallback((id: string) => {
setSavedViews((prev) => prev.filter((v) => v.id !== id));
deleteViewMut.mutate(id);
if (activeViewId === id) setActiveViewId(null);
}, [activeViewId]);
}, [activeViewId, deleteViewMut]);
// Save current filter configuration
const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {