From 824c96ab1d9771b8d8fc49d58cf0bafd93b96811 Mon Sep 17 00:00:00 2001
From: gbena-afk
Date: Wed, 24 Jun 2026 11:51:37 +0100
Subject: [PATCH] feat: Add saved notification filters functionality
- Add SavedFilter type and persistence utilities
- Update event store to manage saved filters
- Create SavedFiltersManager component with UI
- Add filter save, load, rename, and delete functionality
- Persist saved filters across sessions using localStorage
- Integrate saved filters into EventFiltersBar
- Add styling for saved filters component
Users can now:
- Save current filter configurations with custom names
- Load saved filters with one click
- Rename existing saved filters
- Delete unwanted saved filters
- All filters persist across browser sessions
---
dashboard/src/components/EventFiltersBar.tsx | 89 ++++-----
.../src/components/SavedFiltersManager.tsx | 165 +++++++++++++++++
dashboard/src/index.css | 169 ++++++++++++++++++
dashboard/src/pages/EventExplorerPage.tsx | 4 +-
dashboard/src/store/eventStore.ts | 45 ++++-
dashboard/src/types/event.ts | 7 +
dashboard/src/utils/filterPersistence.ts | 62 +++++++
7 files changed, 499 insertions(+), 42 deletions(-)
create mode 100644 dashboard/src/components/SavedFiltersManager.tsx
create mode 100644 dashboard/src/utils/filterPersistence.ts
diff --git a/dashboard/src/components/EventFiltersBar.tsx b/dashboard/src/components/EventFiltersBar.tsx
index a598cf6c..d86ace7e 100644
--- a/dashboard/src/components/EventFiltersBar.tsx
+++ b/dashboard/src/components/EventFiltersBar.tsx
@@ -2,6 +2,7 @@ import { memo } from 'react';
import { useEventStore } from '../store/eventStore';
import { useEventCount, useEventFilters, useFilterOptions } from '../hooks/useEventSelectors';
import { SearchAutocomplete } from './SearchAutocomplete';
+import { SavedFiltersManager } from './SavedFiltersManager';
export const EventFiltersBar = memo(function EventFiltersBar() {
const filters = useEventFilters();
@@ -12,49 +13,57 @@ export const EventFiltersBar = memo(function EventFiltersBar() {
const setEventTypeFilter = useEventStore((state) => state.setEventTypeFilter);
return (
-
-
-
- setSearch(value)}
- />
-
+ <>
+
+
+
+ setSearch(value)}
+ />
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
- {totalCount.toLocaleString()} events loaded
+
+ {totalCount.toLocaleString()} events loaded
+
+
+
+
+ >
+ );
+});
);
});
diff --git a/dashboard/src/components/SavedFiltersManager.tsx b/dashboard/src/components/SavedFiltersManager.tsx
new file mode 100644
index 00000000..1fe59b1f
--- /dev/null
+++ b/dashboard/src/components/SavedFiltersManager.tsx
@@ -0,0 +1,165 @@
+import { memo, useState } from 'react';
+import { useEventStore } from '../store/eventStore';
+import { useShallow } from 'zustand/react/shallow';
+import type { SavedFilter } from '../types/event';
+
+export const SavedFiltersManager = memo(function SavedFiltersManager() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [filterName, setFilterName] = useState('');
+ const [editingId, setEditingId] = useState(null);
+ const [editingName, setEditingName] = useState('');
+
+ const savedFilters = useEventStore((state) => state.savedFilters);
+ const saveCurrentFilter = useEventStore((state) => state.saveCurrentFilter);
+ const loadSavedFilter = useEventStore((state) => state.loadSavedFilter);
+ const renameSavedFilter = useEventStore((state) => state.renameSavedFilter);
+ const deleteSavedFilter = useEventStore((state) => state.deleteSavedFilter);
+
+ const handleSave = () => {
+ if (filterName.trim()) {
+ saveCurrentFilter(filterName.trim());
+ setFilterName('');
+ }
+ };
+
+ const handleLoad = (id: string) => {
+ loadSavedFilter(id);
+ setIsOpen(false);
+ };
+
+ const handleStartEdit = (filter: SavedFilter) => {
+ setEditingId(filter.id);
+ setEditingName(filter.name);
+ };
+
+ const handleSaveEdit = () => {
+ if (editingId && editingName.trim()) {
+ renameSavedFilter(editingId, editingName.trim());
+ setEditingId(null);
+ setEditingName('');
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setEditingId(null);
+ setEditingName('');
+ };
+
+ const handleDelete = (id: string) => {
+ if (confirm('Are you sure you want to delete this saved filter?')) {
+ deleteSavedFilter(id);
+ if (editingId === id) {
+ setEditingId(null);
+ setEditingName('');
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isOpen && (
+
+
+ setFilterName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
+ className="saved-filters__input"
+ />
+
+
+
+ {savedFilters.length > 0 ? (
+
+ ) : (
+
No saved filters yet
+ )}
+
+ )}
+
+ );
+});
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
index f3b7ac75..77e6182a 100644
--- a/dashboard/src/index.css
+++ b/dashboard/src/index.css
@@ -758,3 +758,172 @@ body {
display: none;
}
}
+
+/* ─── SavedFiltersManager ────────────────────────────────────────── */
+
+.saved-filters {
+ margin-top: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ overflow: hidden;
+}
+
+.saved-filters__header {
+ padding: 12px 16px;
+}
+
+.saved-filters__toggle {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: none;
+ color: inherit;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: color 0.15s ease;
+}
+
+.saved-filters__toggle:hover {
+ color: #60a5fa;
+}
+
+.saved-filters__panel {
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.saved-filters__save {
+ display: flex;
+ gap: 8px;
+}
+
+.saved-filters__input {
+ flex: 1;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ padding: 8px 10px;
+ background: #12151c;
+ color: inherit;
+ font-size: 0.9rem;
+}
+
+.saved-filters__input:focus {
+ outline: none;
+ border-color: #60a5fa;
+}
+
+.saved-filters__save-btn {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ padding: 8px 16px;
+ background: rgba(96, 165, 250, 0.15);
+ color: #60a5fa;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+ white-space: nowrap;
+}
+
+.saved-filters__save-btn:hover:not(:disabled) {
+ background: rgba(96, 165, 250, 0.25);
+ border-color: #60a5fa;
+}
+
+.saved-filters__save-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.saved-filters__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.saved-filters__item {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.03);
+ overflow: hidden;
+}
+
+.saved-filters__view,
+.saved-filters__edit {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+}
+
+.saved-filters__name {
+ flex: 1;
+ text-align: left;
+ background: transparent;
+ border: none;
+ color: inherit;
+ font-size: 0.95rem;
+ cursor: pointer;
+ padding: 4px 0;
+ transition: color 0.15s ease;
+}
+
+.saved-filters__name:hover {
+ color: #60a5fa;
+}
+
+.saved-filters__actions {
+ display: flex;
+ gap: 4px;
+}
+
+.saved-filters__btn {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 6px;
+ padding: 4px 8px;
+ background: rgba(255, 255, 255, 0.05);
+ color: inherit;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+ font-size: 0.9rem;
+}
+
+.saved-filters__btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.saved-filters__btn--save {
+ color: #34d399;
+}
+
+.saved-filters__btn--cancel {
+ color: #9aa0a6;
+}
+
+.saved-filters__btn--edit {
+ color: #60a5fa;
+}
+
+.saved-filters__btn--delete {
+ color: #f87171;
+}
+
+.saved-filters__empty {
+ margin: 0;
+ color: #9aa0a6;
+ font-size: 0.9rem;
+ text-align: center;
+ padding: 16px;
+}
diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx
index 724938b1..6c07ae1d 100644
--- a/dashboard/src/pages/EventExplorerPage.tsx
+++ b/dashboard/src/pages/EventExplorerPage.tsx
@@ -34,13 +34,15 @@ export function EventExplorerPage() {
const setEvents = useEventStore((state) => state.setEvents);
const setLoading = useEventStore((state) => state.setLoading);
const setError = useEventStore((state) => state.setError);
+ const loadSavedFiltersFromStorage = useEventStore((state) => state.loadSavedFiltersFromStorage);
const { isLoading, error } = useEventLoadingState();
const filters = useEventFilters();
const filteredEvents = useFilteredEvents();
useEffect(() => {
restoreWalletSession();
- }, []);
+ loadSavedFiltersFromStorage();
+ }, [loadSavedFiltersFromStorage]);
useEffect(() => {
let cancelled = false;
diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts
index 5dcd525e..06a1c204 100644
--- a/dashboard/src/store/eventStore.ts
+++ b/dashboard/src/store/eventStore.ts
@@ -1,10 +1,12 @@
import { create } from 'zustand';
-import type { BlockchainEvent, EventFilters } from '../types/event';
+import type { BlockchainEvent, EventFilters, SavedFilter } from '../types/event';
import { filterEvents } from '../utils/eventData';
+import { getSavedFilters, addSavedFilter, updateSavedFilter, deleteSavedFilter } from '../utils/filterPersistence';
interface EventStoreState {
events: BlockchainEvent[];
filters: EventFilters;
+ savedFilters: SavedFilter[];
isLoading: boolean;
error: string | null;
setEvents: (events: BlockchainEvent[]) => void;
@@ -14,6 +16,11 @@ interface EventStoreState {
setEventTypeFilter: (eventType: string) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
+ saveCurrentFilter: (name: string) => void;
+ loadSavedFilter: (id: string) => void;
+ renameSavedFilter: (id: string, newName: string) => void;
+ deleteSavedFilter: (id: string) => void;
+ loadSavedFiltersFromStorage: () => void;
}
function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] {
@@ -36,6 +43,7 @@ export const useEventStore = create((set) => ({
contractAddress: 'all',
eventType: 'all',
},
+ savedFilters: [],
isLoading: false,
error: null,
setEvents: (events) => set({ events: dedupeEventsById(events) }),
@@ -57,6 +65,37 @@ export const useEventStore = create((set) => ({
})),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
+ saveCurrentFilter: (name) =>
+ set((state) => {
+ const newFilter: SavedFilter = {
+ id: `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ name,
+ filters: { ...state.filters },
+ createdAt: Date.now(),
+ };
+ const updated = addSavedFilter(newFilter);
+ return { savedFilters: updated };
+ }),
+ loadSavedFilter: (id) =>
+ set((state) => {
+ const savedFilter = state.savedFilters.find((f) => f.id === id);
+ if (!savedFilter) return state;
+ return { filters: { ...savedFilter.filters } };
+ }),
+ renameSavedFilter: (id, newName) =>
+ set(() => {
+ const updated = updateSavedFilter(id, { name: newName });
+ return { savedFilters: updated };
+ }),
+ deleteSavedFilter: (id) =>
+ set(() => {
+ const updated = deleteSavedFilter(id);
+ return { savedFilters: updated };
+ }),
+ loadSavedFiltersFromStorage: () =>
+ set(() => ({
+ savedFilters: getSavedFilters(),
+ })),
}));
export function selectFilteredEvents(state: EventStoreState): BlockchainEvent[] {
@@ -90,3 +129,7 @@ export function selectEventTypeOptions(state: EventStoreState): string[] {
)
);
}
+
+export function selectSavedFilters(state: EventStoreState) {
+ return state.savedFilters;
+}
diff --git a/dashboard/src/types/event.ts b/dashboard/src/types/event.ts
index ca0fd028..c1779c53 100644
--- a/dashboard/src/types/event.ts
+++ b/dashboard/src/types/event.ts
@@ -15,3 +15,10 @@ export interface EventFilters {
contractAddress: string;
eventType: string;
}
+
+export interface SavedFilter {
+ id: string;
+ name: string;
+ filters: EventFilters;
+ createdAt: number;
+}
diff --git a/dashboard/src/utils/filterPersistence.ts b/dashboard/src/utils/filterPersistence.ts
new file mode 100644
index 00000000..7d55d0d9
--- /dev/null
+++ b/dashboard/src/utils/filterPersistence.ts
@@ -0,0 +1,62 @@
+import type { SavedFilter } from '../types/event';
+
+const SAVED_FILTERS_KEY = 'notify-chain:saved-filters';
+
+function readStorage(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function writeStorage(key: string, value: string | null): void {
+ try {
+ if (value) {
+ localStorage.setItem(key, value);
+ } else {
+ localStorage.removeItem(key);
+ }
+ } catch {
+ // localStorage may be unavailable (private browsing, etc.)
+ }
+}
+
+export function getSavedFilters(): SavedFilter[] {
+ const data = readStorage(SAVED_FILTERS_KEY);
+ if (!data) return [];
+
+ try {
+ const parsed = JSON.parse(data);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+export function setSavedFilters(filters: SavedFilter[]): void {
+ writeStorage(SAVED_FILTERS_KEY, JSON.stringify(filters));
+}
+
+export function addSavedFilter(filter: SavedFilter): SavedFilter[] {
+ const existing = getSavedFilters();
+ const updated = [...existing, filter];
+ setSavedFilters(updated);
+ return updated;
+}
+
+export function updateSavedFilter(id: string, updates: Partial): SavedFilter[] {
+ const existing = getSavedFilters();
+ const updated = existing.map((filter) =>
+ filter.id === id ? { ...filter, ...updates } : filter
+ );
+ setSavedFilters(updated);
+ return updated;
+}
+
+export function deleteSavedFilter(id: string): SavedFilter[] {
+ const existing = getSavedFilters();
+ const updated = existing.filter((filter) => filter.id !== id);
+ setSavedFilters(updated);
+ return updated;
+}