diff --git a/src/components/Home/PipelineSection/PipelineFiltersBar.tsx b/src/components/Home/PipelineSection/PipelineFiltersBar.tsx new file mode 100644 index 000000000..a80efc4eb --- /dev/null +++ b/src/components/Home/PipelineSection/PipelineFiltersBar.tsx @@ -0,0 +1,260 @@ +import { format } from "date-fns"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { DatePickerWithRange } from "@/components/ui/date-picker"; +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Text } from "@/components/ui/typography"; + +import type { FilterBarProps, PipelineSortField } from "./usePipelineFilters"; + +const SORT_FIELD_OPTIONS: { value: PipelineSortField; label: string }[] = [ + { value: "modified_at", label: "Date" }, + { value: "name", label: "Name" }, +]; + +function isValidSortField(value: string): value is PipelineSortField { + return SORT_FIELD_OPTIONS.some((opt) => opt.value === value); +} + +interface PipelineFiltersBarProps { + filters: FilterBarProps; + actions?: ReactNode; +} + +export function PipelineFiltersBar({ + filters, + actions, +}: PipelineFiltersBarProps) { + const { + searchQuery, + setSearchQuery, + dateRange, + setDateRange, + sortField, + setSortField, + sortDirection, + setSortDirection, + componentQuery, + setComponentQuery, + hasActiveFilters, + activeFilterCount, + clearFilters, + totalCount, + filteredCount, + } = filters; + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + + const isSortDescending = sortDirection === "desc"; + + const handleSortFieldChange = (value: string) => { + if (isValidSortField(value)) setSortField(value); + }; + + const toggleSortDirection = () => { + setSortDirection(isSortDescending ? "asc" : "desc"); + }; + + const allBadges: Array<{ + key: string; + label: string; + onRemove: () => void; + }> = []; + + if (searchQuery) { + allBadges.push({ + key: "search", + label: `Search: ${searchQuery}`, + onRemove: () => setSearchQuery(""), + }); + } + + if (dateRange?.from || dateRange?.to) { + const fromStr = dateRange.from ? format(dateRange.from, "MMM d") : ""; + const toStr = dateRange.to ? format(dateRange.to, "MMM d") : ""; + const separator = fromStr && toStr ? " – " : ""; + allBadges.push({ + key: "date_range", + label: `${fromStr}${separator}${toStr}`, + onRemove: () => setDateRange(undefined), + }); + } + + if (componentQuery) { + allBadges.push({ + key: "component", + label: `Component: ${componentQuery}`, + onRemove: () => setComponentQuery(""), + }); + } + + return ( + + + {/* Row 1: Basic Filters */} + + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 w-full" + /> + {searchQuery && ( + + )} +
+ + {/* Date Range */} +
+ +
+ + {/* Sort Controls */} + + + + + + {/* Advanced Toggle */} + + + + + {actions} +
+ + {/* Row 2: Advanced (Collapsible) */} + + + + Contains component + + + setComponentQuery(e.target.value)} + className="pr-8 w-xs" + /> + {componentQuery && ( + + )} + + + + + {/* Row 3: Count + Active filter badges */} + {(hasActiveFilters || totalCount > 0) && ( + + + Showing {filteredCount} of {totalCount} pipelines + + +
+ + {hasActiveFilters && ( + + {allBadges.map((badge) => ( + + {badge.label} + + + ))} + + + + )} + + )} + + + ); +} diff --git a/src/components/Home/PipelineSection/PipelineSection.tsx b/src/components/Home/PipelineSection/PipelineSection.tsx index 8962b422e..34a2d0f88 100644 --- a/src/components/Home/PipelineSection/PipelineSection.tsx +++ b/src/components/Home/PipelineSection/PipelineSection.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { type ChangeEvent, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { LoadingScreen } from "@/components/shared/LoadingScreen"; import NewPipelineButton from "@/components/shared/NewPipelineButton"; @@ -9,13 +9,12 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Icon } from "@/components/ui/icon"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, + TableCell, TableHead, TableHeader, TableRow, @@ -29,83 +28,75 @@ import { import { USER_PIPELINES_LIST_NAME } from "@/utils/constants"; import BulkActionsBar from "./BulkActionsBar"; +import { PipelineFiltersBar } from "./PipelineFiltersBar"; import PipelineRow from "./PipelineRow"; +import { usePipelineFilters } from "./usePipelineFilters"; const DEFAULT_PAGE_SIZE = 10; -function usePagination(items: T[], pageSize = DEFAULT_PAGE_SIZE) { +function usePagination( + items: T[], + pageSize = DEFAULT_PAGE_SIZE, + resetKey = "", +) { const [currentPage, setCurrentPage] = useState(1); - const totalPages = Math.ceil(items.length / pageSize); - - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - - const paginatedItems = items.slice(startIndex, endIndex); - - const goToNextPage = () => { - setCurrentPage((p) => Math.min(totalPages, p + 1)); - }; - - const goToPreviousPage = () => { - setCurrentPage((p) => Math.max(1, p - 1)); - }; - - const resetPage = () => { + useEffect(() => { setCurrentPage(1); - }; + }, [resetKey]); + + const totalPages = Math.ceil(items.length / pageSize); + const safePage = Math.min(currentPage, totalPages || 1); + const paginatedItems = items.slice( + (safePage - 1) * pageSize, + safePage * pageSize, + ); return { paginatedItems, - currentPage, + currentPage: safePage, totalPages, - hasNextPage: currentPage < totalPages, - hasPreviousPage: currentPage > 1, - goToNextPage, - goToPreviousPage, - resetPage, + hasNextPage: safePage < totalPages, + hasPreviousPage: safePage > 1, + goToNextPage: () => setCurrentPage((p) => Math.min(totalPages, p + 1)), + goToPreviousPage: () => setCurrentPage((p) => Math.max(1, p - 1)), + resetPage: () => setCurrentPage(1), }; } type Pipelines = Map; -const PipelineSectionSkeleton = () => { - return ( - - - - - - - +const PipelineSectionSkeleton = () => ( + + + + + + + + + + + + + - - - - - - - - - - - + + - ); -}; + +); export const PipelineSection = withSuspenseWrapper(() => { const [pipelines, setPipelines] = useState(new Map()); const [isLoading, setIsLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); const [selectedPipelines, setSelectedPipelines] = useState>( new Set(), ); - const filteredPipelines = Array.from(pipelines.entries()).filter(([name]) => { - return name.toLowerCase().includes(searchQuery.toLowerCase()); - }); + const { filteredPipelines, filterBarProps, filterKey } = + usePipelineFilters(pipelines); const { paginatedItems: paginatedPipelines, @@ -116,23 +107,14 @@ export const PipelineSection = withSuspenseWrapper(() => { goToNextPage, goToPreviousPage, resetPage, - } = usePagination(filteredPipelines); + } = usePagination(filteredPipelines, DEFAULT_PAGE_SIZE, filterKey); const fetchUserPipelines = async () => { setIsLoading(true); try { - const pipelines = await getAllComponentFilesFromList( - USER_PIPELINES_LIST_NAME, - ); - const sortedPipelines = new Map( - [...pipelines.entries()].sort((a, b) => { - return ( - new Date(b[1].modificationTime).getTime() - - new Date(a[1].modificationTime).getTime() - ); - }), + setPipelines( + await getAllComponentFilesFromList(USER_PIPELINES_LIST_NAME), ); - setPipelines(sortedPipelines); } catch (error) { console.error("Failed to load user pipelines:", error); } finally { @@ -140,42 +122,24 @@ export const PipelineSection = withSuspenseWrapper(() => { } }; - const handleSearch = (e: ChangeEvent) => { - setSearchQuery(e.target.value); - resetPage(); - }; - const handleSelectAll = (checked: boolean) => { - if (checked) { - const allPipelineNames = new Set(filteredPipelines.map(([name]) => name)); - setSelectedPipelines(allPipelineNames); - } else { - setSelectedPipelines(new Set()); - } - }; - - const handleSelectPipeline = (pipelineName: string, checked: boolean) => { - const newSelected = new Set(selectedPipelines); - if (checked) { - newSelected.add(pipelineName); - } else { - newSelected.delete(pipelineName); - } - setSelectedPipelines(newSelected); + setSelectedPipelines( + checked ? new Set(filteredPipelines.map(([name]) => name)) : new Set(), + ); }; - const handleBulkDelete = () => { - setSelectedPipelines(new Set()); - fetchUserPipelines(); + const handleSelectPipeline = (name: string, checked: boolean) => { + const next = new Set(selectedPipelines); + if (checked) next.add(name); + else next.delete(name); + setSelectedPipelines(next); }; useEffect(() => { fetchUserPipelines(); }, []); - if (isLoading) { - return ; - } + if (isLoading) return ; if (pipelines.size === 0) { return ( @@ -185,7 +149,6 @@ export const PipelineSection = withSuspenseWrapper(() => { You don't have any pipelines yet. Get started with a template below. - @@ -212,70 +175,50 @@ export const PipelineSection = withSuspenseWrapper(() => { - - - - - - {!!searchQuery && ( - - )} - - - - - - - {pipelines.size > 0 && ( - - - - - - - Title - Modified at - Last run - Runs - - - - - {filteredPipelines.length === 0 && ( - No Pipelines found. - )} - {paginatedPipelines.map(([name, fileEntry]) => ( - handleSelectPipeline(name, checked)} + } + /> + +
+ + + + - ))} - -
- )} + + Title + Modified at + Last run + Runs + + + + + {filteredPipelines.length === 0 && ( + + + No pipelines found. + + + )} + {paginatedPipelines.map(([name, fileEntry]) => ( + handleSelectPipeline(name, checked)} + /> + ))} + + {totalPages > 1 && ( - +