diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 928fcbe07..154ad5abe 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -7,7 +7,7 @@ dotenv.config() global.React = React global.TextEncoder = TextEncoder - +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)) beforeEach(() => { jest.spyOn(console, 'error').mockImplementation((...args) => { throw new Error(`Console error: ${args.join(' ')}`) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b0d6979e1..0ced32c35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,7 +46,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-gtm-module": "^2.0.11", - "react-icons": "^5.3.0", + "react-icons": "^5.4.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.0.2", "react-tooltip": "^5.28.0", diff --git a/frontend/package.json b/frontend/package.json index 9fbaf5270..52d58ed42 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-gtm-module": "^2.0.11", - "react-icons": "^5.3.0", + "react-icons": "^5.4.0", "react-leaflet": "^5.0.0", "react-router-dom": "^7.0.2", "react-tooltip": "^5.28.0", diff --git a/frontend/src/components/SearchPageLayout.tsx b/frontend/src/components/SearchPageLayout.tsx index c91f54fdc..dd56a377d 100644 --- a/frontend/src/components/SearchPageLayout.tsx +++ b/frontend/src/components/SearchPageLayout.tsx @@ -50,7 +50,6 @@ const SearchPageLayout = ({ initialValue={searchQuery} onReady={handleSearchBarReady} /> -
{sortChildren}
{!isSearchBarReady || !isLoaded ? (
@@ -59,6 +58,7 @@ const SearchPageLayout = ({ ) : ( <>
+ {totalPages !== 0 &&
{sortChildren}
} {totalPages === 0 &&
{empty}
} {children}
diff --git a/frontend/src/components/SortBy.tsx b/frontend/src/components/SortBy.tsx index 19974db81..841cdf5b4 100644 --- a/frontend/src/components/SortBy.tsx +++ b/frontend/src/components/SortBy.tsx @@ -1,56 +1,103 @@ -import { faCaretDown, faCaretUp, faCheck } from '@fortawesome/free-solid-svg-icons' +import { + faArrowDownShortWide, + faArrowUpWideShort, + faCheck, +} from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useState } from 'react' +import { Tooltip } from 'react-tooltip' +import { SortByProps } from 'types/sortBy' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'components/ui/dropdownMenu' - -interface SortOption { - value: string - label: string -} - -interface SortByProps { - options: SortOption[] - selectedOption: string - onSortChange: (value: string) => void -} - -const SortBy = ({ options, selectedOption, onSortChange }: SortByProps) => { - const [open, setOpen] = useState(false) - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen) - } - - if (!options || options.length === 0) return null + SelectContent, + SelectItem, + SelectLabel, + SelectRoot, + SelectTrigger, + SelectValueText, +} from 'components/ui/Select' +const SortBy = ({ + sortOptions, + selectedSortOption, + selectedOrder, + onSortChange, + onOrderChange, +}: SortByProps) => { + if (!sortOptions || sortOptions.items.length === 0) return null return ( - - - - - - {options.map((option) => ( - onSortChange(option.value)} - className="justify-between" +
+ + Sort By: + + + item.value === selectedSortOption)?.label + } + /> + +
+ + {sortOptions.items.map((attribute) => ( + + {attribute.label} + {attribute.value === selectedSortOption && ( + + )} + + ))} + + +
+ + {/* Sort Order Dropdown */} + {selectedSortOption !== 'default' && ( +
+ + +
+ )} + ) } diff --git a/frontend/src/components/ui/CloseButton.tsx b/frontend/src/components/ui/CloseButton.tsx new file mode 100644 index 000000000..f834ed996 --- /dev/null +++ b/frontend/src/components/ui/CloseButton.tsx @@ -0,0 +1,16 @@ +import type { ButtonProps } from '@chakra-ui/react' +import { IconButton as ChakraIconButton } from '@chakra-ui/react' +import * as React from 'react' +import { LuX } from 'react-icons/lu' + +export type CloseButtonProps = ButtonProps + +export const CloseButton = React.forwardRef( + function CloseButton(props, ref) { + return ( + + {props.children ?? } + + ) + } +) diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 000000000..a0bb643ae --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,134 @@ +'use client' + +import type { CollectionItem } from '@chakra-ui/react' +import { Select as ChakraSelect, Portal } from '@chakra-ui/react' +import * as React from 'react' +import { CloseButton } from './CloseButton' + +interface SelectTriggerProps extends ChakraSelect.ControlProps { + clearable?: boolean +} + +export const SelectTrigger = React.forwardRef( + function SelectTrigger(props, ref) { + const { children, clearable, ...rest } = props + return ( + + {children} + + {clearable && } + + + + ) + } +) + +const SelectClearTrigger = React.forwardRef( + function SelectClearTrigger(props, ref) { + return ( + + + + ) + } +) + +interface SelectContentProps extends ChakraSelect.ContentProps { + portalled?: boolean + portalRef?: React.RefObject +} + +export const SelectContent = React.forwardRef( + function SelectContent(props, ref) { + const { portalled = true, portalRef, ...rest } = props + return ( + + + + + + ) + } +) + +export const SelectItem = React.forwardRef( + function SelectItem(props, ref) { + const { item, children, ...rest } = props + return ( + + {children} + + + ) + } +) + +interface SelectValueTextProps extends Omit { + children?(items: CollectionItem[]): React.ReactNode +} + +export const SelectValueText = React.forwardRef( + function SelectValueText(props, ref) { + const { children, ...rest } = props + return ( + + + {(select) => { + const items = select.selectedItems + if (items.length === 0) return props.placeholder + if (children) return children(items) + if (items.length === 1) return select.collection.stringifyItem(items[0]) + return `${items.length} selected` + }} + + + ) + } +) + +export const SelectRoot = React.forwardRef( + function SelectRoot(props, ref) { + return ( + + {props.asChild ? ( + props.children + ) : ( + <> + + {props.children} + + )} + + ) + } +) as ChakraSelect.RootComponent + +interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps { + label: React.ReactNode +} + +export const SelectItemGroup = React.forwardRef( + function SelectItemGroup(props, ref) { + const { children, label, ...rest } = props + return ( + + {label} + {children} + + ) + } +) + +export const SelectLabel = ChakraSelect.Label +export const SelectItemText = ChakraSelect.ItemText diff --git a/frontend/src/components/ui/dropdownMenu.tsx b/frontend/src/components/ui/dropdownMenu.tsx deleted file mode 100644 index a52b7955a..000000000 --- a/frontend/src/components/ui/dropdownMenu.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { faCheck, faChevronRight, faDotCircle } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' -import * as React from 'react' -import { cn } from 'utils/utility' - -const DropdownMenu = DropdownMenuPrimitive.Root - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger - -const DropdownMenuGroup = DropdownMenuPrimitive.Group - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal - -const DropdownMenuSub = DropdownMenuPrimitive.Sub - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)) -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - svg]:size-4 [&>svg]:shrink-0', - inset && 'pl-8', - className - )} - {...props} - /> -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - -const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return -} -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -} diff --git a/frontend/src/hooks/useSearchPage.ts b/frontend/src/hooks/useSearchPage.ts index 0348f292c..5e9de339e 100644 --- a/frontend/src/hooks/useSearchPage.ts +++ b/frontend/src/hooks/useSearchPage.ts @@ -8,6 +8,7 @@ interface UseSearchPageOptions { indexName: string pageTitle: string defaultSortBy?: string + defaultOrder?: string } interface UseSearchPageReturn { @@ -17,15 +18,18 @@ interface UseSearchPageReturn { totalPages: number searchQuery: string sortBy: string + order: string handleSearch: (query: string) => void handlePageChange: (page: number) => void handleSortChange: (sort: string) => void + handleOrderChange: (order: string) => void } export function useSearchPage({ indexName, pageTitle, defaultSortBy = '', + defaultOrder = '', }: UseSearchPageOptions): UseSearchPageReturn { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() @@ -33,16 +37,24 @@ export function useSearchPage({ const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1')) const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '') const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || defaultSortBy) + const [order, setOrder] = useState(searchParams.get('order') || defaultOrder) const [totalPages, setTotalPages] = useState(0) const [isLoaded, setIsLoaded] = useState(false) - useEffect(() => { const params = new URLSearchParams() if (searchQuery) params.set('q', searchQuery) if (currentPage > 1) params.set('page', currentPage.toString()) - if (sortBy && sortBy !== 'projects') params.set('sortBy', sortBy) + + if (sortBy && sortBy !== 'default' && sortBy[0] !== 'default' && sortBy !== '') { + params.set('sortBy', sortBy) + } + + if (sortBy !== 'default' && sortBy[0] !== 'default' && order && order !== '') { + params.set('order', order) + } + setSearchParams(params) - }, [searchQuery, currentPage, sortBy, setSearchParams]) + }, [searchQuery, order, currentPage, sortBy, setSearchParams]) useEffect(() => { document.title = pageTitle @@ -51,7 +63,9 @@ export function useSearchPage({ const fetchData = async () => { try { const data: AlgoliaResponseType = await fetchAlgoliaData( - sortBy ? `${indexName}_${sortBy}` : indexName, + sortBy && sortBy !== 'default' && sortBy[0] !== 'default' + ? `${indexName}_${sortBy}${order && order !== '' ? `_${order}` : ''}` + : indexName, searchQuery, currentPage ) @@ -64,7 +78,7 @@ export function useSearchPage({ } fetchData() - }, [currentPage, searchQuery, sortBy, indexName, pageTitle, navigate]) + }, [currentPage, searchQuery, order, sortBy, indexName, pageTitle, navigate]) const handleSearch = (query: string) => { setSearchQuery(query) @@ -83,7 +97,10 @@ export function useSearchPage({ setSortBy(sort) setCurrentPage(1) } - + const handleOrderChange = (order: string) => { + setOrder(order) + setCurrentPage(1) + } return { items, isLoaded, @@ -91,8 +108,10 @@ export function useSearchPage({ totalPages, searchQuery, sortBy, + order, handleSearch, handlePageChange, handleSortChange, + handleOrderChange, } } diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx index 20b8d70c8..dd829bd2d 100644 --- a/frontend/src/pages/Projects.tsx +++ b/frontend/src/pages/Projects.tsx @@ -8,7 +8,6 @@ import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper' import Card from 'components/Card' import SearchPageLayout from 'components/SearchPageLayout' import SortBy from 'components/SortBy' - const ProjectsPage = () => { const { items: projects, @@ -17,13 +16,16 @@ const ProjectsPage = () => { totalPages, searchQuery, sortBy, + order, handleSearch, handlePageChange, handleSortChange, + handleOrderChange, } = useSearchPage({ indexName: 'projects', pageTitle: 'OWASP Projects', - defaultSortBy: '', + defaultSortBy: 'default', + defaultOrder: 'asc', }) const navigate = useNavigate() @@ -68,9 +70,11 @@ const ProjectsPage = () => { searchPlaceholder="Search for OWASP projects..." sortChildren={ } > diff --git a/frontend/src/types/sortBy.tsx b/frontend/src/types/sortBy.tsx new file mode 100644 index 000000000..fc251e08b --- /dev/null +++ b/frontend/src/types/sortBy.tsx @@ -0,0 +1,8 @@ +import { ListCollection } from '@chakra-ui/react' +export interface SortByProps { + sortOptions: ListCollection + selectedSortOption: string + selectedOrder: string + onSortChange: (value: string) => void + onOrderChange: (order: string) => void +} diff --git a/frontend/src/utils/sortingOptions.ts b/frontend/src/utils/sortingOptions.ts index 15f63b0e9..817a6be0b 100644 --- a/frontend/src/utils/sortingOptions.ts +++ b/frontend/src/utils/sortingOptions.ts @@ -1,11 +1,11 @@ -export const sortOptionsProject = [ - { label: 'Default', value: '' }, - { label: 'Name (A-Z)', value: 'name_asc' }, - { label: 'Name (Z-A)', value: 'name_desc' }, - { label: 'Stars (Low to High)', value: 'stars_count_asc' }, - { label: 'Stars (High to Low)', value: 'stars_count_desc' }, - { label: 'Contributors (Low to High)', value: 'contributors_count_asc' }, - { label: 'Contributors (High to Low)', value: 'contributors_count_desc' }, - { label: 'Forks (Low to High)', value: 'forks_count_asc' }, - { label: 'Forks (High to Low)', value: 'forks_count_desc' }, -] +import { createListCollection } from '@chakra-ui/react' + +export const sortOptionsProject = createListCollection({ + items: [ + { label: 'Relevancy', value: 'default' }, + { label: 'Contributors', value: 'contributors_count' }, + { label: 'Forks', value: 'forks_count' }, + { label: 'Name', value: 'name' }, + { label: 'Stars', value: 'stars_count' }, + ], +}) diff --git a/frontend/src/wrappers/testUtil.tsx b/frontend/src/wrappers/testUtil.tsx index 600789582..7a5114fad 100644 --- a/frontend/src/wrappers/testUtil.tsx +++ b/frontend/src/wrappers/testUtil.tsx @@ -1,9 +1,13 @@ +import { ChakraProvider, defaultSystem } from '@chakra-ui/react' import { render } from '@testing-library/react' import { ReactNode } from 'react' import { BrowserRouter } from 'react-router-dom' - const customRender = (ui: ReactNode) => { - return render({ui}) + return render( + + {ui} + + ) } export * from '@testing-library/react'