diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 580bc8a1d..30e620d7a 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -5,6 +5,12 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline # Non Published Changes +- **DropdownButton:** + - Add `customToggle` prop to allow custom toggle components. + - Add `customToggleClassname` and `customToggleMenuClassname` props to allow custom styling of the custom toggle dropdown wrapper and menu. + - Add `align` prop to control the alignment of the dropdown menu. +- **DropdownButtonItem:** + - Add `type` prop to allow specifying the type of the element. - **SelectAdvanced:** Fix word wrapping in options list to prevent overflow and ensure long text is displayed correctly. # [2.0.2](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@2.0.1...@iqss/dataverse-design-system@2.0.2) (2024-06-23) diff --git a/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx b/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx index ce3c5604d..577346f10 100644 --- a/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx +++ b/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx @@ -1,14 +1,20 @@ -import { DropdownButton as DropdownButtonBS } from 'react-bootstrap' -import { ReactNode } from 'react' +import { DropdownButton as DropdownButtonBS, Dropdown } from 'react-bootstrap' +import { ReactNode, ComponentType, ForwardRefExoticComponent } from 'react' import styles from './DropdownButton.module.scss' import { IconName } from '../icon/IconName' import { ButtonGroup } from '../button-group/ButtonGroup' import { Icon } from '../icon/Icon' type DropdownButtonVariant = 'primary' | 'secondary' | 'link' + +interface CustomToggleProps { + onClick: (event: React.MouseEvent) => void + [key: string]: unknown +} + interface DropdownButtonProps { id: string - title: string + title?: string variant?: DropdownButtonVariant icon?: IconName | ReactNode withSpacing?: boolean @@ -17,6 +23,10 @@ interface DropdownButtonProps { disabled?: boolean children: ReactNode ariaLabel?: string + customToggle?: ComponentType | ForwardRefExoticComponent + customToggleClassname?: string + customToggleMenuClassname?: string + align?: 'end' | 'start' } export function DropdownButton({ @@ -29,8 +39,22 @@ export function DropdownButton({ onSelect, disabled, ariaLabel, + customToggle, + customToggleClassname, + customToggleMenuClassname, + align, children }: DropdownButtonProps) { + // If customToggle is provided, use Dropdown instead of DropdownButtonBS + if (customToggle) { + return ( + + + {children} + + ) + } + return ( {typeof icon === 'string' ? : icon} - {title} + {title && title} } aria-label={ariaLabel || title} variant={variant} as={asButtonGroup ? ButtonGroup : undefined} disabled={disabled} + align={align} onSelect={onSelect}> {children} diff --git a/packages/design-system/src/lib/components/dropdown-button/dropdown-button-item/DropdownButtonItem.tsx b/packages/design-system/src/lib/components/dropdown-button/dropdown-button-item/DropdownButtonItem.tsx index 42bf1d568..01fe05860 100644 --- a/packages/design-system/src/lib/components/dropdown-button/dropdown-button-item/DropdownButtonItem.tsx +++ b/packages/design-system/src/lib/components/dropdown-button/dropdown-button-item/DropdownButtonItem.tsx @@ -9,6 +9,9 @@ interface DropdownItemProps extends React.HTMLAttributes { children: ReactNode as?: ElementType to?: string // When passing as the `as` prop, this prop is used to pass the URL ... + type?: string // For button or input elements + active?: boolean + className?: string } export function DropdownButtonItem({ @@ -18,6 +21,8 @@ export function DropdownButtonItem({ download, children, as, + active, + className, ...props }: DropdownItemProps) { return ( @@ -27,6 +32,8 @@ export function DropdownButtonItem({ disabled={disabled} download={download} as={as} + active={active} + className={className} {...props}> {children} diff --git a/packages/design-system/src/lib/stories/dropdown-button/DropdownButton.stories.tsx b/packages/design-system/src/lib/stories/dropdown-button/DropdownButton.stories.tsx index 94d7a06a8..6d07033da 100644 --- a/packages/design-system/src/lib/stories/dropdown-button/DropdownButton.stories.tsx +++ b/packages/design-system/src/lib/stories/dropdown-button/DropdownButton.stories.tsx @@ -1,10 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react' +import { forwardRef } from 'react' import { DropdownButtonItem } from '../../components/dropdown-button/dropdown-button-item/DropdownButtonItem' import { DropdownButton } from '../../components/dropdown-button/DropdownButton' import { IconName } from '../../components/icon/IconName' import { CanvasFixedHeight } from '../CanvasFixedHeight' import { DropdownSeparator } from '../../components/dropdown-button/dropdown-separator/DropdownSeparator' import { DropdownHeader } from '../../components/dropdown-button/dropdown-header/DropdownHeader' +import { Icon } from '../../components/icon/Icon' /** * ## Description @@ -192,3 +194,46 @@ export const UseCaseSelect: Story = { ) } + +// Custom Toggle Component Example +const CustomToggle = forwardRef< + HTMLButtonElement, + { onClick: (event: React.MouseEvent) => void } +>(({ onClick }, ref) => ( + +)) + +CustomToggle.displayName = 'CustomToggle' + +export const WithCustomToggle: Story = { + name: 'With Custom Toggle', + render: () => ( + + {}}> + Choose an Option + Custom Option 1 + Custom Option 2 + Custom Option 3 + + + ) +} diff --git a/packages/design-system/tests/component/dropdown-button/DropdownButton.spec.tsx b/packages/design-system/tests/component/dropdown-button/DropdownButton.spec.tsx index 1ff6076e9..57af6f30d 100644 --- a/packages/design-system/tests/component/dropdown-button/DropdownButton.spec.tsx +++ b/packages/design-system/tests/component/dropdown-button/DropdownButton.spec.tsx @@ -121,4 +121,30 @@ describe('DropdownButton', () => { ) cy.findByText(titleText).should('be.disabled') }) + + it('renders with custom toggle', () => { + const CustomToggle = ({ + onClick + }: { + onClick: (event: React.MouseEvent) => void + }) => ( + + ) + + cy.mount( + {}}> + Custom Option 1 + Custom Option 2 + Custom Option 3 + + ) + + cy.findByText('Custom Toggle').should('exist') + }) }) diff --git a/public/locales/en/homepage.json b/public/locales/en/homepage.json index 9f302e911..fe846e929 100644 --- a/public/locales/en/homepage.json +++ b/public/locales/en/homepage.json @@ -33,5 +33,9 @@ "content": "Learn about getting started creating your own dataverse repository here.", "text_button": "Getting started" } + }, + "searchDropdown": { + "header": "Search Services", + "buttonLabel": "Toggle search services dropdown" } } diff --git a/src/collection/domain/models/CollectionItemsQueryParams.ts b/src/collection/domain/models/CollectionItemsQueryParams.ts index ed5d4c2cc..f268d6daf 100644 --- a/src/collection/domain/models/CollectionItemsQueryParams.ts +++ b/src/collection/domain/models/CollectionItemsQueryParams.ts @@ -4,5 +4,6 @@ export enum CollectionItemsQueryParams { START = 'start', TYPES = 'types', QUERY = 'q', - FILTER_QUERIES = 'fqs' + FILTER_QUERIES = 'fqs', + SEARCH_SERVICE = 'search_service' } diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index c8a749ded..080d739b8 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -21,7 +21,8 @@ export interface CollectionRepository { getItems( collectionId: string, paginationInfo: CollectionItemsPaginationInfo, - searchCriteria?: CollectionSearchCriteria + searchCriteria?: CollectionSearchCriteria, + searchService?: string ): Promise getMyDataItems: ( roleIds: number[], diff --git a/src/collection/domain/useCases/getCollectionItems.ts b/src/collection/domain/useCases/getCollectionItems.ts index 44b15c7eb..f8c2a53e7 100644 --- a/src/collection/domain/useCases/getCollectionItems.ts +++ b/src/collection/domain/useCases/getCollectionItems.ts @@ -7,10 +7,11 @@ export async function getCollectionItems( collectionRepository: CollectionRepository, collectionId: string, paginationInfo: CollectionItemsPaginationInfo, - searchCriteria: CollectionSearchCriteria + searchCriteria: CollectionSearchCriteria, + searchService?: string ): Promise { return collectionRepository - .getItems(collectionId, paginationInfo, searchCriteria) + .getItems(collectionId, paginationInfo, searchCriteria, searchService) .catch((error: Error) => { throw new Error(error.message) }) diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index 41c1a4db0..ae78652eb 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -59,10 +59,17 @@ export class CollectionJSDataverseRepository implements CollectionRepository { getItems( collectionId: string, paginationInfo: CollectionItemsPaginationInfo, - searchCriteria: CollectionSearchCriteria + searchCriteria: CollectionSearchCriteria, + searchService?: string ): Promise { return getCollectionItems - .execute(collectionId, paginationInfo?.pageSize, paginationInfo?.offset, searchCriteria) + .execute( + collectionId, + paginationInfo?.pageSize, + paginationInfo?.offset, + searchCriteria, + searchService + ) .then((jsCollectionItemSubset) => { const collectionItemsPreviewsMapped = JSCollectionItemsMapper.toCollectionItemsPreviews( jsCollectionItemSubset.items diff --git a/src/search/domain/hooks/useGetSearchServices.ts b/src/search/domain/hooks/useGetSearchServices.ts new file mode 100644 index 000000000..465c425e8 --- /dev/null +++ b/src/search/domain/hooks/useGetSearchServices.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { SearchRepository } from '../repositories/SearchRepository' +import { SearchService } from '../models/SearchService' +import { getSearchServices } from '../useCases/getSearchServices' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +interface useGetSearchServicesProps { + searchRepository: SearchRepository + autoFetch?: boolean +} + +export const useGetSearchServices = ({ + searchRepository, + autoFetch = true +}: useGetSearchServicesProps) => { + const [searchServices, setSearchServices] = useState([]) + const [isLoadingSearchServices, setIsLoadingSearchServices] = useState(autoFetch) + const [errorSearchServices, setErrorSearchServices] = useState(null) + + const fetchSearchServices = useCallback(async () => { + setIsLoadingSearchServices(true) + setErrorSearchServices(null) + + try { + const searchServicesResponse = await getSearchServices(searchRepository) + + setSearchServices(searchServicesResponse) + } catch (err) { + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + + setErrorSearchServices(formattedError) + } else { + setErrorSearchServices('Something went wrong getting the search services. Try again later.') + } + } finally { + setIsLoadingSearchServices(false) + } + }, [searchRepository]) + + useEffect(() => { + if (autoFetch) { + void fetchSearchServices() + } + }, [autoFetch, fetchSearchServices]) + + return { + searchServices, + isLoadingSearchServices, + errorSearchServices, + fetchSearchServices + } +} diff --git a/src/search/domain/models/SearchService.ts b/src/search/domain/models/SearchService.ts new file mode 100644 index 000000000..b895878ea --- /dev/null +++ b/src/search/domain/models/SearchService.ts @@ -0,0 +1,4 @@ +export interface SearchService { + name: string + displayName: string +} diff --git a/src/search/domain/repositories/SearchRepository.ts b/src/search/domain/repositories/SearchRepository.ts new file mode 100644 index 000000000..f191e7753 --- /dev/null +++ b/src/search/domain/repositories/SearchRepository.ts @@ -0,0 +1,5 @@ +import { SearchService } from '../models/SearchService' + +export interface SearchRepository { + getServices: () => Promise +} diff --git a/src/search/domain/useCases/getSearchServices.ts b/src/search/domain/useCases/getSearchServices.ts new file mode 100644 index 000000000..51499963b --- /dev/null +++ b/src/search/domain/useCases/getSearchServices.ts @@ -0,0 +1,8 @@ +import { SearchService } from '../models/SearchService' +import { SearchRepository } from '../repositories/SearchRepository' + +export async function getSearchServices( + searchRepository: SearchRepository +): Promise { + return searchRepository.getServices() +} diff --git a/src/search/infrastructure/repositories/SearchJSRepository.ts b/src/search/infrastructure/repositories/SearchJSRepository.ts new file mode 100644 index 000000000..70f2a09e7 --- /dev/null +++ b/src/search/infrastructure/repositories/SearchJSRepository.ts @@ -0,0 +1,9 @@ +import { SearchService } from '@/search/domain/models/SearchService' +import { SearchRepository } from '@/search/domain/repositories/SearchRepository' +import { getSearchServices } from '@iqss/dataverse-client-javascript' + +export class SearchJSRepository implements SearchRepository { + getServices(): Promise { + return getSearchServices.execute().then((searchServices) => searchServices) + } +} diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx index 492801de4..5944307ea 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -109,7 +109,6 @@ export const CollectionItemsPanel = ({ const handleSearchSubmit = async (searchValue: string) => { const isSearchValueEmpty = searchValue === '' - itemsListContainerRef.current?.scrollTo({ top: 0 }) const resetPaginationInfo = new CollectionItemsPaginationInfo() diff --git a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx index fa262478f..c0d327bae 100644 --- a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx +++ b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx @@ -8,6 +8,7 @@ import { } from '@/collection/domain/models/CollectionItemSubset' import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' export const NO_COLLECTION_ITEMS = 0 @@ -58,12 +59,16 @@ export const useGetAccumulatedItems = ({ ): Promise => { setIsLoadingItems(true) + const searchServiceFromSessionStorage: string | undefined = + sessionStorage.getItem(CollectionItemsQueryParams.SEARCH_SERVICE) ?? undefined + try { const { items, facets, totalItemCount } = await loadNextItems( collectionRepository, collectionId, pagination, - searchCriteria + searchCriteria, + searchServiceFromSessionStorage ) const newAccumulatedItems = !resetAccumulated ? [...accumulatedItems, ...items] : items @@ -82,6 +87,11 @@ export const useGetAccumulatedItems = ({ if (!isNextPage) { setIsLoadingItems(false) + + // External search is not working properly for pagination or types, for now we remove the ext search service selected once the user loads all pages. + if (searchServiceFromSessionStorage) { + sessionStorage.removeItem(CollectionItemsQueryParams.SEARCH_SERVICE) + } } return totalItemCount @@ -114,13 +124,15 @@ async function loadNextItems( collectionRepository: CollectionRepository, collectionId: string, paginationInfo: CollectionItemsPaginationInfo, - searchCriteria: CollectionSearchCriteria + searchCriteria: CollectionSearchCriteria, + searchService?: string ): Promise { return getCollectionItems( collectionRepository, collectionId, paginationInfo, - searchCriteria + searchCriteria, + searchService ).catch((err: Error) => { throw new Error(err.message) }) diff --git a/src/sections/homepage/Homepage.tsx b/src/sections/homepage/Homepage.tsx index c36d3de20..4cff459ea 100644 --- a/src/sections/homepage/Homepage.tsx +++ b/src/sections/homepage/Homepage.tsx @@ -3,6 +3,8 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { DataverseHubRepository } from '@/dataverse-hub/domain/repositories/DataverseHubRepository' +import { SearchRepository } from '@/search/domain/repositories/SearchRepository' +import { useGetSearchServices } from '@/search/domain/hooks/useGetSearchServices' import { useCollection } from '../collection/useCollection' import { FeaturedItems } from '../collection/featured-items/FeaturedItems' import { useLoading } from '../loading/LoadingContext' @@ -15,20 +17,29 @@ import styles from './Homepage.module.scss' interface HomepageProps { collectionRepository: CollectionRepository dataverseHubRepository: DataverseHubRepository + searchRepository: SearchRepository } -export const Homepage = ({ collectionRepository, dataverseHubRepository }: HomepageProps) => { +export const Homepage = ({ + collectionRepository, + dataverseHubRepository, + searchRepository +}: HomepageProps) => { const { collection, isLoading: isLoadingCollection } = useCollection(collectionRepository) + const { searchServices, isLoadingSearchServices } = useGetSearchServices({ + searchRepository, + autoFetch: true + }) const { setIsLoading } = useLoading() const { t } = useTranslation('homepage') useEffect(() => { - if (!isLoadingCollection) { + if (!isLoadingCollection && !isLoadingSearchServices) { setIsLoading(false) } - }, [setIsLoading, isLoadingCollection]) + }, [setIsLoading, isLoadingCollection, isLoadingSearchServices]) - if (isLoadingCollection) { + if (isLoadingCollection || isLoadingSearchServices) { return } @@ -39,7 +50,7 @@ export const Homepage = ({ collectionRepository, dataverseHubRepository }: Homep
- + {t('browseCollections')} diff --git a/src/sections/homepage/HomepageFactory.tsx b/src/sections/homepage/HomepageFactory.tsx index cb3076d07..8fcad9c6f 100644 --- a/src/sections/homepage/HomepageFactory.tsx +++ b/src/sections/homepage/HomepageFactory.tsx @@ -1,10 +1,12 @@ import { ReactElement } from 'react' import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' import { ApiDataverseHubRepository } from '@/dataverse-hub/infrastructure/repositories/ApiDataverseHubRepository' +import { SearchJSRepository } from '@/search/infrastructure/repositories/SearchJSRepository' import Homepage from './Homepage' const collectionRepository = new CollectionJSDataverseRepository() const dataverseHubRepository = new ApiDataverseHubRepository() +const searchRepository = new SearchJSRepository() export class HomepageFactory { static create(): ReactElement { @@ -12,6 +14,7 @@ export class HomepageFactory { ) } diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx new file mode 100644 index 000000000..348c45c3b --- /dev/null +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -0,0 +1,78 @@ +import { ForwardedRef, forwardRef } from 'react' +import { useTranslation } from 'react-i18next' +import { CaretDownFill, Search as SearchIcon, Stars as StarsIcon } from 'react-bootstrap-icons' +import { DropdownButton, DropdownButtonItem, DropdownHeader } from '@iqss/dataverse-design-system' +import { SearchService } from '@/search/domain/models/SearchService' +import { SOLR_SERVICE_NAME } from './SearchInput' +import styles from './SearchInput.module.scss' + +interface SearchDropdownProps { + searchServices: SearchService[] + searchServiceSelected: string + handleSearchEngineSelect: (eventKey: string | null) => void +} + +export const SearchDropdown = ({ + searchServices, + searchServiceSelected, + handleSearchEngineSelect +}: SearchDropdownProps) => { + const { t } = useTranslation('homepage') + // Sort the search services to show the Solr service first + const searchServicesWithSolrFirst = [...searchServices].sort((a, b) => { + if (a.name === SOLR_SERVICE_NAME) return -1 + if (b.name === SOLR_SERVICE_NAME) return 1 + return 0 + }) + + return ( + + {t('searchDropdown.header')} + {searchServicesWithSolrFirst.map((service) => { + const isSolrService = service.name === SOLR_SERVICE_NAME + + return ( + + {isSolrService ? : } + {service.displayName} + + ) + })} + + ) +} + +interface CustomToggleProps { + onClick: (event: React.MouseEvent) => void +} + +const CustomToggle = forwardRef(({ onClick }: CustomToggleProps, ref) => { + const { t } = useTranslation('homepage') + return ( + + ) +}) + +CustomToggle.displayName = 'CustomToggle' diff --git a/src/sections/homepage/search-input/SearchInput.module.scss b/src/sections/homepage/search-input/SearchInput.module.scss index 33898c7f7..501a3d1c9 100644 --- a/src/sections/homepage/search-input/SearchInput.module.scss +++ b/src/sections/homepage/search-input/SearchInput.module.scss @@ -7,6 +7,7 @@ --search-input-gap: 4px; --search-input-transition: 0.15s ease-in-out; --search-icon-btn-width: 60px; + --search-engine-icon-btn-width: 35px; } .search-input-wrapper { @@ -36,11 +37,14 @@ } .text-input { + position: relative; + z-index: 2; height: 100%; border: 0; border-radius: 999px; caret-color: $dv-brand-color; padding-inline: 1.25rem; + outline: solid 4px $dv-brand-color; &:focus { box-shadow: none; @@ -50,10 +54,13 @@ .clear-btn { position: absolute; right: 0; + z-index: 2; margin-right: 1rem; } -.search-btn { +%search-btn { + position: relative; + z-index: 3; display: grid; place-items: center; min-width: var(--search-icon-btn-width); @@ -83,3 +90,50 @@ color: $dv-primary-text-color; } } + +.search-btn { + @extend %search-btn; +} + +.search-dropdown { + height: 100%; +} + +.search-dropdown-btn { + @extend %search-btn; + + z-index: 2; + min-width: var(--search-engine-icon-btn-width); + aspect-ratio: 1; + height: 100%; +} + +// This selector is used to style the dropdown button when the dropdown is open +:global(.show).search-dropdown .search-dropdown-btn { + background-color: color.adjust($dv-brand-color, $lightness: -10%); +} + +.search-dropdown-menu { + min-width: calc(var(--search-input-max-width) / 2); + margin-top: 10px; + padding-top: 0; + + .search-dropdown-item { + display: flex; + gap: 0.35rem; + align-items: center; + + svg { + color: $dv-text-color; + } + + &[aria-selected='true'], + &:active { + background-color: $dv-brand-color; + + svg { + color: #fff; + } + } + } +} diff --git a/src/sections/homepage/search-input/SearchInput.tsx b/src/sections/homepage/search-input/SearchInput.tsx index 5e9f1e104..a809fb1e5 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -2,17 +2,28 @@ import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Form, CloseButton } from '@iqss/dataverse-design-system' -import { Search as SearchIcon } from 'react-bootstrap-icons' +import { Search as SearchIcon, Stars as StarsIcon } from 'react-bootstrap-icons' import { Route } from '../../Route.enum' -import { CollectionItemType } from '../../../collection/domain/models/CollectionItemType' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' +import { SearchService } from '@/search/domain/models/SearchService' +import { SearchDropdown } from './SearchDropdown' import styles from './SearchInput.module.scss' -export const SearchInput = () => { +export const SOLR_SERVICE_NAME = 'solr' + +interface SearchInputProps { + searchServices: SearchService[] +} + +export const SearchInput = ({ searchServices }: SearchInputProps) => { const navigate = useNavigate() const { t } = useTranslation('shared') const inputSearchRef = useRef(null) const [searchValue, setSearchValue] = useState('') + const [searchServiceSelected, setSearchServiceSelected] = useState(SOLR_SERVICE_NAME) + + const hasMoreThanOneSearchService = searchServices.length > 1 const handleSearchChange = (event: React.ChangeEvent) => { setSearchValue(event.target.value) @@ -32,6 +43,12 @@ export const SearchInput = () => { [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') ) + if (searchServiceSelected !== SOLR_SERVICE_NAME) { + // For now we set this search service selected in the session storage to use in only the first time we arrive to the collection page + // We could put this in the URL if we want to keep using it on subsequent searches within the collection page + sessionStorage.setItem(CollectionItemsQueryParams.SEARCH_SERVICE, searchServiceSelected) + } + const collectionUrlWithQuery = `${Route.COLLECTIONS_BASE}?${searchParams.toString()}` navigate(collectionUrlWithQuery) @@ -42,10 +59,21 @@ export const SearchInput = () => { inputSearchRef.current?.focus() } + const handleSearchEngineSelect = (eventKey: string | null) => { + setSearchServiceSelected(eventKey as string) + inputSearchRef.current?.focus() + } + return (
+ {searchServiceSelected !== SOLR_SERVICE_NAME && ( + + + + )} { /> )}
- + {hasMoreThanOneSearchService && ( + + )} diff --git a/src/stories/homepage/Homepage.stories.tsx b/src/stories/homepage/Homepage.stories.tsx index f694d0232..b1697cf50 100644 --- a/src/stories/homepage/Homepage.stories.tsx +++ b/src/stories/homepage/Homepage.stories.tsx @@ -6,6 +6,7 @@ import { CollectionMockRepository } from '../collection/CollectionMockRepository import { FeaturedItemMother } from '@tests/component/collection/domain/models/FeaturedItemMother' import { FakerHelper } from '@tests/component/shared/FakerHelper' import { DataverseHubMockRepository } from '../dataverse-hub/DataverseHubMockRepository' +import { SearchMockRepository } from '../shared-mock-repositories/search/SearchMockRepository' const meta: Meta = { title: 'Pages/Homepage', @@ -25,6 +26,7 @@ export const Default: Story = { ) } @@ -64,6 +66,7 @@ export const WithFeaturedItems: Story = { ) } diff --git a/src/stories/homepage/SearchInput.stories.tsx b/src/stories/homepage/SearchInput.stories.tsx new file mode 100644 index 000000000..def803d34 --- /dev/null +++ b/src/stories/homepage/SearchInput.stories.tsx @@ -0,0 +1,62 @@ +import { SearchInput } from '@/sections/homepage/search-input/SearchInput' +import { Meta, StoryObj } from '@storybook/react' +import { WithI18next } from '../WithI18next' + +const meta: Meta = { + title: 'Sections/Homepage/SearchInput', + component: SearchInput, + decorators: [WithI18next], + parameters: { + // Sets the delay for all stories. + chromatic: { delay: 15000, pauseAnimationAtEnd: true } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( +
+ +
+ ) +} + +export const WithSearchEnginesDropdown: Story = { + render: () => ( +
+ +
+ ) +} diff --git a/src/stories/shared-mock-repositories/search/SearchMockLoadingRepository.ts b/src/stories/shared-mock-repositories/search/SearchMockLoadingRepository.ts new file mode 100644 index 000000000..0e8b5006b --- /dev/null +++ b/src/stories/shared-mock-repositories/search/SearchMockLoadingRepository.ts @@ -0,0 +1,8 @@ +import { SearchService } from '@/search/domain/models/SearchService' +import { SearchMockRepository } from './SearchMockRepository' + +export class SearchMockLoadingRepository implements SearchMockRepository { + getServices(): Promise { + return new Promise(() => {}) + } +} diff --git a/src/stories/shared-mock-repositories/search/SearchMockRepository.ts b/src/stories/shared-mock-repositories/search/SearchMockRepository.ts new file mode 100644 index 000000000..150e262e6 --- /dev/null +++ b/src/stories/shared-mock-repositories/search/SearchMockRepository.ts @@ -0,0 +1,17 @@ +import { SearchService } from '@/search/domain/models/SearchService' +import { SearchRepository } from '@/search/domain/repositories/SearchRepository' + +export class SearchMockRepository implements SearchRepository { + getServices(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + name: 'solr', + displayName: 'Dataverse Standard Search' + } + ]) + }, 1_000) + }) + } +} diff --git a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx index 0174a156c..8023f7260 100644 --- a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx @@ -584,4 +584,44 @@ describe('CollectionItemsPanel', () => { cy.findByRole('button', { name: /Oldest/ }).click() }) }) + + it('uses search_service from sessionStorage on first load and clears it when no next page', () => { + cy.window().then((win) => win.sessionStorage.setItem('search_service', 'ExternalSearch')) + + const first4Elements = items.slice(0, 4) + const onePageOnly: CollectionItemSubset = { + items: first4Elements, + facets, + totalItemCount: first4Elements.length + } + + collectionRepository.getItems = cy.stub().as('getItems').resolves(onePageOnly) + + cy.customMount( + + ) + + // Assert: repository was called with the search service from sessionStorage + cy.get('@getItems').should((spy) => { + const getItemsSpy = spy as unknown as Cypress.Agent + const getItemsSearchServiceArg = getItemsSpy.getCall(0).args[3] as string + + expect(getItemsSearchServiceArg).to.be.eq('ExternalSearch') + }) + + // And the key was cleared since there is no next page + cy.window().then((win) => { + expect(win.sessionStorage.getItem('search_service')).to.be.null + }) + }) }) diff --git a/tests/component/sections/homepage/Homepage.spec.tsx b/tests/component/sections/homepage/Homepage.spec.tsx index d1f9ae311..994f012e8 100644 --- a/tests/component/sections/homepage/Homepage.spec.tsx +++ b/tests/component/sections/homepage/Homepage.spec.tsx @@ -3,6 +3,7 @@ import { FeaturedItemMother } from '@tests/component/collection/domain/models/Fe import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' import { DataverseHubMockRepository } from '@/stories/dataverse-hub/DataverseHubMockRepository' import Homepage from '@/sections/homepage/Homepage' +import { SearchMockRepository } from '@/stories/shared-mock-repositories/search/SearchMockRepository' const testCollectionRepository = {} as CollectionRepository const testCollection = CollectionMother.create({ name: 'Collection Name' }) @@ -25,6 +26,7 @@ describe('Homepage', () => { ) cy.findByTestId('app-loader').should('exist') @@ -35,6 +37,7 @@ describe('Homepage', () => { ) cy.findByTestId('featured-items').should('exist') @@ -46,6 +49,7 @@ describe('Homepage', () => { ) cy.findByTestId('featured-items').should('not.exist') diff --git a/tests/component/sections/homepage/SearchInput.spec.tsx b/tests/component/sections/homepage/SearchInput.spec.tsx index 7d1215948..56253ddbf 100644 --- a/tests/component/sections/homepage/SearchInput.spec.tsx +++ b/tests/component/sections/homepage/SearchInput.spec.tsx @@ -2,12 +2,12 @@ import { SearchInput } from '../../../../src/sections/homepage/search-input/Sear describe('SearchInput', () => { it('should be focused on render', () => { - cy.customMount() + cy.customMount() cy.get('[aria-label="Search"]').should('have.focus') }) it('should show the clear button when the input have value and clear the search value with it', () => { - cy.customMount() + cy.customMount() cy.findByLabelText('Clear Search').should('not.exist') cy.get('[aria-label="Search"]').type('test') cy.findByLabelText('Clear Search').should('be.visible') @@ -16,7 +16,7 @@ describe('SearchInput', () => { }) it('should be able to click the submit button when the user enters a value or not', () => { - cy.customMount() + cy.customMount() cy.get('[aria-label="Search"]').type('test') cy.get('[aria-label="Search"]').should('have.value', 'test') cy.get('[aria-label="Submit Search"]').click() @@ -25,4 +25,98 @@ describe('SearchInput', () => { cy.get('[aria-label="Search"]').should('have.value', '') cy.get('[aria-label="Submit Search"]').click() }) + + it('should show the SearchDropdown with the search services when there is more than one search service', () => { + const searchServices = [ + { name: 'solr', displayName: 'Solr' }, + { name: 'ExternalSearch', displayName: 'External Search' } + ] + cy.customMount() + cy.findByRole('button', { name: 'Toggle search services dropdown' }) + .should('exist') + .as('searchDropdownToggle') + + cy.get('@searchDropdownToggle').click() + + cy.findByRole('button', { name: 'Solr' }).should('exist') + cy.findByRole('button', { name: 'External Search' }).should('exist').click() + + // Check if the selected service is highlighted + cy.get('@searchDropdownToggle').click() + cy.findByRole('button', { name: 'External Search' }).should( + 'have.attr', + 'aria-selected', + 'true' + ) + // Check if clicking on Solr unselects External Search + cy.findByRole('button', { name: 'Solr' }).should('have.attr', 'aria-selected', 'false').click() + cy.get('@searchDropdownToggle').click() + cy.findByRole('button', { name: 'Solr' }).should('have.attr', 'aria-selected', 'true') + cy.findByRole('button', { name: 'External Search' }).should( + 'have.attr', + 'aria-selected', + 'false' + ) + }) + + it('stores the selected non-Solr search service in sessionStorage on submit', () => { + const searchServices = [ + { name: 'solr', displayName: 'Solr' }, + { name: 'ExternalSearch', displayName: 'External Search' } + ] + + // Ensure a clean state + cy.window().then((win) => win.sessionStorage.clear()) + + cy.customMount() + + // Select the non-Solr search service + cy.findByRole('button', { name: 'Toggle search services dropdown' }).click() + cy.findByRole('button', { name: 'External Search' }).click() + + // Enter a query and submit + cy.get('[aria-label="Search"]').type('test query') + cy.get('[aria-label="Submit Search"]').click() + + // Assert the selected service was stored in sessionStorage + cy.window().then((win) => { + expect(win.sessionStorage.getItem('search_service')).to.eq('ExternalSearch') + }) + }) + + it('sorts the search sevices to show Solr first', () => { + const searchServices = [ + { name: 'ExternalSearch', displayName: 'External Search' }, + { name: 'solr', displayName: 'Solr' } + ] + + cy.customMount() + + cy.findByRole('button', { name: 'Toggle search services dropdown' }).click() + + cy.get('[id="search-dropdown"]').within(() => { + cy.findByText('Search Services').next().should('have.text', 'Solr') + }) + }) + + // Should not happen in practice, but we test it to ensure the order is preserved + it('keeps the original order when there is no Solr service (covers comparator return 0)', () => { + const searchServices = [ + { name: 'ExternalA', displayName: 'External A' }, + { name: 'ExternalB', displayName: 'External B' } + ] + + cy.customMount() + + cy.findByRole('button', { name: 'Toggle search services dropdown' }).click() + + cy.get('[id="search-dropdown"]').within(() => { + // After the header, the first two entries should be External A then External B + cy.findByText('Search Services') + .next() + .should('have.text', 'External A') + .next() + .should('have.text', 'External B') + }) + }) })