feat: add filtering options for notifications by 'unread' and 'errors', enhancing user experience in the notifications list

This commit is contained in:
Anish Sarkar 2026-03-06 18:32:28 +05:30
parent 1aa2c8a334
commit bd783cc2d0
5 changed files with 84 additions and 17 deletions

View file

@ -228,6 +228,10 @@ async def list_notifications(
None, None,
description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'", description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'",
), ),
filter: str | None = Query(
None,
description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only",
),
before_date: str | None = Query( before_date: str | None = Query(
None, description="Get notifications before this ISO date (for pagination)" None, description="Get notifications before this ISO date (for pagination)"
), ),
@ -294,6 +298,21 @@ async def list_notifications(
query = query.where(source_filter) query = query.where(source_filter)
count_query = count_query.where(source_filter) count_query = count_query.where(source_filter)
# Filter by preset: 'unread' or 'errors'
if filter == "unread":
unread_filter = Notification.read == False # noqa: E712
query = query.where(unread_filter)
count_query = count_query.where(unread_filter)
elif filter == "errors":
error_filter = (
(Notification.type == "page_limit_exceeded")
| (
Notification.notification_metadata["status"].astext == "failed"
)
)
query = query.where(error_filter)
count_query = count_query.where(error_filter)
# Filter by date (for efficient pagination of older items) # Filter by date (for efficient pagination of older items)
if before_date: if before_date:
try: try:

View file

@ -317,6 +317,24 @@ export function InboxSidebar({
// Get items for current tab // Get items for current tab
const displayItems = activeTab === "comments" ? commentsItems : statusItems; const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// When a non-default filter (unread/errors) is active on the status tab,
// fetch matching items from the API so older items beyond the Electric
// sync window are included.
const isActiveFilterMode = activeTab === "status" && (activeFilter === "unread" || activeFilter === "errors");
const { data: activeFilterResponse, isLoading: isActiveFilterLoading } = useQuery({
queryKey: cacheKeys.notifications.byFilter(searchSpaceId, activeFilter),
queryFn: () =>
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
filter: activeFilter as "unread" | "errors",
limit: 100,
},
}),
staleTime: 30 * 1000,
enabled: isActiveFilterMode && open && !isSearchMode,
});
// When a source filter is active, fetch matching items from the API so // When a source filter is active, fetch matching items from the API so
// older items (outside the Electric sync window) are included. // older items (outside the Electric sync window) are included.
const isSourceFilterMode = activeTab === "status" && !!selectedSource; const isSourceFilterMode = activeTab === "status" && !!selectedSource;
@ -359,11 +377,26 @@ export function InboxSidebar({
[selectedSource] [selectedSource]
); );
// Client-side matcher: checks if an item matches the active filter (unread/errors)
const matchesActiveFilter = useCallback(
(item: InboxItem): boolean => {
if (activeFilter === "unread") return !item.read;
if (activeFilter === "errors") {
if (item.type === "page_limit_exceeded") return true;
const meta = item.metadata as Record<string, unknown> | undefined;
return typeof meta?.status === "string" && meta.status === "failed";
}
return true;
},
[activeFilter]
);
// Filter items based on filter type, connector filter, and search mode // Filter items based on filter type, connector filter, and search mode
// Three data paths: // Four data paths:
// 1. Search mode → server-side search results // 1. Search mode → server-side search results (client-side filter applied after)
// 2. Source filter mode → API results merged with real-time Electric items // 2. Active filter mode (unread/errors) → API results merged with real-time Electric items
// 3. Default → Electric real-time items (fast, local) // 3. Source filter mode → API results merged with real-time Electric items
// 4. Default → Electric real-time items (fast, local)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let items: InboxItem[]; let items: InboxItem[];
@ -378,9 +411,25 @@ export function InboxSidebar({
item.type === "connector_deletion" item.type === "connector_deletion"
); );
} }
if (activeFilter === "unread") {
items = items.filter((item) => !item.read);
} else if (activeFilter === "errors") {
items = items.filter(matchesActiveFilter);
}
} else if (isActiveFilterMode) {
const apiItems = activeFilterResponse?.items ?? [];
const realtimeMatching = statusItems.filter(matchesActiveFilter);
const seen = new Set(apiItems.map((i) => i.id));
const merged = [...apiItems];
for (const item of realtimeMatching) {
if (!seen.has(item.id)) {
merged.push(item);
}
}
items = merged.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} else if (isSourceFilterMode) { } else if (isSourceFilterMode) {
// Merge API results (covers older items) with Electric real-time items
// that match the filter (covers brand-new items arriving in real-time).
const apiItems = sourceFilterResponse?.items ?? []; const apiItems = sourceFilterResponse?.items ?? [];
const realtimeMatching = statusItems.filter(matchesSourceFilter); const realtimeMatching = statusItems.filter(matchesSourceFilter);
const seen = new Set(apiItems.map((i) => i.id)); const seen = new Set(apiItems.map((i) => i.id));
@ -397,25 +446,18 @@ export function InboxSidebar({
items = displayItems; items = displayItems;
} }
if (activeFilter === "unread") {
items = items.filter((item) => !item.read);
} else if (activeFilter === "errors") {
items = items.filter((item) => {
if (item.type === "page_limit_exceeded") return true;
const meta = item.metadata as Record<string, unknown> | undefined;
return typeof meta?.status === "string" && meta.status === "failed";
});
}
return items; return items;
}, [ }, [
displayItems, displayItems,
statusItems, statusItems,
searchResponse, searchResponse,
sourceFilterResponse, sourceFilterResponse,
activeFilterResponse,
isSearchMode, isSearchMode,
isActiveFilterMode,
isSourceFilterMode, isSourceFilterMode,
matchesSourceFilter, matchesSourceFilter,
matchesActiveFilter,
activeFilter, activeFilter,
activeTab, activeTab,
]); ]);
@ -1026,7 +1068,7 @@ export function InboxSidebar({
</Tabs> </Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{(isSearchMode ? isSearchLoading : isSourceFilterMode ? isSourceFilterLoading : loading) ? ( {(isSearchMode ? isSearchLoading : isActiveFilterMode ? isActiveFilterLoading : isSourceFilterMode ? isSourceFilterLoading : loading) ? (
<div className="space-y-2"> <div className="space-y-2">
{activeTab === "comments" {activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */ ? /* Comments skeleton: avatar + two-line text + time */

View file

@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({
search_space_id: z.number().optional(), search_space_id: z.number().optional(),
type: inboxItemTypeEnum.optional(), type: inboxItemTypeEnum.optional(),
source_type: z.string().optional(), source_type: z.string().optional(),
filter: z.enum(["unread", "errors"]).optional(),
before_date: z.string().optional(), before_date: z.string().optional(),
search: z.string().optional(), search: z.string().optional(),
limit: z.number().min(1).max(100).optional(), limit: z.number().min(1).max(100).optional(),

View file

@ -47,6 +47,9 @@ class NotificationsApiService {
if (queryParams.source_type) { if (queryParams.source_type) {
params.append("source_type", queryParams.source_type); params.append("source_type", queryParams.source_type);
} }
if (queryParams.filter) {
params.append("filter", queryParams.filter);
}
if (queryParams.before_date) { if (queryParams.before_date) {
params.append("before_date", queryParams.before_date); params.append("before_date", queryParams.before_date);
} }

View file

@ -100,5 +100,7 @@ export const cacheKeys = {
["notifications", "source-types", searchSpaceId] as const, ["notifications", "source-types", searchSpaceId] as const,
bySourceType: (searchSpaceId: number | null, sourceType: string) => bySourceType: (searchSpaceId: number | null, sourceType: string) =>
["notifications", "by-source-type", searchSpaceId, sourceType] as const, ["notifications", "by-source-type", searchSpaceId, sourceType] as const,
byFilter: (searchSpaceId: number | null, filter: string) =>
["notifications", "by-filter", searchSpaceId, filter] as const,
}, },
}; };