diff --git a/src/components/activity/ActivityList.tsx b/src/components/activity/ActivityList.tsx index 75583e8..cfe7c37 100644 --- a/src/components/activity/ActivityList.tsx +++ b/src/components/activity/ActivityList.tsx @@ -1,16 +1,18 @@ -import { useMemo, useRef, useState, useEffect } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import type { MirrorJob } from "@/lib/db/schema"; -import Fuse from "fuse.js"; -import { Button } from "../ui/button"; -import { RefreshCw } from "lucide-react"; -import { Card } from "../ui/card"; -import { formatDate, getStatusColor } from "@/lib/utils"; -import { Skeleton } from "../ui/skeleton"; -import type { FilterParams } from "@/types/filter"; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { MirrorJob } from '@/lib/db/schema'; +import Fuse from 'fuse.js'; +import { Button } from '../ui/button'; +import { RefreshCw } from 'lucide-react'; +import { Card } from '../ui/card'; +import { formatDate, getStatusColor } from '@/lib/utils'; +import { Skeleton } from '../ui/skeleton'; +import type { FilterParams } from '@/types/filter'; + +type MirrorJobWithKey = MirrorJob & { _rowKey: string }; interface ActivityListProps { - activities: MirrorJob[]; + activities: MirrorJobWithKey[]; isLoading: boolean; filter: FilterParams; setFilter: (filter: FilterParams) => void; @@ -22,38 +24,44 @@ export default function ActivityList({ filter, setFilter, }: ActivityListProps) { - const [expandedItems, setExpandedItems] = useState>(new Set()); + const [expandedItems, setExpandedItems] = useState>( + () => new Set(), + ); + const parentRef = useRef(null); - const rowRefs = useRef>(new Map()); + // We keep the ref only for possible future scroll-to-row logic. + const rowRefs = useRef>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars const filteredActivities = useMemo(() => { let result = activities; if (filter.status) { - result = result.filter((activity) => activity.status === filter.status); + result = result.filter((a) => a.status === filter.status); } if (filter.type) { - if (filter.type === 'repository') { - result = result.filter((activity) => !!activity.repositoryId); - } else if (filter.type === 'organization') { - result = result.filter((activity) => !!activity.organizationId); - } + result = + filter.type === 'repository' + ? result.filter((a) => !!a.repositoryId) + : filter.type === 'organization' + ? result.filter((a) => !!a.organizationId) + : result; } if (filter.name) { - result = result.filter((activity) => - activity.repositoryName === filter.name || - activity.organizationName === filter.name + result = result.filter( + (a) => + a.repositoryName === filter.name || + a.organizationName === filter.name, ); } if (filter.searchTerm) { const fuse = new Fuse(result, { - keys: ["message", "details", "organizationName", "repositoryName"], + keys: ['message', 'details', 'organizationName', 'repositoryName'], threshold: 0.3, }); - result = fuse.search(filter.searchTerm).map((res) => res.item); + result = fuse.search(filter.searchTerm).map((r) => r.item); } return result; @@ -62,10 +70,8 @@ export default function ActivityList({ const virtualizer = useVirtualizer({ count: filteredActivities.length, getScrollElement: () => parentRef.current, - estimateSize: (index) => { - const activity = filteredActivities[index]; - return expandedItems.has(activity.id || "") ? 217 : 120; - }, + estimateSize: (idx) => + expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120, overscan: 5, measureElement: (el) => el.getBoundingClientRect().height + 8, }); @@ -74,118 +80,132 @@ export default function ActivityList({ virtualizer.measure(); }, [expandedItems, virtualizer]); - return isLoading ? ( -
- {Array.from({ length: 5 }, (_, index) => ( - - ))} -
- ) : filteredActivities.length === 0 ? ( -
- -

No activities found

-

- {filter.searchTerm || filter.status || filter.type || filter.name - ? "Try adjusting your search or filter criteria." - : "No mirroring activities have been recorded yet."} -

- {filter.searchTerm || filter.status || filter.type || filter.name ? ( - - ) : ( - - )} -
- ) : ( + /* ------------------------------ render ------------------------------ */ + + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ); + } + + if (filteredActivities.length === 0) { + const hasFilter = + filter.searchTerm || filter.status || filter.type || filter.name; + + return ( +
+ +

No activities found

+

+ {hasFilter + ? 'Try adjusting your search or filter criteria.' + : 'No mirroring activities have been recorded yet.'} +

+ {hasFilter ? ( + + ) : ( + + )} +
+ ); + } + + return (
- {virtualizer.getVirtualItems().map((virtualRow) => { - const activity = filteredActivities[virtualRow.index]; - const isExpanded = expandedItems.has(activity.id || ""); - const key = activity.id || String(virtualRow.index); + {virtualizer.getVirtualItems().map((vRow) => { + const activity = filteredActivities[vRow.index]; + const isExpanded = expandedItems.has(activity._rowKey); return (
{ - if (node) { - rowRefs.current.set(key, node); - virtualizer.measureElement(node); - } + rowRefs.current.set(activity._rowKey, node); + if (node) virtualizer.measureElement(node); }} style={{ - position: "absolute", + position: 'absolute', top: 0, left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - paddingBottom: "8px", + width: '100%', + transform: `translateY(${vRow.start}px)`, + paddingBottom: '8px', }} - className="border-b px-4 pt-4" + className='border-b px-4 pt-4' > -
-
+
+
-
-
-

{activity.message}

-

+ +

+
+

{activity.message}

+

{formatDate(activity.timestamp)}

{activity.repositoryName && ( -

+

Repository: {activity.repositoryName}

)} {activity.organizationName && ( -

+

Organization: {activity.organizationName}

)} {activity.details && ( -
+
{isExpanded && ( -
+                        
                           {activity.details}
                         
)} diff --git a/src/components/activity/ActivityLog.tsx b/src/components/activity/ActivityLog.tsx index af77839..aa717f4 100644 --- a/src/components/activity/ActivityLog.tsx +++ b/src/components/activity/ActivityLog.tsx @@ -1,76 +1,97 @@ -import { useCallback, useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Search, Download, RefreshCw, ChevronDown } from "lucide-react"; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, Download, RefreshCw, Search } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { apiRequest, formatDate } from "@/lib/utils"; -import { useAuth } from "@/hooks/useAuth"; -import type { MirrorJob } from "@/lib/db/schema"; -import type { ActivityApiResponse } from "@/types/activities"; +} from '../ui/dropdown-menu'; +import { apiRequest, formatDate } from '@/lib/utils'; +import { useAuth } from '@/hooks/useAuth'; +import type { MirrorJob } from '@/lib/db/schema'; +import type { ActivityApiResponse } from '@/types/activities'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "../ui/select"; -import { repoStatusEnum, type RepoStatus } from "@/types/Repository"; -import ActivityList from "./ActivityList"; -import { ActivityNameCombobox } from "./ActivityNameCombobox"; -import { useSSE } from "@/hooks/useSEE"; -import { useFilterParams } from "@/hooks/useFilterParams"; -import { toast } from "sonner"; +} from '../ui/select'; +import { repoStatusEnum, type RepoStatus } from '@/types/Repository'; +import ActivityList from './ActivityList'; +import { ActivityNameCombobox } from './ActivityNameCombobox'; +import { useSSE } from '@/hooks/useSEE'; +import { useFilterParams } from '@/hooks/useFilterParams'; +import { toast } from 'sonner'; + +type MirrorJobWithKey = MirrorJob & { _rowKey: string }; + +function genKey(job: MirrorJob): string { + return `${ + job.id ?? (typeof crypto !== 'undefined' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2)) + }-${job.timestamp}`; +} export function ActivityLog() { const { user } = useAuth(); - const [activities, setActivities] = useState([]); - const [isLoading, setIsLoading] = useState(false); + + const [activities, setActivities] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { filter, setFilter } = useFilterParams({ - searchTerm: "", - status: "", - type: "", - name: "", + searchTerm: '', + status: '', + type: '', + name: '', }); - const handleNewMessage = useCallback((data: MirrorJob) => { - setActivities((prevActivities) => [data, ...prevActivities]); + /* ----------------------------- SSE hook ----------------------------- */ - console.log("Received new log:", data); + const handleNewMessage = useCallback((data: MirrorJob) => { + const withKey: MirrorJobWithKey = { + ...structuredClone(data), + _rowKey: genKey(data), + }; + + setActivities((prev) => [withKey, ...prev]); }, []); - // Use the SSE hook const { connected } = useSSE({ userId: user?.id, onMessage: handleNewMessage, }); + /* ------------------------- initial fetch --------------------------- */ + const fetchActivities = useCallback(async () => { if (!user) return false; try { setIsLoading(true); - const response = await apiRequest( + const res = await apiRequest( `/activities?userId=${user.id}`, - { - method: "GET", - } + { method: 'GET' }, ); - if (response.success) { - setActivities(response.activities); - return true; - } else { - toast.error(response.message || "Failed to fetch activities."); + if (!res.success) { + toast.error(res.message ?? 'Failed to fetch activities.'); return false; } - } catch (error) { + + const data: MirrorJobWithKey[] = res.activities.map((a) => ({ + ...structuredClone(a), + _rowKey: genKey(a), + })); + + setActivities(data); + return true; + } catch (err) { toast.error( - error instanceof Error ? error.message : "Failed to fetch activities." + err instanceof Error ? err.message : 'Failed to fetch activities.', ); return false; } finally { @@ -82,208 +103,167 @@ export function ActivityLog() { fetchActivities(); }, [fetchActivities]); - const handleRefreshActivities = async () => { - const success = await fetchActivities(); - if (success) { - toast.success("Activities refreshed successfully."); - } - }; + /* ---------------------- filtering + exporting ---------------------- */ - // Get the currently filtered activities - const getFilteredActivities = () => { - return activities.filter(activity => { - let isIncluded = true; + const applyLightFilter = (list: MirrorJobWithKey[]) => { + return list.filter((a) => { + if (filter.status && a.status !== filter.status) return false; - if (filter.status) { - isIncluded = isIncluded && activity.status === filter.status; + if (filter.type === 'repository' && !a.repositoryId) return false; + if (filter.type === 'organization' && !a.organizationId) return false; + + if ( + filter.name && + a.repositoryName !== filter.name && + a.organizationName !== filter.name + ) { + return false; } - if (filter.type) { - if (filter.type === 'repository') { - isIncluded = isIncluded && !!activity.repositoryId; - } else if (filter.type === 'organization') { - isIncluded = isIncluded && !!activity.organizationId; - } - } - - if (filter.name) { - isIncluded = isIncluded && ( - activity.repositoryName === filter.name || - activity.organizationName === filter.name - ); - } - - // Note: We're not applying the search term filter here as that would require - // re-implementing the Fuse.js search logic - - return isIncluded; + return true; }); }; - // Function to export activities as CSV const exportAsCSV = () => { - const filteredActivities = getFilteredActivities(); + const rows = applyLightFilter(activities); + if (!rows.length) return toast.error('No activities to export.'); - if (filteredActivities.length === 0) { - toast.error("No activities to export."); - return; - } - - // Create CSV content - const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"]; - const csvRows = [ - headers.join(","), - ...filteredActivities.map(activity => { - const formattedDate = formatDate(activity.timestamp); - // Escape fields that might contain commas or quotes - const escapeCsvField = (field: string | null | undefined) => { - if (!field) return ''; - if (field.includes(',') || field.includes('"') || field.includes('\n')) { - return `"${field.replace(/"/g, '""')}"`; - } - return field; - }; - - return [ - formattedDate, - escapeCsvField(activity.message), - activity.status, - escapeCsvField(activity.repositoryName || ''), - escapeCsvField(activity.organizationName || ''), - escapeCsvField(activity.details || '') - ].join(','); - }) + const headers = [ + 'Timestamp', + 'Message', + 'Status', + 'Repository', + 'Organization', + 'Details', ]; - const csvContent = csvRows.join('\n'); + const escape = (v: string | null | undefined) => + v && /[,\"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v ?? ''; - // Download the CSV file - downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.csv'); + const csv = [ + headers.join(','), + ...rows.map((a) => + [ + formatDate(a.timestamp), + escape(a.message), + a.status, + escape(a.repositoryName), + escape(a.organizationName), + escape(a.details), + ].join(','), + ), + ].join('\n'); - toast.success("Activity log exported as CSV successfully."); + downloadFile(csv, 'text/csv;charset=utf-8;', 'activity_log_export.csv'); + toast.success('CSV exported.'); }; - // Function to export activities as JSON const exportAsJSON = () => { - const filteredActivities = getFilteredActivities(); + const rows = applyLightFilter(activities); + if (!rows.length) return toast.error('No activities to export.'); - if (filteredActivities.length === 0) { - toast.error("No activities to export."); - return; - } + const json = JSON.stringify( + rows.map((a) => ({ + ...a, + formattedTime: formatDate(a.timestamp), + })), + null, + 2, + ); - // Format the activities for export (removing any sensitive or unnecessary fields if needed) - const activitiesForExport = filteredActivities.map(activity => ({ - id: activity.id, - timestamp: activity.timestamp, - formattedTime: formatDate(activity.timestamp), - message: activity.message, - status: activity.status, - repositoryId: activity.repositoryId, - repositoryName: activity.repositoryName, - organizationId: activity.organizationId, - organizationName: activity.organizationName, - details: activity.details - })); - - const jsonContent = JSON.stringify(activitiesForExport, null, 2); - - // Download the JSON file - downloadFile(jsonContent, 'application/json', 'activity_log_export.json'); - - toast.success("Activity log exported as JSON successfully."); + downloadFile(json, 'application/json', 'activity_log_export.json'); + toast.success('JSON exported.'); }; - // Generic function to download a file - const downloadFile = (content: string, mimeType: string, filename: string) => { - // Add date to filename - const date = new Date(); - const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - const filenameWithDate = filename.replace('.', `_${dateStr}.`); - - // Create a download link - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); + const downloadFile = ( + content: string, + mime: string, + filename: string, + ): void => { + const date = new Date().toISOString().slice(0, 10); // yyyy-mm-dd const link = document.createElement('a'); - - link.href = url; - link.setAttribute('download', filenameWithDate); - document.body.appendChild(link); + link.href = URL.createObjectURL(new Blob([content], { type: mime })); + link.download = filename.replace('.', `_${date}.`); link.click(); - document.body.removeChild(link); }; + /* ------------------------------ UI ------------------------------ */ + return ( -
-
-
- +
+
+ {/* search input */} +
+ - setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) + setFilter((prev) => ({ + ...prev, + searchTerm: e.target.value, + })) } />
+ + {/* status select */} - {/* Repository/Organization Name Combobox */} + {/* repo/org name combobox */} setFilter((prev) => ({ ...prev, name }))} + value={filter.name || ''} + onChange={(name) => setFilter((p) => ({ ...p, name }))} /> - {/* Filter by type: repository/org/all */} + + {/* type select */} + + {/* export dropdown */} - @@ -295,19 +275,21 @@ export function ActivityLog() { -
-
- -
+ + {/* activity list */} +
); }