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
+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) => {