mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
feat: add filtering options for notifications by 'unread' and 'errors', enhancing user experience in the notifications list
This commit is contained in:
parent
1aa2c8a334
commit
bd783cc2d0
5 changed files with 84 additions and 17 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue