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 (
-
-
-
)
}
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'