From 20503684d6269a9e1b4ea47f087deba0085f6275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 28 May 2025 14:20:57 -0300 Subject: [PATCH 01/17] relevance behaviour --- .../domain/models/CollectionSearchCriteria.ts | 3 +- src/sections/collection/CollectionHelper.ts | 8 +++-- .../CollectionItemsPanel.tsx | 9 +++--- .../items-list/ItemsSortBy.tsx | 32 ++++++++----------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/collection/domain/models/CollectionSearchCriteria.ts b/src/collection/domain/models/CollectionSearchCriteria.ts index f6f7b84c7..02340bad8 100644 --- a/src/collection/domain/models/CollectionSearchCriteria.ts +++ b/src/collection/domain/models/CollectionSearchCriteria.ts @@ -2,7 +2,8 @@ import { type CollectionItemType } from './CollectionItemType' export enum SortType { NAME = 'name', - DATE = 'date' + DATE = 'date', + SCORE = 'score' } export enum OrderType { diff --git a/src/sections/collection/CollectionHelper.ts b/src/sections/collection/CollectionHelper.ts index 27199bf6a..beb192258 100644 --- a/src/sections/collection/CollectionHelper.ts +++ b/src/sections/collection/CollectionHelper.ts @@ -36,9 +36,13 @@ export class CollectionHelper { .filter((decodedFilter) => /^[^:]+:[^:]+$/.test(decodedFilter)) as FilterQuery[]) : undefined - const sortQuery = (searchParams.get(CollectionItemsQueryParams.SORT) as SortType) ?? undefined + // If we don't have a sort query parameter, we default to RELEVANCE if there is a search query or DATE otherwise. + const sortQuery = + (searchParams.get(CollectionItemsQueryParams.SORT) as SortType) ?? + (searchQuery && searchQuery.length > 0 ? SortType.SCORE : SortType.DATE) + const orderQuery = - (searchParams.get(CollectionItemsQueryParams.ORDER) as OrderType) ?? undefined + (searchParams.get(CollectionItemsQueryParams.ORDER) as OrderType) ?? OrderType.DESC return { pageQuery, searchQuery, typesQuery, filtersQuery, sortQuery, orderQuery } } diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx index 9578857bd..65f7e620b 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -108,6 +108,7 @@ export const CollectionItemsPanel = ({ } const handleSearchSubmit = async (searchValue: string) => { + const isSearchValueEmpty = searchValue === '' itemsListContainerRef.current?.scrollTo({ top: 0 }) const resetPaginationInfo = new CollectionItemsPaginationInfo() @@ -115,7 +116,7 @@ export const CollectionItemsPanel = ({ // When searching, we reset the item types to COLLECTION, DATASET and FILE. Other filters are cleared setSearchParams((currentSearchParams) => { - if (searchValue === '') { + if (isSearchValueEmpty) { currentSearchParams.delete(CollectionItemsQueryParams.QUERY) } else { currentSearchParams.set(CollectionItemsQueryParams.QUERY, searchValue) @@ -133,12 +134,12 @@ export const CollectionItemsPanel = ({ return currentSearchParams }) - // WHEN SEARCHING, WE RESET THE PAGINATION INFO AND KEEP ALL ITEM TYPES!! + // WHEN SEARCHING, WE RESET THE PAGINATION INFO AND KEEP ALL ITEM TYPES AND SORT TYPE AS RELEVANCE ORDER DESC const newCollectionSearchCriteria = new CollectionSearchCriteria( searchValue === '' ? undefined : searchValue, [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE], - undefined, - undefined, + isSearchValueEmpty ? undefined : SortType.SCORE, + OrderType.DESC, undefined ) diff --git a/src/sections/collection/collection-items-panel/items-list/ItemsSortBy.tsx b/src/sections/collection/collection-items-panel/items-list/ItemsSortBy.tsx index 914ebd3f0..7b6387324 100644 --- a/src/sections/collection/collection-items-panel/items-list/ItemsSortBy.tsx +++ b/src/sections/collection/collection-items-panel/items-list/ItemsSortBy.tsx @@ -31,14 +31,14 @@ export function ItemsSortBy({ }: ItemsSortByProps) { const { t } = useTranslation('collection') const [selectedOption, setSelectedOption] = useState( - convertToSortOption(currentSortType, currentSortOrder, hasSearchValue) + convertToSortOption(currentSortType, currentSortOrder) ) useEffect(() => { - const newSortOption = convertToSortOption(currentSortType, currentSortOrder, hasSearchValue) + const newSortOption = convertToSortOption(currentSortType, currentSortOrder) if (newSortOption !== selectedOption) { setSelectedOption(newSortOption) } - }, [currentSortType, currentSortOrder, hasSearchValue, selectedOption]) + }, [currentSortType, currentSortOrder, selectedOption]) const handleSortChange = (eventKey: string | null) => { const newSortOption = eventKey as SortOption @@ -75,22 +75,18 @@ export function ItemsSortBy({ ) } -function convertToSortOption( - sortType?: SortType, - orderType?: OrderType, - hasSearchValue?: boolean -): SortOption { - let sortOption: SortOption +function convertToSortOption(sortType?: SortType, orderType?: OrderType): SortOption { if (sortType === SortType.NAME) { - sortOption = orderType === OrderType.ASC ? SortOption.NAME_ASC : SortOption.NAME_DESC - } else if (sortType === SortType.DATE) { - sortOption = orderType === OrderType.ASC ? SortOption.DATE_ASC : SortOption.DATE_DESC - } else if (hasSearchValue) { - sortOption = SortOption.RELEVANCE - } else { - sortOption = SortOption.DATE_DESC + return orderType === OrderType.ASC ? SortOption.NAME_ASC : SortOption.NAME_DESC + } + if (sortType === SortType.DATE) { + return orderType === OrderType.ASC ? SortOption.DATE_ASC : SortOption.DATE_DESC } - return sortOption + if (sortType === SortType.SCORE) { + return SortOption.RELEVANCE + } + + return SortOption.NAME_DESC } function convertFromSortOption(sortOption: SortOption): { sortType?: SortType @@ -106,6 +102,6 @@ function convertFromSortOption(sortOption: SortOption): { case SortOption.DATE_DESC: return { sortType: SortType.DATE, orderType: OrderType.DESC } case SortOption.RELEVANCE: - return { sortType: undefined, orderType: undefined } + return { sortType: SortType.SCORE, orderType: OrderType.DESC } } } From 19067765b61fb28051edae59b01b4ade342f791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 28 May 2025 16:23:52 -0300 Subject: [PATCH 02/17] chore: js-dv pr version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 797dc1275..5dc78e5e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.47", + "@iqss/dataverse-client-javascript": "2.0.0-pr308.e29b28e", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.47", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.47/5f461fe665457557d60dcbcea0eaaf052a17e662", - "integrity": "sha512-MckQVEFFHYDqcuWZVo6iuOyFffp8us9nrUHup2U1NyuWsoKQvX43+p4PN16G2oCbjzn9xC+Bpzpto4OtJujUiQ==", + "version": "2.0.0-pr308.e29b28e", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr308.e29b28e/3b2adc4e9cf4248ee535082dd9dcc86f59946f9a", + "integrity": "sha512-pv9JcroYL5kMNkkPvfKW/TcJs1dc4J1W7bAcfrGtAEgCsDCOiAdNf8qXMuHK8OBO1ussVgZNcBd+iBCBqROrbA==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 621f4ae36..9cfc9fd06 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.47", + "@iqss/dataverse-client-javascript": "2.0.0-pr308.e29b28e", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", From ab18346a3d1434acc122929a72af69e3ea1c1b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 28 May 2025 17:38:49 -0300 Subject: [PATCH 03/17] feat: reading services and display them in selector --- .../repositories/CollectionRepository.ts | 3 +- .../domain/useCases/getCollectionItems.ts | 5 +- .../CollectionJSDataverseRepository.ts | 11 ++- .../domain/hooks/useGetSearchServices.ts | 56 ++++++++++++ src/search/domain/models/SearchService.ts | 4 + .../domain/repositories/SearchRepository.ts | 5 ++ .../domain/useCases/getSearchServices.ts | 8 ++ .../repositories/SearchJSRepository.ts | 9 ++ src/sections/homepage/Homepage.tsx | 33 +++++-- src/sections/homepage/HomepageFactory.tsx | 3 + .../homepage/search-input/SearchDropdown.tsx | 80 +++++++++++++++++ .../search-input/SearchInput.module.scss | 85 +++++++++++++++++- .../homepage/search-input/SearchInput.tsx | 41 ++++++++- src/stories/homepage/SearchInput.stories.tsx | 89 +++++++++++++++++++ .../search/SearchMockLoadingRepository.ts | 8 ++ .../search/SearchMockRepository.ts | 21 +++++ 16 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 src/search/domain/hooks/useGetSearchServices.ts create mode 100644 src/search/domain/models/SearchService.ts create mode 100644 src/search/domain/repositories/SearchRepository.ts create mode 100644 src/search/domain/useCases/getSearchServices.ts create mode 100644 src/search/infrastructure/repositories/SearchJSRepository.ts create mode 100644 src/sections/homepage/search-input/SearchDropdown.tsx create mode 100644 src/stories/homepage/SearchInput.stories.tsx create mode 100644 src/stories/shared-mock-repositories/search/SearchMockLoadingRepository.ts create mode 100644 src/stories/shared-mock-repositories/search/SearchMockRepository.ts diff --git a/src/collection/domain/repositories/CollectionRepository.ts b/src/collection/domain/repositories/CollectionRepository.ts index 176f0e701..6f374dbd1 100644 --- a/src/collection/domain/repositories/CollectionRepository.ts +++ b/src/collection/domain/repositories/CollectionRepository.ts @@ -18,7 +18,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 e3fddf863..fb01ade4e 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -55,10 +55,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/homepage/Homepage.tsx b/src/sections/homepage/Homepage.tsx index c36d3de20..dd66d8d2d 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' @@ -12,23 +14,44 @@ import { Metrics } from './metrics/Metrics' import { Usage } from './usage/Usage' import styles from './Homepage.module.scss' +// TODO:ME - Fetch the search services and show them in the list, possibly remove something from the list? Check slack discussion +const searchServicesMock = [ + { + name: 'postExternalSearch', + displayName: 'Natural Language Search' + }, + { + name: 'solr', + displayName: 'Dataverse Standard Search' + } +] + 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 +62,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..3562b9b0e --- /dev/null +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -0,0 +1,80 @@ +import { ForwardedRef, forwardRef } from 'react' +import cn from 'classnames' +import { Dropdown } from 'react-bootstrap' +import { CaretDownFill, Search as SearchIcon, Stars as StarsIcon } from 'react-bootstrap-icons' +import { SearchService } from '@/search/domain/models/SearchService' +import { SOLR_SERVICE_NAME } from './SearchInput' +import styles from './SearchInput.module.scss' + +// TODO:ME - Don't use react-boostrap, move to dataverse-design-system and first check a11ty + +interface SearchDropdownProps { + searchServices: SearchService[] + searchEngineSelected: string + handleSearchEngineSelect: (eventKey: string | null) => void + position?: 'left' | 'right' +} + +export const SearchDropdown = ({ + searchServices, + searchEngineSelected, + handleSearchEngineSelect, + position +}: SearchDropdownProps) => { + // 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 ( + + + + + Search Engines + {searchServicesWithSolrFirst.map((service) => { + const isSolrService = service.name === SOLR_SERVICE_NAME + + return ( + + {isSolrService ? : } + {service.displayName} + + ) + })} + + + ) +} + +interface CustomToggleProps { + onClick: (event: React.MouseEvent) => void + position?: 'left' | 'right' +} + +const CustomToggle = forwardRef(({ onClick, position }: CustomToggleProps, ref) => ( + +)) + +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..3289d2c1b 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; @@ -53,7 +57,9 @@ 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 +89,80 @@ 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); + + &.on-the-left { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + &::after { + position: absolute; + top: 0; + left: 100%; + width: calc( + 1.25rem + var(--search-input-gap) + ); // The padding inline the input has + flex gap of the search input + + height: 100%; + background-color: transparent; + transition: var(--search-input-transition); + transition-property: background-color; + content: ''; + } + + &:hover::after { + background-color: color.adjust($dv-brand-color, $lightness: -10%); + } + + &:active::after { + background-color: color.adjust($dv-brand-color, $lightness: -15%); + } + + svg { + margin-left: 4px; + } + } + + &.on-the-right { + aspect-ratio: 1; + height: 100%; + } + } + + .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..d5105f54a 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -4,15 +4,30 @@ import { useTranslation } from 'react-i18next' import { Form, CloseButton } from '@iqss/dataverse-design-system' import { Search as SearchIcon } 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[] + searchDropdownPosition?: 'left' | 'right' +} + +export const SearchInput = ({ + searchServices, + searchDropdownPosition = 'right' +}: SearchInputProps) => { const navigate = useNavigate() const { t } = useTranslation('shared') const inputSearchRef = useRef(null) const [searchValue, setSearchValue] = useState('') + const [searchEngineSelected, setSearchEngineSelected] = useState(SOLR_SERVICE_NAME) + + const hasMoreThanOneSearchService = searchServices.length > 1 const handleSearchChange = (event: React.ChangeEvent) => { setSearchValue(event.target.value) @@ -42,8 +57,21 @@ export const SearchInput = () => { inputSearchRef.current?.focus() } + const handleSearchEngineSelect = (eventKey: string | null) => { + setSearchEngineSelected(eventKey as string) + inputSearchRef.current?.focus() + } + return (
+ {hasMoreThanOneSearchService && searchDropdownPosition === 'left' && ( + + )}
{ /> )}
- + {hasMoreThanOneSearchService && searchDropdownPosition === 'right' && ( + + )} diff --git a/src/stories/homepage/SearchInput.stories.tsx b/src/stories/homepage/SearchInput.stories.tsx new file mode 100644 index 000000000..2431716ea --- /dev/null +++ b/src/stories/homepage/SearchInput.stories.tsx @@ -0,0 +1,89 @@ +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: () => ( +
+ +
+ ) +} + +export const WithSearchEngineDropdownOnTheLeft: 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..c96d1c802 --- /dev/null +++ b/src/stories/shared-mock-repositories/search/SearchMockRepository.ts @@ -0,0 +1,21 @@ +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: 'postExternalSearch', + displayName: 'Natural Language Search' + }, + { + name: 'solr', + displayName: 'Dataverse Standard Search' + } + ]) + }, 1_000) + }) + } +} From 17095523f61fbbcb2c5e4ba5f3dd8779bab189f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 28 May 2025 18:15:05 -0300 Subject: [PATCH 04/17] feat: fetch with external search only first time --- .../models/CollectionItemsQueryParams.ts | 3 ++- .../useGetAccumulatedItems.tsx | 19 +++++++++++++--- src/sections/homepage/Homepage.tsx | 22 +++++++++---------- .../homepage/search-input/SearchDropdown.tsx | 8 +++---- .../homepage/search-input/SearchInput.tsx | 14 ++++++++---- src/stories/homepage/Homepage.stories.tsx | 3 +++ .../sections/homepage/Homepage.spec.tsx | 4 ++++ .../sections/homepage/SearchInput.spec.tsx | 6 ++--- 8 files changed, 53 insertions(+), 26 deletions(-) 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/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx index 3b1e1d75c..decc26fac 100644 --- a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx +++ b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx @@ -9,6 +9,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 @@ -64,12 +65,22 @@ export const useGetAccumulatedItems = ({ ): Promise => { setIsLoadingItems(true) + const selectedSearchServiceFromSessionStorage: string | null = sessionStorage.getItem( + CollectionItemsQueryParams.SEARCH_SERVICE + ) + + // To remove it after using it the first time + if (selectedSearchServiceFromSessionStorage) { + sessionStorage.removeItem(CollectionItemsQueryParams.SEARCH_SERVICE) + } + try { const { items, facets, totalItemCount, countPerObjectType } = await loadNextItems( collectionRepository, collectionId, pagination, - searchCriteria + searchCriteria, + selectedSearchServiceFromSessionStorage ?? undefined ) const newAccumulatedItems = !resetAccumulated ? [...accumulatedItems, ...items] : items @@ -123,13 +134,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 dd66d8d2d..16cc6e293 100644 --- a/src/sections/homepage/Homepage.tsx +++ b/src/sections/homepage/Homepage.tsx @@ -15,16 +15,16 @@ import { Usage } from './usage/Usage' import styles from './Homepage.module.scss' // TODO:ME - Fetch the search services and show them in the list, possibly remove something from the list? Check slack discussion -const searchServicesMock = [ - { - name: 'postExternalSearch', - displayName: 'Natural Language Search' - }, - { - name: 'solr', - displayName: 'Dataverse Standard Search' - } -] +// const searchServicesMock = [ +// { +// name: 'postExternalSearch', +// displayName: 'Natural Language Search' +// }, +// { +// name: 'solr', +// displayName: 'Dataverse Standard Search' +// } +// ] interface HomepageProps { collectionRepository: CollectionRepository @@ -62,7 +62,7 @@ export const Homepage = ({
- + {t('browseCollections')} diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx index 3562b9b0e..7f44cc66e 100644 --- a/src/sections/homepage/search-input/SearchDropdown.tsx +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -10,14 +10,14 @@ import styles from './SearchInput.module.scss' interface SearchDropdownProps { searchServices: SearchService[] - searchEngineSelected: string + searchServiceSelected: string handleSearchEngineSelect: (eventKey: string | null) => void position?: 'left' | 'right' } export const SearchDropdown = ({ searchServices, - searchEngineSelected, + searchServiceSelected, handleSearchEngineSelect, position }: SearchDropdownProps) => { @@ -36,14 +36,14 @@ export const SearchDropdown = ({ - Search Engines + Search Services {searchServicesWithSolrFirst.map((service) => { const isSolrService = service.name === SOLR_SERVICE_NAME return ( {isSolrService ? : } diff --git a/src/sections/homepage/search-input/SearchInput.tsx b/src/sections/homepage/search-input/SearchInput.tsx index d5105f54a..7a3812a59 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -25,7 +25,7 @@ export const SearchInput = ({ const { t } = useTranslation('shared') const inputSearchRef = useRef(null) const [searchValue, setSearchValue] = useState('') - const [searchEngineSelected, setSearchEngineSelected] = useState(SOLR_SERVICE_NAME) + const [searchServiceSelected, setSearchServiceSelected] = useState(SOLR_SERVICE_NAME) const hasMoreThanOneSearchService = searchServices.length > 1 @@ -47,6 +47,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) @@ -58,7 +64,7 @@ export const SearchInput = ({ } const handleSearchEngineSelect = (eventKey: string | null) => { - setSearchEngineSelected(eventKey as string) + setSearchServiceSelected(eventKey as string) inputSearchRef.current?.focus() } @@ -66,7 +72,7 @@ export const SearchInput = ({ {hasMoreThanOneSearchService && searchDropdownPosition === 'left' && ( {hasMoreThanOneSearchService && searchDropdownPosition === 'right' && ( = { title: 'Pages/Homepage', @@ -25,6 +26,7 @@ export const Default: Story = { ) } @@ -64,6 +66,7 @@ export const WithFeaturedItems: Story = { ) } diff --git a/tests/component/sections/homepage/Homepage.spec.tsx b/tests/component/sections/homepage/Homepage.spec.tsx index 55072b020..47716ebd7 100644 --- a/tests/component/sections/homepage/Homepage.spec.tsx +++ b/tests/component/sections/homepage/Homepage.spec.tsx @@ -3,6 +3,7 @@ import { CollectionFeaturedItemMother } from '@tests/component/collection/domain 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..030678934 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() From 15d5c2eedd953efd4a3b1b7a8f03859206e64510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 29 May 2025 08:10:10 -0300 Subject: [PATCH 05/17] chore: comments --- src/sections/homepage/search-input/SearchDropdown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx index 7f44cc66e..f8f34f24c 100644 --- a/src/sections/homepage/search-input/SearchDropdown.tsx +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -6,7 +6,9 @@ import { SearchService } from '@/search/domain/models/SearchService' import { SOLR_SERVICE_NAME } from './SearchInput' import styles from './SearchInput.module.scss' +// TODO:ME - Add z-index to clear button to avoid it being hidden // TODO:ME - Don't use react-boostrap, move to dataverse-design-system and first check a11ty +// TODO:ME - Persistir hover focus style de boton mientras el dropdown está abierto interface SearchDropdownProps { searchServices: SearchService[] From e6a6c0d4986e9cf232d3229169f2c0b724666000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 6 Jun 2025 12:00:40 -0300 Subject: [PATCH 06/17] chore: use udpate js-dv pr version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5dc78e5e7..d3052f250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr308.e29b28e", + "@iqss/dataverse-client-javascript": "2.0.0-pr308.0c891f8", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr308.e29b28e", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr308.e29b28e/3b2adc4e9cf4248ee535082dd9dcc86f59946f9a", - "integrity": "sha512-pv9JcroYL5kMNkkPvfKW/TcJs1dc4J1W7bAcfrGtAEgCsDCOiAdNf8qXMuHK8OBO1ussVgZNcBd+iBCBqROrbA==", + "version": "2.0.0-pr308.0c891f8", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr308.0c891f8/9463a3de0d615af0ecf9c897b9ea25c7da95292c", + "integrity": "sha512-OE786QjjL7HvE6UUmdBubT6Gqu57WKMK/wWVNtQau8fmK9RAK6948XqW6F3yIxuY4IzZDIaxG4PjjimeK9Lr2w==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 9cfc9fd06..0960ade1a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr308.e29b28e", + "@iqss/dataverse-client-javascript": "2.0.0-pr308.0c891f8", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", From e0fc2eb0e1a026ad8193b3696957c26b015073cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 6 Jun 2025 12:40:21 -0300 Subject: [PATCH 07/17] fix: clear button being hide --- src/sections/homepage/Homepage.tsx | 1 - src/sections/homepage/search-input/SearchDropdown.tsx | 1 - src/sections/homepage/search-input/SearchInput.module.scss | 1 + 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sections/homepage/Homepage.tsx b/src/sections/homepage/Homepage.tsx index 16cc6e293..8e2805816 100644 --- a/src/sections/homepage/Homepage.tsx +++ b/src/sections/homepage/Homepage.tsx @@ -14,7 +14,6 @@ import { Metrics } from './metrics/Metrics' import { Usage } from './usage/Usage' import styles from './Homepage.module.scss' -// TODO:ME - Fetch the search services and show them in the list, possibly remove something from the list? Check slack discussion // const searchServicesMock = [ // { // name: 'postExternalSearch', diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx index f8f34f24c..dea627597 100644 --- a/src/sections/homepage/search-input/SearchDropdown.tsx +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -6,7 +6,6 @@ import { SearchService } from '@/search/domain/models/SearchService' import { SOLR_SERVICE_NAME } from './SearchInput' import styles from './SearchInput.module.scss' -// TODO:ME - Add z-index to clear button to avoid it being hidden // TODO:ME - Don't use react-boostrap, move to dataverse-design-system and first check a11ty // TODO:ME - Persistir hover focus style de boton mientras el dropdown está abierto diff --git a/src/sections/homepage/search-input/SearchInput.module.scss b/src/sections/homepage/search-input/SearchInput.module.scss index 3289d2c1b..165a6041a 100644 --- a/src/sections/homepage/search-input/SearchInput.module.scss +++ b/src/sections/homepage/search-input/SearchInput.module.scss @@ -54,6 +54,7 @@ .clear-btn { position: absolute; right: 0; + z-index: 2; margin-right: 1rem; } From c1a4609c46a731b975aaf60e195a9ed94bba10cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 4 Aug 2025 11:19:10 -0300 Subject: [PATCH 08/17] feat: use latest js-dv --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a5da3082..c058e8168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.53", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.58", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3561,9 +3561,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.53", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.53/0a9acbda27e2bb1d97b7beb3518a278e8d264cc2", - "integrity": "sha512-7aG4YdPu6tFxYOILLRh8u47kXBRmEWJiYAQHDmQl4Y0eY0C+gR8ckDKSM0E9KAwvMUM/OhvDtTXkJOjEaD1aKQ==", + "version": "2.0.0-alpha.58", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.58/11428448961c711b4ff62829648941b4945cceb8", + "integrity": "sha512-4Jlxgzgsno2KwfeZ4rZNLAQhKi7MSbCD0TgbDfMYvGUZIroowaVsH+lt9Gn0LE+5kLKbqeKqpSaV+o5/KxgmCw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 8fb033fb1..e8da6b7ba 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.53", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.58", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", From f9f73eb0ffac6424a51c266d1e2e7049ce0d729c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 4 Aug 2025 14:02:43 -0300 Subject: [PATCH 09/17] feat(Design Sytem): DropdownButton customToggle --- packages/design-system/CHANGELOG.md | 7 +++ .../dropdown-button/DropdownButton.tsx | 32 +++++++++++-- .../DropdownButtonItem.tsx | 6 +++ .../DropdownButton.stories.tsx | 45 +++++++++++++++++++ .../dropdown-button/DropdownButton.spec.tsx | 26 +++++++++++ 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 476a900fa..fab55de0c 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# 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. + # [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) ### Bug Fixes 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 cab491d37..e35f8d922 100644 --- a/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx +++ b/packages/design-system/src/lib/components/dropdown-button/DropdownButton.tsx @@ -1,5 +1,5 @@ -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' @@ -7,9 +7,14 @@ import { Icon } from '../icon/Icon' type DropdownButtonVariant = 'primary' | 'secondary' +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 @@ -18,6 +23,10 @@ interface DropdownButtonProps { disabled?: boolean children: ReactNode ariaLabel?: string + customToggle?: ComponentType | ForwardRefExoticComponent + customToggleClassname?: string + customToggleMenuClassname?: string + align?: 'end' | 'start' } export function DropdownButton({ @@ -30,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..5194d9021 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,8 @@ 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 ... + active?: boolean + className?: string } export function DropdownButtonItem({ @@ -18,6 +20,8 @@ export function DropdownButtonItem({ download, children, as, + active, + className, ...props }: DropdownItemProps) { return ( @@ -27,6 +31,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') + }) }) From 86d65c7f20b8c4840045bd809d3097524a3df7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 4 Aug 2025 14:57:28 -0300 Subject: [PATCH 10/17] feat: clean up search dropdown with dd component --- public/locales/en/homepage.json | 4 + src/sections/homepage/Homepage.tsx | 13 +-- .../homepage/search-input/SearchDropdown.tsx | 90 +++++++++---------- .../search-input/SearchInput.module.scss | 84 ++++++----------- .../homepage/search-input/SearchInput.tsx | 17 +--- src/stories/homepage/SearchInput.stories.tsx | 27 ------ .../search/SearchMockRepository.ts | 4 - .../sections/homepage/SearchInput.spec.tsx | 33 +++++++ 8 files changed, 110 insertions(+), 162 deletions(-) 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/sections/homepage/Homepage.tsx b/src/sections/homepage/Homepage.tsx index 8e2805816..4cff459ea 100644 --- a/src/sections/homepage/Homepage.tsx +++ b/src/sections/homepage/Homepage.tsx @@ -14,17 +14,6 @@ import { Metrics } from './metrics/Metrics' import { Usage } from './usage/Usage' import styles from './Homepage.module.scss' -// const searchServicesMock = [ -// { -// name: 'postExternalSearch', -// displayName: 'Natural Language Search' -// }, -// { -// name: 'solr', -// displayName: 'Dataverse Standard Search' -// } -// ] - interface HomepageProps { collectionRepository: CollectionRepository dataverseHubRepository: DataverseHubRepository @@ -61,7 +50,7 @@ export const Homepage = ({
- + {t('browseCollections')} diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx index dea627597..57390092c 100644 --- a/src/sections/homepage/search-input/SearchDropdown.tsx +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -1,27 +1,23 @@ import { ForwardedRef, forwardRef } from 'react' -import cn from 'classnames' -import { Dropdown } from 'react-bootstrap' +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' -// TODO:ME - Don't use react-boostrap, move to dataverse-design-system and first check a11ty -// TODO:ME - Persistir hover focus style de boton mientras el dropdown está abierto - interface SearchDropdownProps { searchServices: SearchService[] searchServiceSelected: string handleSearchEngineSelect: (eventKey: string | null) => void - position?: 'left' | 'right' } export const SearchDropdown = ({ searchServices, searchServiceSelected, - handleSearchEngineSelect, - position + 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 @@ -30,52 +26,52 @@ export const SearchDropdown = ({ }) return ( - - - - - Search Services - {searchServicesWithSolrFirst.map((service) => { - const isSolrService = service.name === SOLR_SERVICE_NAME + + {t('searchDropdown.header')} + {searchServicesWithSolrFirst.map((service) => { + const isSolrService = service.name === SOLR_SERVICE_NAME - return ( - - {isSolrService ? : } - {service.displayName} - - ) - })} - - + return ( + + {isSolrService ? : } + {service.displayName} + + ) + })} + ) } interface CustomToggleProps { onClick: (event: React.MouseEvent) => void - position?: 'left' | 'right' } -const CustomToggle = forwardRef(({ onClick, position }: CustomToggleProps, ref) => ( - -)) +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 165a6041a..501a3d1c9 100644 --- a/src/sections/homepage/search-input/SearchInput.module.scss +++ b/src/sections/homepage/search-input/SearchInput.module.scss @@ -97,72 +97,42 @@ .search-dropdown { height: 100%; +} - .search-dropdown-btn { - @extend %search-btn; - - z-index: 2; - min-width: var(--search-engine-icon-btn-width); - - &.on-the-left { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - - &::after { - position: absolute; - top: 0; - left: 100%; - width: calc( - 1.25rem + var(--search-input-gap) - ); // The padding inline the input has + flex gap of the search input - - height: 100%; - background-color: transparent; - transition: var(--search-input-transition); - transition-property: background-color; - content: ''; - } +.search-dropdown-btn { + @extend %search-btn; - &:hover::after { - background-color: color.adjust($dv-brand-color, $lightness: -10%); - } + z-index: 2; + min-width: var(--search-engine-icon-btn-width); + aspect-ratio: 1; + height: 100%; +} - &:active::after { - background-color: color.adjust($dv-brand-color, $lightness: -15%); - } +// 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%); +} - svg { - margin-left: 4px; - } - } +.search-dropdown-menu { + min-width: calc(var(--search-input-max-width) / 2); + margin-top: 10px; + padding-top: 0; - &.on-the-right { - aspect-ratio: 1; - height: 100%; - } - } + .search-dropdown-item { + display: flex; + gap: 0.35rem; + align-items: center; - .search-dropdown-menu { - min-width: calc(var(--search-input-max-width) / 2); - margin-top: 10px; - padding-top: 0; + svg { + color: $dv-text-color; + } - .search-dropdown-item { - display: flex; - gap: 0.35rem; - align-items: center; + &[aria-selected='true'], + &:active { + background-color: $dv-brand-color; svg { - color: $dv-text-color; - } - - &[aria-selected='true'], - &:active { - background-color: $dv-brand-color; - - svg { - color: #fff; - } + color: #fff; } } } diff --git a/src/sections/homepage/search-input/SearchInput.tsx b/src/sections/homepage/search-input/SearchInput.tsx index 7a3812a59..b8f944c89 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -14,13 +14,9 @@ export const SOLR_SERVICE_NAME = 'solr' interface SearchInputProps { searchServices: SearchService[] - searchDropdownPosition?: 'left' | 'right' } -export const SearchInput = ({ - searchServices, - searchDropdownPosition = 'right' -}: SearchInputProps) => { +export const SearchInput = ({ searchServices }: SearchInputProps) => { const navigate = useNavigate() const { t } = useTranslation('shared') const inputSearchRef = useRef(null) @@ -70,14 +66,6 @@ export const SearchInput = ({ return ( - {hasMoreThanOneSearchService && searchDropdownPosition === 'left' && ( - - )}
)}
- {hasMoreThanOneSearchService && searchDropdownPosition === 'right' && ( + {hasMoreThanOneSearchService && ( )}
- ) -} - -export const WithSearchEngineDropdownOnTheLeft: Story = { - render: () => ( -
- { setTimeout(() => { resolve([ - { - name: 'postExternalSearch', - displayName: 'Natural Language Search' - }, { name: 'solr', displayName: 'Dataverse Standard Search' diff --git a/tests/component/sections/homepage/SearchInput.spec.tsx b/tests/component/sections/homepage/SearchInput.spec.tsx index 030678934..e40fb559a 100644 --- a/tests/component/sections/homepage/SearchInput.spec.tsx +++ b/tests/component/sections/homepage/SearchInput.spec.tsx @@ -25,4 +25,37 @@ 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' + ) + }) }) From ec21b18dbcb8faacb82606bd3bc6ac9d373c5c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 11 Aug 2025 12:25:20 -0300 Subject: [PATCH 11/17] feat: add stars icon if selected search service is diff than solr --- src/sections/homepage/search-input/SearchInput.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sections/homepage/search-input/SearchInput.tsx b/src/sections/homepage/search-input/SearchInput.tsx index b8f944c89..a809fb1e5 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -2,7 +2,7 @@ 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 { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' @@ -67,7 +67,13 @@ export const SearchInput = ({ searchServices }: SearchInputProps) => { return (
+ {searchServiceSelected !== SOLR_SERVICE_NAME && ( + + + + )} Date: Mon, 11 Aug 2025 17:10:41 -0300 Subject: [PATCH 12/17] feat(DesignSystem): Add type prop --- packages/design-system/CHANGELOG.md | 2 ++ .../dropdown-button/dropdown-button-item/DropdownButtonItem.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index fab55de0c..a13889d59 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -9,6 +9,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - 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. # [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/dropdown-button-item/DropdownButtonItem.tsx b/packages/design-system/src/lib/components/dropdown-button/dropdown-button-item/DropdownButtonItem.tsx index 5194d9021..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,7 @@ 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 } From cd7f17d2e77379cb0635596e6ba37391a7bfa062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 11 Aug 2025 17:41:54 -0300 Subject: [PATCH 13/17] feat: remove ext search after loading all pages --- .../useGetAccumulatedItems.tsx | 17 ++++++++--------- .../homepage/search-input/SearchDropdown.tsx | 1 + 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx index 788bb008f..c0d327bae 100644 --- a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx +++ b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx @@ -59,14 +59,8 @@ export const useGetAccumulatedItems = ({ ): Promise => { setIsLoadingItems(true) - const selectedSearchServiceFromSessionStorage: string | null = sessionStorage.getItem( - CollectionItemsQueryParams.SEARCH_SERVICE - ) - - // To remove it after using it the first time - if (selectedSearchServiceFromSessionStorage) { - sessionStorage.removeItem(CollectionItemsQueryParams.SEARCH_SERVICE) - } + const searchServiceFromSessionStorage: string | undefined = + sessionStorage.getItem(CollectionItemsQueryParams.SEARCH_SERVICE) ?? undefined try { const { items, facets, totalItemCount } = await loadNextItems( @@ -74,7 +68,7 @@ export const useGetAccumulatedItems = ({ collectionId, pagination, searchCriteria, - selectedSearchServiceFromSessionStorage ?? undefined + searchServiceFromSessionStorage ) const newAccumulatedItems = !resetAccumulated ? [...accumulatedItems, ...items] : items @@ -93,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 diff --git a/src/sections/homepage/search-input/SearchDropdown.tsx b/src/sections/homepage/search-input/SearchDropdown.tsx index 57390092c..348c45c3b 100644 --- a/src/sections/homepage/search-input/SearchDropdown.tsx +++ b/src/sections/homepage/search-input/SearchDropdown.tsx @@ -43,6 +43,7 @@ export const SearchDropdown = ({ active={searchServiceSelected === service.name} className={styles['search-dropdown-item']} as="button" + type="button" key={service.name}> {isSolrService ? : } {service.displayName} From ff751e84cff4840c8a0f4c36ead0bbf22b140d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 14 Aug 2025 09:45:58 -0300 Subject: [PATCH 14/17] test: improve coverage in SearchInput --- .../sections/homepage/SearchInput.spec.tsx | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/component/sections/homepage/SearchInput.spec.tsx b/tests/component/sections/homepage/SearchInput.spec.tsx index e40fb559a..56253ddbf 100644 --- a/tests/component/sections/homepage/SearchInput.spec.tsx +++ b/tests/component/sections/homepage/SearchInput.spec.tsx @@ -28,7 +28,7 @@ describe('SearchInput', () => { it('should show the SearchDropdown with the search services when there is more than one search service', () => { const searchServices = [ - { name: 'Solr', displayName: 'Solr' }, + { name: 'solr', displayName: 'Solr' }, { name: 'ExternalSearch', displayName: 'External Search' } ] cy.customMount() @@ -58,4 +58,65 @@ describe('SearchInput', () => { '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') + }) + }) }) From ca1ea8af028d5aa484fb6610cccc31de9dd61700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 14 Aug 2025 10:06:08 -0300 Subject: [PATCH 15/17] test: improve coverage --- .../CollectionItemsPanel.spec.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 + }) + }) }) From be65ad98d0dbf9b9dd10f56259b7f069216e95fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Thu, 14 Aug 2025 10:37:33 -0300 Subject: [PATCH 16/17] test: improve coverage again --- ...atasetMetadataFieldValueFormatted.spec.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/component/sections/dataset/dataset-metadata/DatasetMetadataFieldValueFormatted.spec.tsx b/tests/component/sections/dataset/dataset-metadata/DatasetMetadataFieldValueFormatted.spec.tsx index 2a91743f6..59948b907 100644 --- a/tests/component/sections/dataset/dataset-metadata/DatasetMetadataFieldValueFormatted.spec.tsx +++ b/tests/component/sections/dataset/dataset-metadata/DatasetMetadataFieldValueFormatted.spec.tsx @@ -4,6 +4,7 @@ import { metadataFieldValueToDisplayFormat } from '../../../../../src/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataFieldValueFormatted' import type { DatasetMetadataSubField } from '@/dataset/domain/models/Dataset' +import { DatasetMetadataFieldValueFormatted } from '@/sections/dataset/dataset-metadata/dataset-metadata-fields/DatasetMetadataFieldValueFormatted' describe('joinSubFields formatting logic', () => { const mockDisplayFormatInfo: MetadataBlockInfoDisplayFormat = { @@ -33,6 +34,12 @@ describe('joinSubFields formatting logic', () => { type: 'TEXT', title: 'Other Field', description: 'Other Field description' + }, + datasetContact: { + displayFormat: ':', + type: 'TEXT', + title: 'Dataset Contact', + description: 'Dataset contact description' } } } @@ -80,6 +87,28 @@ describe('joinSubFields formatting logic', () => { expect(result).equal('value1\n value2') }) + + it("hides the 'datasetContactEmail' subfield when present with other subfields", () => { + const metadataSubField = { + datasetContactName: 'John Doe', + datasetContactEmail: 'john@example.com' + } + + const result = joinSubFields(metadataSubField, mockDisplayFormatInfo, 'datasetContact') + + expect(result).to.contain('John Doe') + expect(result).to.not.contain('john@example.com') + }) + + it("returns empty string when only 'datasetContactEmail' is present", () => { + const metadataSubField: DatasetMetadataSubField = { + datasetContactEmail: 'john@example.com' + } + + const result = joinSubFields(metadataSubField, mockDisplayFormatInfo, 'datasetContact') + + expect(result).equal('') + }) }) describe('metadataFieldValueToDisplayFormat', () => { @@ -276,3 +305,94 @@ describe('metadataFieldValueToDisplayFormat', () => { }) }) }) + +describe('DatasetMetadataFieldValueFormatted component', () => { + it('renders an anchor tag with correct attributes when field type is URL', () => { + const mockDisplayFormatInfo: MetadataBlockInfoDisplayFormat = { + name: 'mock name', + displayName: 'Mock Metadata', + fields: { + urlField: { + displayFormat: '', + type: 'URL', + title: 'URL Field', + description: 'URL description' + } + } + } + + const url = 'https://example.com' + + cy.mount( + + ) + + cy.get('a') + .should('have.attr', 'href', url) + .and('have.attr', 'target', '_blank') + .and('have.attr', 'rel', 'noreferrer') + + cy.contains(url) + }) + + it('renders markdown (from HTML) when field type is TEXTBOX', () => { + const mockDisplayFormatInfo: MetadataBlockInfoDisplayFormat = { + name: 'mock name', + displayName: 'Mock Metadata', + fields: { + textBoxField: { + displayFormat: '', + type: 'TEXTBOX', + title: 'Text Box Field', + description: 'Text Box description' + } + } + } + + // HTML will be converted to markdown, then rendered as HTML by ReactMarkdown + const htmlValue = 'Hello world' + + cy.mount( + + ) + + // After transformHtmlToMarkdown + ReactMarkdown, becomes + cy.get('strong').contains('Hello') + cy.contains('world') + }) + + it('should just render the value as markdown when field type is neither URL nor TEXTBOX', () => { + const mockDisplayFormatInfo: MetadataBlockInfoDisplayFormat = { + name: 'mock name', + displayName: 'Mock Metadata', + fields: { + simpleField: { + displayFormat: '', + type: 'TEXT', + title: 'Simple Field', + description: 'Simple Field description' + } + } + } + + const value = 'Just a simple value' + + cy.mount( + + ) + + cy.contains(value) + }) +}) From 03b80e189c23c3f3a8ff4b7f0aeb0a5db7ce7afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 20 Aug 2025 10:55:55 -0300 Subject: [PATCH 17/17] test: avoid fetching external image and use fixture image --- .../e2e/sections/dataset/Dataset.spec.tsx | 3 +- .../fixtures/images/dog-640x480.jpg | Bin 0 -> 49907 bytes .../files/FileJSDataverseRepository.spec.ts | 34 +++++++++++------- .../shared/files/FileHelper.ts | 27 +++++--------- 4 files changed, 31 insertions(+), 33 deletions(-) create mode 100644 tests/e2e-integration/fixtures/images/dog-640x480.jpg diff --git a/tests/e2e-integration/e2e/sections/dataset/Dataset.spec.tsx b/tests/e2e-integration/e2e/sections/dataset/Dataset.spec.tsx index 97ef89241..307713abc 100644 --- a/tests/e2e-integration/e2e/sections/dataset/Dataset.spec.tsx +++ b/tests/e2e-integration/e2e/sections/dataset/Dataset.spec.tsx @@ -682,7 +682,8 @@ describe('Dataset', () => { }) it('shows the thumbnail for a file', () => { - cy.wrap(FileHelper.createImage().then((file) => DatasetHelper.createWithFiles([file]))) + FileHelper.createImage() + .then((fileData) => cy.wrap(DatasetHelper.createWithFiles([fileData]))) .its('persistentId') .then((persistentId: string) => { cy.visit(`/spa/datasets?persistentId=${persistentId}&version=${DRAFT_PARAM}`) diff --git a/tests/e2e-integration/fixtures/images/dog-640x480.jpg b/tests/e2e-integration/fixtures/images/dog-640x480.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9e76f701708d69f2b421cf7e9d1d1984010f827e GIT binary patch literal 49907 zcmb5VbyQnH*Ebq0Sa5fDO>lR22wI9e6bKZGRUo*#yA#}{(Bf`|;Lzd}DMbn`Qu^^c z?|aw1-=BBZnKL^xd;L!KoV8|V@BKUfR{!k)zN!ScIspJ08r%RZ000045Tl?0P@bj# zRB9BA|F$O2GR^c5&#Pb!~|ksVPaxpV`JffNbx|pxF9NG5&}{> zY6f~bYFb)GR(=jfW?mLrT23)8UO}+1urLFMgp4>unqNp5@*fZsY;0^0E{GBjj}pQ} z%LMuVUH%OMh_Fz$Q9q!eFauDDP|%1_{tW|Yo{d67LHS=B{$mFkItnTV0EqcKNclYQ ze~mrQB`O;FzjXiz4F!Noh(`F_alJF2|5{Txly(c7(*o*L?kvUAse3WU`+YK^Jk%*1 zM~HSVa?m>G1S7F{j3|7X#C#!p?bc$;BHXiqJdZzlT$fSIqquegH`|oQ%{wp!cra|) zCSlk3nmdbXX*V>Q^4%0$57j7oPIK!^A$*_cisJg%A-jbvx`lzze0H)b^>21xLWzwU zZrILWrOo}MiC1z>9}PJI8g6A^+0@68lZO$8%8$kus19~w5UJqC83zq>9pX4_;fOjw zPZBK1Jm@3Str}8WHe573tJPDMv~d6q?_ej(jJPSdne&cy(Vuy$UcLi0gzFn*aLVSv zn+aVmGYCwzZ>%kj>VkN}E!HX}ZxcX627b!*I^5_f3vEUj*tj~nVQuRKu)Sr(6N6mf ztwvw~@|WNBV_r&}+bXe=FjtYN1eec8F3VuA$>HGJC->Spt6AWaa19-z(nr1z7pp#J z&U{ddq>{#@uU9wXuczN=uJ48dDq6M4%^TlMpdzt>vx~#xm(p^2_b(;}o(+BJarlER zWp&4m7Fi!DS}yfN??^$!C*UB%6U8qre(sb94V%Bo~zP8#*d&UYnk+7{H0;g#ae z7&h&7NW&%g0|Q9=8)9Yr{`@5#U|MW!|M98vD32`2Zs@!}<8kOeqbU)C5qp^d?!S;_h0L zAd7zh*;Zi#tIb2c{`-05F%pqfj;_R0vQq^jPac_($`%~~!#-)%4u_%INcD>Qn&Gg> zGhC(2(6Vfi35s9)u<|^%GVXHnR{uPvYNZDk-J$n`}Y0po7^{g+OOZx zfzf}P#jQrD?DW@~hD5liSvPYMCR14nvW|&;pK~UK^AL=r8qubPI?`b^{Wq{Fs z7r#7Z;#5OyMN30HuaDP7C9v^%+R^&mXGMloIsU@R zuO5*{w&g*|cQDh+&XkJ6mO@1WGd&jHrN+bRpa-z!(#rfn^{OlIgIoGYWYv zn)qY3Z}asWkB^rO3VqEUrTzgT7@%FNyNGzw-Ds6X*C5SQ6dcbf^9CWJ&OsZ8MEUWKI_I z*P-mZdRTVJbP~oduL8gRy^jHLYIMS>w-pn80xBrnpd~I9GeU@A)83lJ>p^X2QqVH* zZqPvKUv=ku-qjh-^2MG+leZcQzrO107YB)X_pyg`dH0`LNhP*Bz~MVMTG`Nf}&n8$sGmlaOy1d8?^e$ED`bH`-vF7s+@@SbU63E!(r&*Eh zOw<+HS{NAR#a-;76g?t)eUg$z;uWVGMXzDYjYKZFOcCDceJEqlio+qDL`QQ9^5*F+ zkL>zmAC7W+-)Z-$p2*@Q&=J_9_gmdZchU^cW{U;J3w~zyDvJ*86AQ@J4hi)_wO#>3 zwW|`{GJmTlPL!*LH*T$tv#7=H4QIvt*_{lVHfW)}@L2dHlRbEcBRcgX%$gl|PT}d7 zeacdguDrTyvV=i2fVCs}tLc*53CjDCTo4WoSC^v5*?wWxkoQ5*sdYSPv;TnFH$q?O zI~RJnX(g_q%^qHt-g_Pk*PXU1nW^dui~Y%RuMPaslbj28ymueINom4o2H7`5b(CYQ z!uG9&d$454>p@s;#XpvqUj|1j9sL97Ur80(g(vntl#bX16eiY+#sE>=;C&us<#h3_ zIBFO}Xp1PNot)|=oguA@M2e}kv8m(%h?L7xoG>G(a8r9p zHLR}J-haTIvB8k^u-3R08j4=lV4fp*>h4Q6Nbs^nWpk+yq!;Uz2^s7HPI6p2RxW6?72s(qyb> z;$=ryUBA>^@4sKdlBXrLDiOWBovy*8Q&jJ+6$!}3ToP}0VhQ#gv8KKqxT2W3_1*jP zo9wb9Yh!U#Cs^o@nan|mH2qm2YR!4pWrj>euMWXWFDl$0$SeKez2-GUW)hBXaxC61 zzdarvMVS%jEMyH^(&EnTX>rVNaJf%}HSD{1t+%%seydw%?hLW}02eGYQx(Mbn%k(q zPhh~mn^VvuMExKv z#8u1gMUTNcDrCHBl@dok=TiJoxYM?-(WKh2fNC`qh$h5g_6EyAon*>Opeoe*+xAa- z&*_@puS&vJbP2C5nfKRHich^UniA1>n$*kF%MGc#yv&B9v3AdUN z6xtGP4SY4|-Jm_1&Elmtp4=R@2Oh{6?a-@-wjlMxA-6BPJ7%a@N9KR>@GTs9(fo6&X6Ki1vsjj!dfogn^M}v+ zeLel9za+EjjF%jiDKy+RMF-hw@hfp)KjoF$)zA0l&(ZXvEVJLpk95R_AcWKMU!oJ#oOJny|FOB+?QikSJ-*%a>i>ql$sD%Hy;p+ml(+ zOC{C%ZvbY(DX1tV*NygRHHR;hUv__AvLmDVgB@ElDV@zdWB;+sc6y2iO;NSFje_

V0Xy`vCs&;FMUZ9Qat1IbtUCmItMW`Cv2frHfx^?(+&DO%}nb@Al^q$-RzU!|Q{a^i1 z{a^~OR;LOe?0ljK`;`DEHqqj_0^|OhY@xuZoB;=6`m`9{-3~!l$>sXt+V0YF+-Mtz zlHyaao02LDhVm$RN7!y>^GHGU(84JTDUl{JtM0SFPuSTd-r=052kNhqIp~Q+lLp1} z3GYV(vKTmsnyhK|qEQ5+rlg?mo61}nKKC~nC?79G>M}R;P@J3I%c9<`O>JTjqbz1Z)e+2Coa!Ad zDe}r(7^}psa<;oP0TJNAP##d&rMdX_Fykae%TVfLOdlS)pYvgzIlo_6BVynyv~ISQ zU!-_3c# z3_E}M0CE7m7R_{{O$&3IDK`j zeDqDYor51j$Q@O?+9tJLlRL#+Q!U(Aj9xKqvrz_1`+*Dr}A!=(_=AQHFJ=IluH|+H{2xFHOqwVM#By-ec4)h5aHHe9ojvx zumb5-L&|zB=EuHT6hYI>N@S$(W6@gPcey$~UVf|n1yaNK%VcB(37k=`@2`t#H>hQ% zKu^i8sDTO6*Cd-R*Bw$;C=#Gh5OH4B_49RfDh}5B*ocJ%6IJzET|ZInO_G z@7BdhW@%HWpB&fQ$FitZ|FWWs_xgT$T{_lP6-N45zB8;Zc=qlefTNFL?&K~0H;Lw7 z3V*Os-uAF)FYKLRb~qgye;vy?m_axBvSdj{Gh}c&sBM+kExRA9XC4>d7;U;K z54sNA{yMy-+HLn)jrB^Hf!gB*Uij@KiOX;_1Vyy99h7u_mAm>?EKf^Fjbm(x3&Xgh z!Q3g*2erAQi-2aPLf6XzaC?`7r^x$K=kPJVNzg?xCyg-S1AGF`RQyqm5WeA_hg{7T ziANWKa>N5_WQ094pHI<|--m<8D5~Q!cWd=a=+eWdIem($_SpHwyG}sO6Mg!sHl zmu19VCm80rv^w(0uLBgM_~T(ogon(a_=Tf0UvBuzf<`54;uttlS)wavf26BVdmdID zOTzA+tu(BuJ)PV7udgVx5e0INOex+v5zZxuU5-7~$TRIXzq#l#RTrF{JwG`p(AB=s zpwog|$OyF+zw*>&iRH#YTHO9s;PfBoWj>L0O3~Lv=VzM0NyCL+Y;vMc`PWuk2-1(y z&+|8-sF^+c_K{7+>F)j7+R|=L$-AN4)J0u>vBbp9=FUWOcu(s4;G$#2w(7-0{fK~h z9Y*@IQu~}@x&l>1o~n-XG5`AvDr`n@ArBg1lzDNea`6iVjs8!k*wvO==(HJVnF;-? zq5E*wbq~?2@^RQq=6W;&u7bv_Xite|Sf(;^zOA#2X@tfVez{-mGk86PZyf73 zSN#vbcyie`deR?i@et0aoddvn<0sEpLfH*xVZENUV#$z#8JR%`TjkOA?1AKPGBvZT zL#M}ctZA=6! zFh-ry@h{*auIEwfLO1gv6;_~enQo-8JV{d{Rl{})t(cGX=CqWJinU>)p2dTD5gU04L% zyhEfwZ3$LBv+OXf!Ew)T+5NWh0%Y=fE7)G#*M1gtU|86ha8TmVJf~-WM6#jpTIpNm zF%5VKKFne-j9tlL;~)J|4TEtSUoPKjAqOKteY}@_BX?e6+ej~>2jgr=3m0|d=(3rG zmMifeAYOjegg@lXp;sqc=*t`JayzeERc*;4pnpg3>)WmUzGCsU7f_c^! z*x&q$>K`D%4Ev1)qdRuOm3#&lxal%`1PW^1v>E0yNn8@^{`pCs!c-u^Bt*Bd0qNlH z`{&y~0HKJaTs5Iu7dbY>Po0KiNj;}0KPUdvpJv+nxROgmJ%FdlKf-pb$x=aM16Qog z>WjFPh#iHt`5VDF25 zsYiM-+b9r4(sB0wzWL@3R^~?qQEC(WQ%WH&*uWcXCNTI9kZG2TG)479kuhN>NWJ3Y zwT$qQGj_L`^Q)wxJ$a5RvHNsIm`tQ z%%=yPihlraa#bNzIIVBpwob(pU|uc?ocX~m%O+1MC#jnAt?%loLUz7nntq+5;5M&y z6%Gosr}pZq?17Y6?3q9GfUodmU6R@SfPsj^r>G)v-#et&E2iJESo zs&5|04a({}GE~dU5&fnE5~5140v*C&6vwVd#IAh=RQub{>oFumo6i3W6$B?^7TP6G zV&0tHoFHuc!>}Uy2BrE8{z@%M%lh zS_F3>g4)5?nq-Y(uM>~8^h`6?U%`95YUDd*^;86@Vuv_Y;qEQ>sOxK z(K-xliod~+XQ#^hjHG1bl-{ z$nIqlRPPu02L7ff@^L9Lbp5R^TO)hoyhT}9djeXO>(sEA7Im_$f3u2JUgyLb(IOX1 zvTxHVTx0K1BqCBu{=g8scaP5e8Rc8$_{k@21TZ8?Y)<}&$Ncy{zWQq|>$@`6tNr>b zst%1n$AhfgSA|vqwGmNClo8=q|w#TOTPzym@Eu zznA{Pl55)5-iRUWv$(64m>kCoa%Dk$_oN%j;9@JRO6JwZeGsJUQ8smOS6gm6{$u75o@E=vuS! zEA_;8?r_PNHb5~?+sPmH@?7-C^tM}LCUiAE>QZ564)ogN0K@d>6P?yG*SDv zqY)d_>8Qi)Sh|ago?x|<) z9{_y!c;e=U-N=B3=Qbmj=PlE8MQOyCquYhlj^x$&ECsDCRF)qVE+zQ;*?vc54E)LQ za&DL7v{NI_tTOVVi7Sqs6>rI=u$J~+J?MtHPrjsZ@34AHWo8Nwn=QoG&TbC_mT0>3 z7SWsB7UIyKkG^kDR$SoyytTY9(IB4qfP+e9lmZD+34~dQ%2bNPOXsX-WUmo`}O)eE=wrEFpf5tw9`Mp8BFw~85}J*r`JTsOX=cDL#pi{ zH%uqctv@pI14^>H8Au;ki0jm0@{zGg#|)*dbiN$ti>H2pIzeaz!?^$7rxPe@xVs$C={j5#(EvACLNtdZ^=F#O}>xO2$0X3M-oAmL#)=7Hv*^?TqcTI1YY2 zmcVL=(h1)Y1t90bEWS)(HC#d@A`qZTPp5O;GmgnPG<9AO9bQav(kiFw7zyB(~;5y6p)#Hrf;L6x@aJ$k=hSgDqH}F?}!6I=a z;nyjpnD-c>LL~zN+M#ZkJYtY#B2n*iuP zk6c)%KwQX6xwy%ojt!{=1b}3<{TfGhpK_O`7JfAQ@dCjRqUL^XF=|WhJ{%Jk>75` zd@t-BrDz1(&|I>UadG*Ntdk}dii$=bV7xlv&71Pzl5MI}90GF{8Xp94t}h{Ur8{vZFxY>BHtL7-=Mkxqz-UOzjWB zfFt1nGrQp;-`D=!_zq1s6qf?|`0ut9_)QT;F~fuv5o?&KEuPwcKdhV+_TFc*Th0&s z!NQ}-B4!xEu9_6_y^^2|m_Wu(U`~Hpr7nI)vp*l^tE)Y1pY}%J9;`O1WoH;&S1gnH zb{GRrJ0tqyikD7tZJHX*;H8P|%bo1RFPD@=?JJCUG3}*{{_$28Eu~1*)uBs7^(rA& zp3m^m^eH&dTk_;v2L87`#QSRHzpJrsj;yyvXl`pSd^e7ILTXLzY{=cj%}O1`2!5f! z3=}IYm>ncwrAir!l{V&A{2OS%;`}$#4hq39RGjj(z@EP+&>Z5|tfz3;qMB%;h{}ti4?wFXgzW{%y2z6H?|Q^)9sKoe82N zvFOX99sbt>5ld;t*Ze-MqRe{n@Lhjdb|Oh97Xvcax;;GUfbaEm*ilqwrH5$>7R^!E z9|UDNT!el|r_tM^dC1Mm4+i^RyC*k?uCMK->`;q#j1qjs|8h*<85tDx z)I2V<*SGFK`=m%e`0gSmImU@(c32@D-m$a&hkfTK{2#y~iS=TZuog{N38$J7PCPjMDvQUSkoLUt;oD0Kk^S%ALxu}qr ziUD8$0dOAWH0>3{!=Xk)_&D>ACPZ6y4@vcflWpnTzlK(hpk7H2(t>`;Cr$yOec` zBvdZr^mlnbW`mTjm;NbZq9EgBehSfhnfi8!^&wK@Hz@6#ienQp*L6??n~~ki4SH-{ zaV+d)y;-w8h2)&H=ozKbS1}cqvC_$6AA2{+uv2}%(9?*-PXqnRcK@7Ei0XJB* zA?mRt3bM)#3PBADAwe9&XeEGkDw99Fh3|52NfM+`WEsykKX9BhK&xd59Ed+hM12ec zii{=n#upOnh4&{%zk5f~A-o=7BB0xO+%)q2LiphwRuBkPKp}kS*R<-}&vZpqdxdb? zhakO9d%?G2v=5$A51v%F)B1mhrXc5&`W&J!8*r-gV)-Jf?0?JN8NPQXk=KNW+ zhj6C}4=W%HwG~w&= zd^8lD-(?Tj((p((uavP_Af?_#)H)xp4lT&=<3xuO$8af(tMG>tV0*ynCrpzQI@KlO z6*E}LeiNkBkNjDW2_WFt;y&XLtXbtU$|A^gxVP6X;D@rQ|M1x@R6?@2Oc|E^Ig=>r z(iz+LH(&JeGC}y;ngdT497ncKf$0Sa)G+x7yEOXkyG_Me)#g>~Iy4Q^qgydW z?RH_NJs}LTecUO%P<~uOd_`rV#t$(7MTLLy>BcImPhi8Qf_nj7xv~_6m>)#*75y_3 zl!}RX2mB6sXsXA6jsa_BCd&G^CsSyM=^S+NOGoZ5a>3z&_R+5;H7&2CnHN#)kH-s{ z8AqM?!Z{a>s@EJvI+(|+O#&v2Fw(N9Mj2l?eeZxDuWH`N(8naSb7b3^Ikm6q*ltrb zQW)fpAU|_{iwPPNqzW-6(`Ay2Qj8T$z76uI+oMuQj3H`zmU;%B{!#hmnQpS z{W)6n@RbBl$!x*Py>5AQ_AN94)5Ux$Q~p->$mn?XPSkmeN;1mp_hAmpJi5Fw`lq;9 z+MA>74|{_@!8Rc<^8w3V)07QBp$OrcOQyqNZx1+`=9Duu|m$?!5?}#fXcd%n_`!%D~w_ zRxcF7;*)+(*mYJ0d4Q@vEm3y0e1T=W*JnOdErgEA=r*39DL!*#6p&nn3hx{X9g_9} z;FPTPp_kDwd0-94O4X>K2_^2dALjccQ@DV4q)t_&^lqBR)*1B{~_obEZ?f1tPO>FtBT z}53$$IG3kI`bHlkP?M&a8HU13U(&v{XhhWwJZsIA5o2cRxaY4 zs3_hq)xg5ZvZNT?z*FR#kefDum8f0NWom4t`e3bPaH`%f@WCVSc z?}_5rBoA^GIF}y%SH94(IWb_B-Zg+;0Zi_5xAoF&;IduCsV@Ep87;Lf?JH^4!FVPP zeO|5Dj-ECLFf`^of5_K_qpm;bjVWXZXr<@A`yeo}*A<&dC5NKCKi24&eJMms2j0-O zSsDdq__I*+Uh;|~Bn`y>uo#~CSx#f7t#>PgQ-nrKMTDkoPnC5zy7=boi|uD$J5GoW z6Ygmz9ZFEpw8716uVTY(o zSHgv2akf31wi(E>_{cyeuVW&d8R7yrhM}7Tg#Bdy?nIVCnoEU?15qKr7RI!*fsmu! zPz@KpPLXMILZ^o%o+2QM{xs3SAFK{e>TLP&BdG!>D7@2KUkKS;)!Wj%>Ga$F^o1Vh zZ3sr;LBa{i>1!8U+rQcps6v__U@`#xhIvTI!XbGK1l- z#&e0lzmOQN7GGNpZ>Me~xjhn7`72Bm6Tg4Za@>z5(!wkBq1u6bB~b8u4}N2S## z9*TXcpF)N#ANDwOC$({XtB$oV@l=-UF(<~||MFL4DI)5B7%uDQdkm{^I9eozCF#dX zmqdO^v+vIfMO&j@tHk^9%HO$^d9alVp5Rs$XMls z{#7r#m@Z_RPLwnhwsILg+Y>oK`ii;&fj;@6qYi^p8ZFJ$&YFc$|0UI%VZxqSjX(M3 z#Se67;LHmeF>%Zptxp%6pJS%+O0GM)E`=D6Q)@ol{zOR5oAnuShHU6@>L<1|F#68lT2 zBtc+qHe%GiI?mUqBfLM6<)@Vt3!&K^`;iL&$t!0lk7PSu$)o!1H0D+eH#?EEqFYNi z?Hn!7Y^_Hz8H*i%z)S<5Rs`n$op3_o9HQowG;5mW23?Pn(BVy##27`bEkk|aiD*KG zi_Wr+K@Sp1YO&gDhC4>OCy?OD3Q>=m5`{?FKnR9L{Ztmd_0p)o{nq^SGVVFgt3}bIoPN zDm5GoL$BHE-m5lrL! z>A%X?e!j??=S$y@&f&^$)N6&L6UmWPPe@Sh5JiEs)$Nt<3oK-)o{=q+wl*Z(y^krB ztDeI|<_5@?a==fF7H_T>{jR^<3oa&jvJE5|M@Js0We z+vW(6#OU|omhTNa##ZJq%@~JPa(3>cpV6#}^hur^{sD624kl08`y5v4GM2Ib0U+?x#zu4gMjB?CQ9 zIMR_pDL`9>n!>>S?w&O}MnA_lDuo`f=M%h#zYj2h2*kn|#WIx9i1a;i&T@5qrv{sZ_`3+}i= z0llo@_80uZ!%wp6Ub`0c$5n{0^(6f8H9fpX22y* z!MK~lH!D+Dk6m|s4!wgUEJnr-6?Y*r7XDInvClPe%)wuZ1Z^ptnF%XeSy|CG%6js| zk9D@sFuh`S*^FP@R%Y6=xqcn>*J{P{ zKYf`KT8?u75_oDORrdrGPE!JIrnTZ1JQpd1OL}20z@dpsDgxy@=RcZ|5}Xt zAB*i}gTE0iU5yy6Hw>)k|)$*+dmA}l9X}Zxn zKa(-!YyMu?7#Sk#lGJ=uNEi-PWWfDIa+t$W1n1j03do{2Uo+=sE+vc)X^rF5FEQs~ zV=Ylk)vwIX+D~=xu~1`48Q=Gjpe=emTq?wnan)1r5g(T#5Hk1m-9Q_kR zx=0L;4{0Fm4r(s!c2^`UR^0J>A$Tff?UwOui(!T4bE6oF6@hllO!Pm$Y78SZGYBIF z3B&oC)3Z1@IxxP#o@ZQ~5b(^QYd;+b2hU!#-;4_;)`y+SI%E&%Zb&bIXDbb3FAa= zs1sDn*vkxa)5n*J&lS={M_Qcz(5aTs@tAkhdBcd|@j}?9{KOkHG%td6ddKX6px_re zc{xk^Zme_POLS`#DQ?ffPU^aP%-Hm4(=cP*QbF&p{MB*~LK9aZU**!gl-zAbekDha z60W*HoU}KlC!cajcN(SV3}XwjPkL9|7&qef9NaYq70{D;c@t)UhlfYcVe7N8Zn*ks z-k4D07>+~GXZ<5gmnKPG;&P}6hp}Z{WNp_Zd<{|VNuNQ7%>*J!n+`o;URA&dpantI2qwo*T3wPW@fqgzV3 zt;yfCN(_Q8LKx7#0SiqJ2D(4zU+B$`pNq)uCylqFAttnsn1AEJ&!8MW1)76~lW|9zM+HA{&qIE@L&|>Z zZ0<~RQ++ggNQ5Wt|K0rk7TSYV6L1J;DD~rhCH3a}1nX~FEk#ViM03;7%ohWxh>38f``FbaEz;P#;BSDeU8#BwuLb8=1)UfH zCD`Jbn!^Pn)7c0IX=o@FUZGnArjT?O8rS%@dIY}s;fR>NjmJhW3jpoUZNlu-6dog3 zeQ(cSO4NGMIL-LOenyBX`EomI_yp2PRG~KdC2DT_Qg{(1+nT0i)YU(%6Fhp2M0A$? zmOZpj>!0)f6tYJ5gM|j9MWw_!x)L{O9@DNb;D_UMqCg$pg76L5aHO5sm9I`_CUL%^ zRH4rE40yvEZlNDeuMsT7)m@=YldpPsczF9l1GpTlVfr@3X!IK_Nrc9|Le#xA$56Gi zh~vk%Oa9*z$mId@)iD;D7g)as;##kujhknButS~Vw~54}pRm|Vd55kA0!}CT9)EOa zFwOkv#`m=KXo=Jh5ET{uSynWaA^l_B0RqNS;v8GF7du0#I8aQG}Cd1iHbhmyH39=xb*AX3nYj8?3 zU3&x*XKiG}rge`z@^J++xW64fnD~>3JQIapUnu+j1H}E+nR*be`!-kH~Pl&oz|@ z7rhD+)1OTR6*BcA{tY+GHc*GxRIfMASzZkF_76*Rce6t$@}%?f@;0V(GvafET*T98 z6NhsMt@#i2m`Ms6`UWMiIq-xz3UppQC!&=`(QSjf1ntnB=xPKb$BcWTR9Y398iunO z7d`M1Dw>i^EG4WyHi%S&+h67?S?C27^K|Ch@ESpG+u&(x5PqfF`s~xu_=!T1FDoLp z`gb68ITMiTtj19~ULo`cE3V5r>0@H-LOV(t#s?Wkq!*^-++OHsh2h z>(9!WZ@g5WQ>339*7;Z&YiP5X7e+_*Pn<*rZ9$ZD(C~wi(x0v^3*7ITDF+VqUdQfL zsA^|_Tw9-+Nd+Y$#BT@67hH{iqzf)ain*in?IRr~ zs^>(tb3~}qCv%LRcOhhDg$P%gB12}>LCKxVJB$AcC3sjaN{ZBk^O51gjP;t#3HQLC zhUhg0CzkjK(U6CH&crLN=eM4he0cbKb8~YtFE>F`v8FrCfJJ;$^*NLKF~wtTR(6K9 z@ULmg7(-@dC-EOC(oZC|?}^eaTJ{&`GQ|v}303K^TXWb;JdvgGr6VO11~m}zz21DT zRG)AvhyzlN=*X$nM}Roa+D=4cQ{+;*X8J7llQZeNq4@?#?1)T(W?gB8dbdNZy+LRi z`Q8uv)=Tb3VYfhA`c*3J9LVhn?HdS$>(IeT&|v~cGxc>%hL7c7V&-J*;qQ?(0ZtyK zCHY^u(Dc8kc&`1`d@Kur8y8ZJbJ4&2+e-QA)N8oOH~Ga_DVf6vUsIP%7{zQyq`b62 zD(1UXzD;l{GtM!41JO(l$W=2iAY70v?pPfp9vucZFF3rR$4iKUD%B*$#uI0%oEK#c z5bUTLNb3=WsJ`ewE%YGt3g{%jn)WUS4J|;j*+4I zLUK|J)O<)M@*ILinnc* zfh?JXfov1TBG56z`W~nX4J=)$f^;*AZaUS*p?Cjn1T-bx7qtrTdCgSmNkq~6y^c}Z zU+$tyH@UsEzamP;tH@X#kuH+9wsjQF0jC?w|c^p#?y~G+sL0@HLs=k`yQQU zCAU5I*gzOQxsp8=~QWDsrf$b6Rsg-n4d9&KE2HrbsPcNvuQ zOm)z~+C$`l;CyNnyZA@Zab2;WCyHP-f_ADRSnA_L&FuJON)f4N3OS;X$Z}DNvA$Rx z76hoRgg7fG`L)v33`;$?7*6v-6fQBG9xXn-WKe}=IZA~Ep|M@oEt+B+k0pma7|n}? zsL~>y^-#iU#zyK}7!&G}i6e{**{)N$*#aFs&J`2enTqU9n~*DVc5ZqK zrthwdWw4;{n~~AqZ2uU|Z_F<;!Vz_s%#W6b5g&ow2JGX>Qn-bD^OZ7L#?{C6i&;ya-Jt%{_dh*;+N7GTq%~5B% zCPD~J{JgBJP)WluFlkFGx)SQVpC!fwRjbjcd5gJA7JyV%F@{t_jL#r-Z=*%1ZaMz2 zMH@WtEPv^gndvwa9WtL!bg^o-35j(%H1SgfnhmVg@!y38n5%HI4Z4h{InNZaabP@& z)n(;Xn`2$Q>Sd+Gm1VN~U6TkooNN;&3Z(OI$n0dgvxw;xvIH zNer=7e^So40iWx%I<>4VsTpd=?^Ou85?7uVy(Yo;(4u5cC4L~Dv3g?Wr1itcd}~ zbjzQK|9C9_;=GdlBTL)XxEv)7QrIO$J!dBWsjvtYU3Pn^A@_mA{Aa*VfB#5LJ>_aG zbBMH=cb$Jl^DiaQymRKw_4TWPp<~yDnhxv7-mEpix9~A|B@#^%H2p!LE?iG_cG;=e z<26A7o{#O1rb|5uJw2)@-5s2zUzAoT@t9pdH!XdvX$iKjyR$LBO$3I`COWLW;sI~S ze164asNDgnTkw8NZ|>I;pm+4rWrv9-6OSF?J4r}U+JbwBxqCypc?9dux$5}K+-Xl8 zG(Z0;CT0?)dA$9)UxhdRPEP)7%tQ|h{lEC8-#-?b;RSRdCR&{Ggqlq`a zC5EyPaGaFzNeHIWMC6%7SVRrCf6g(+0gju?%2HE*dKE;i3+k-aH0%Yqfr9w!)2ZbX z*v+EEzv)62wY})4wDnBIM4?5AXy)}fMLO+UqBJ+Nkg9!6{VX3B9+Mm6svASDnZz8` z*DoJu6TE;&-v=zr|K<|^zcB|wX@!?Oa@OCj;7t$!F5;@&Q(Nb>IXmw|5wx|vF0zm-Sw zBF8p+U^d#no{2iEiT-uU3U+{@S%dXpMkmjn&{9p%$-aJ4?CepV9*Tz(Khbr`4@w)L zxH20;{S-Roxw&?{tu&^SZD)2^bdo5`b4-fU}4}N2E})KZXv{p;1>rTz3i|JWj2fVW+o@7e%w&bHhFn>n(~XAn- z!fn!O3k&S;E0MK=7pf-^d)B)B4QS#^6Hxg+rH%^6_wjze6G?v>1Ez2HYN}W9?;`by zSRpNE3Z5sT4yX2?FR0&LkYw)jX7q!iqsqEc@B|n7KGn~BFtB~7!1k!K*2DaKbqfRq2;R5x}+NPnICFpV~cr{tvxLAK!hL`TO{{9Q8to>En>f4 zVjNZ0KUW{CA?rW~kk>cF~o zkyzr#reS@ij_45b55p8*pNt^xW>)!;xh_+Cs5rkaGjwRXa!5FMNws{NqPlMizoc|? zT;luaX1ui3%6P_41~gT=hBrSr-uszKVbc_x&KLv zp8!C_Vpr~y;V<|fl+c?ig9CUg$@IjJlhiCx0Ss@7E*YY4lk^Trj1Nu!VMsE6m=t{( zR20+C7}%Q>aX+dUp8V}n;x~(n^U>j>3bw7U8}a$j1gbphY8oG$%PvvoetE5(1J$?h+c240ntpg#TeYK9z}l^u4;7NYK3|CamsWyNA`Z7^W~f z6yu5YZB%1cJt*n-9dhM1_g&i_cIK-0t%4sOzEM!b&>x|8glRhLd9&o)^#|2Y}1)y)dPzj}>% zfi*qH)z$(RNbA=ZFGW^Ay(#n?fV@|QPN$`oM{fV&?{a4@uk6I)T|k#V@y8CS!lR>m z?et@hBzEhTh1XxX23r{Z2&|=DLPnW^DBZ|JbjO0BXU#cmXC;b&s=|G z$vORLJnB|w?~JxU#r^E|&4Rp-*y-A#-v*3`iUBEV`3pF*K*ATrJ)+AbV6{07?~clr zCFz|hyXATNeEOU~lylv4J=#YVwiAUuy{~>xTUhY?f%;Aa@VJ(cv{$T$UUkW%0jka& zBL6VN!Z;{a#UrBj@a{V3bR3Wrq*3a#((0Ft-Y%(ybj7=5pR`Oy6D6G2YQ^5%BvN7F zd1xegKKV+pW+*7e-iAdn>{X~X+}Bb+8>!D!c!D{4uU2NPr@uctX<`GD7jLCg{}y!` z$Lg8jv`fu;NKKoG=$E+8L!Yr|J5B|z3cN;D@=DZ$M3PC`z7i&*?YXB^A2Sv({){2* zCE3q84HgaOh8f}(RL9SR%>jzV6l>OTet-8<`*}=(z3*>@#gpm)8HSdG;B z6^%{s^l>$y8_<$iRB$$v_bI2BDuu&V6G+R6nc2yjKUU(A*ZH>WyM^DRzC@F)-y3k8 zSs_D>H({bDR(XP~=@WT(KF3iss;@}u{urKfFP>qn4{TYp@nqHgMmi_hy5w(Ku*<2X zO<^9=N+WlvHG|%JQep=cYdLmjc<@@6TZn10!n$;qReKQ9dyu9}jLmMZ^t;J-x6y-< zL%jC|OK!0))(51%7j!$y`cET@*$MTU=g!9z4M^bzbL45i;IEUlB>i-B_vN~P{_+U} z@Sfnzt)N44SHAY3^OL9ty~7J^gTS|4!cQY zSb1CLZ?gVt#Y=wdueT_eaJ-DBnb|&X)e96W*2@Josg?G)o`GOGYETD0SL@Vz+ZFY9 zOmi|%m92c)J=2gg{K@YEFFlz)?)q)4_x!_HF8qhl3h{JlISk$Cc|ptK^5YPT{psI3 zuQdJ#@4P}2o&nmc8ZyVuc|@r97q$gzVau=tX6J5X2kR#N&fNw@7aBr7ESb7;rQKj^ zDkaryl?2cT<=7ITzA4(+mnogIp;qC3>w(0&d0K#f9=$p0FWhy7z^3U_BZgAesBqx= zAaR6`;M}xV1?`z|(FcyCyYx9LNy+wc3!7FFMz;2X1-nv{^7r+jU#ld)R?(I&>_61F zzl4swgpNHYdXqfb6RcWr1$5>jMWUM96K%LiZVgD3$`x}?-nMh$cS|e=SDi^R#9}5* zZ)Xm^1H{WQ#??3ML&~=f<=)s@0rFgXoolg^OuoHvCV*CkEP~7a!UPC3voC2E1B)HP zLb*(OjA1?Tm*R5CTlH@H018GFdxh^m4BbV(tKydWocI<>(AW9`W0S5nF&ICLvRZhQ za}|WH`F%+ey+?_4 zdB#$o(}Ui{KC}?Cg|S3`kYyEX{v5mbn^I7kd~6);O`LCbH`MoZU*aH~GeWq_=~=yY zwOR}un5c@4j`<#*KsDaCuC-ucus)gSyAsn2F~}uGRzGBiM~=}?4=J#oAS)t`g3m#Zjs~)sh z!fTh5K0Us#^9Wc2nT#f&PSI~Cj^yE8Bn_mfN)$&%G@+}7P0I8(;=$+GDQF&Ies>^I z8LH3vSaT(XKhn2iPoAfhQBRy=Q48lQ{wAhpOx*3&laF}AKaW|B1g9go}h+? zpyLu-ixfcQ9x&w_3%93k0kUQs+u8NcoBFMoSxLQUGs=GFu8wZagrp`z!sH$& zw!VZ{JuS_g53qor20r_5RKb`J^)uvvXKvgNtUDA(9`zOr`q7ehMxLx5LoD#zgFA(7 znBjw19(|tK4QIm|QRSg?KU3nTPIdcK`qrV%z%QZA&$Td53pH)^1B*ErLPSv?#Jklx zAd;n1=m8ShxotN}+N+*;htZw)a~9x7ya^n;X@PpjU^~y_`1(_SIScZf;|Y~@=L6OT zx#WnmT>j=IUzZ_FdWg?T{fU!n?r~y*?Vbqdov_dpuMa})TYHAAXh$H`)RHnjin`7T z#_BmNZ45DrHJ`Z@wN`Rh{<6pM4b^HX<1N%@>0+y?!BQDm>9Qv;La$A{ZL4`A8n{fd zf@K*8uC|=Rel&7mu3)j};QDbzDwkiB;65OoIbO%T-T9J^Qx~&BiC{~}$|1vp5O?3q z+J(J1W6MNlyp~_k4mnxFdctO)t)wxq1#Fy7e#{>X!x5Rr`<$WscoxcPKL}&-kDa&| zy2FaOL`L{6_Dodk7+Z^lWgo}np%I1~-Uxw(o_DVN9ir(5Biel(GVcki z1eVvalm7OKWp-ox0>VgqlVEYrZTJ$RjNQ3kY6^3JSc#exzP&0`n_p!bDm|g}BBxk0 z^Z!|*N%oG7Ix{$$S}_2{sB=k?xuSaRmb0-wLVGDCddMS41^s;_=%$AIuJd(hbJLRwn?d*bc#FU%^ntT52wwmT z&yWs%or|4ZPm)fOZtr*|xnz4WN0Uqkn$WjSz*6r;UbDmfo=4>j57Txj!i74+#L9!{ zj*=o}g!T3xhCi2d+#xSEQUv3WtEuRG{xGAQ$iBe};VA((cLp-uS< z&xfgol3k+IGZmIC;g0p->_gk37<$@0n(-jVJ!f1}Ss1!>p>+)%jK9!tb`$5P3|3?X z*gDkp9wmFV)Ex)@5`jedr$vc&N(zv2&?ThN8ea-2f=PZ;NhcN7z32r)7n~(((2e)0 zS6XjJ#RC0LPRioL(DOh;1?zQK6;Z*kh1_&$( zOq{Ji8^T3)$yaFXcjMteA1`N`R_#V(@ex8;0<ow(}p;uwQ8MzL0VFlZ)#5(g!a2?LEFx!*xkEB9eYd~QhxFKx~qTlK+zUx>s8W(rZ^k;$^m2KVL0E|r;+oQ9^R zty;YR8{2`ZDfOJ1NnwShrNE8t>>}# z1bI&iy)ty#aaaG|;eDIB;G6;_`q6l2X1}mCkW!OTXJi=K2lDmXjW#6*jSvE{x$I9! z7bOEmdQd&qxr`|%CZZ|O3rRfrRBZ%}qjh0BinM!uU zQW?}-*U*a6nP7DB3Yg$f35EYiGfFWwp*?3M)Byg>dzeBes848aFf2UTSWVuJ#~Ur{ zP8X*vu4H<>$MhgTYig>=2<_AANU8g6;YbqNDEN+twjR3^kUp4O!{_+bQ#h*iABI&f zgR)p#S;_^Vx#2SDph`Z#lk0o%N(>{xYP44DDxBUb`3pDp0Q!VzFYL`UqJBi=co-$i zzw$VvgwEzrH`@rv*w7>0T1I_o>?5A2I9FcZW-4}bQ3H0ZCy;vfcbw^#CYg-1NyX_8V_PW`@q zxK@(rTFqXy**Sdgw-~icC+G~x=d`=tWY3>$7ArmRoz1&8GMtTiedMdZ^)}@0wUCCz zhDjOuKMc-2m5P%J>^R&mG3XB#PDZz|X!C7kSKo<{xRrtW4dR&t<neSk*yJ^yi#IKG3nei#AjC&Cu%x-r?;l1F z{I&ljbvB5PP|k>cRO^Q4>Pl)W>SyuWGG zmZb4Yp9GYrFZCL-g6e1<`h5>Sa$CJL5z4(u*jTX0tdB>NmnO%l>RIIYOS~Dgrl5?a zlH_mN$4-A%^g5dULTJcTUJ#||?$*T-zutZ#{;f~vAU8n6?<&Za^rO)CZ0qM=g6+z0QB0x6kw(AV`H z)EdglWZ@<6rY(#q8O|=e?@Ar1^`rwGI;|_5XAD^A@K1KCU1ghK3l{pT86~`8qTi>H zXcbFL9z7TsANWDbkvx!Se%-kXi64RPCie2W%AoF6AR0K!BVMH_Cc1G3D%3s5!@!Ec3i&6EB{0AGf_AurM9iBxhDV zxQFlU(nR9?Nn+CEH)990t$1Zok|?F+Kz-GifH;zQvOD{Zd=z0fI#+6Hp-EJo1uvj+ zUKLimAk}X;QNVbbX@=NL(~J?2+9i@OsP?yTi5dO_tz4r+^4OEvQ+>uoVAi9&eMohX zYYeNClmVvV@J?DWYaCKZw@B(G@$NNA5?R2mj}4XuncPrS*U- zuTOii+|q)EYvH+n;`Cky&9Tqz#@mF)*l+N;evaIYKs4tzz3d(n*;+xckIKC7j{2c) z2iyzwyGRcFxS6J0E@5>b_h~1}F|LVw+zS-(+Co^WH5Km&R!e%$-(%x`F#3bPS z4};Rk;8=iv=|&nZcT*aBmEQ4O9t`4Z)rM7N|R|5Mz@7Z3kztRMw|RkTeC%I zIVYrB7v+=Q(1nlhngf#6mzmdXqD)%4<%(pk+$_IBQZwlmp7U5z{bJp=isnFyF_t*z z$B|mS&}L6@GQO5#t-@Oii&5^^-1!8jyh!eUsJ-5i3|G>~&_GjS=X2kVQZ(1_pdI5S z6rRQ}U(i`;qL6}zwy3)aPaPeb8b z6c=1*JHfsX3#)fE{U8}-^5CX9Xg01QPRbJBKk71o5Al7e0L zvef!Tyy2wfueyn=yh+YHCBbSY3BS`4$0RB|Aq%o{w<1@rCRb7b(+9&;89C8j?1l#R z5P7gYCq27i(B_?C1Hrr?{ODw8i5E!7kJ~}fr)21Nfg&0JyX8w)$rr=USkCOZzjZXb zNfn#{=?kF)Tpkg0>2adT#h|MKMh8l3Gor6`+ky+TQ58znS7~sd9?jE48yDm-Yki8< zF#1NlKcX0Wfkfs+*wzw0V|`pu&r)GZe?mtPQN+FvG1omG5m^AUy~q9B7B^hOb0~$? z+@3NI>{$Qu<%i0dz^HLbc)OXGi1|d5ww>b8)CdHJL8AVM=F3V!CSse9TxpEka`V}B zIdlL?pQn(J#}fZdSF7sda*{f^va^wv@+J)*cSYE`3FK(W&Sou85;Fb?!FYtP8V4(& z6vLQY|8ayGl7lDLTICqoC~kyD(HV?IB~i)ALHq{x+Drg1oAK0xq`MiXJSBI9mL{G!x-Nu@hJ)7& zTGR77IoT4M(w8Araxt}vxp_I zJL(6PmqxdnR7=Sj-gRmt+I3s$L3^_z!H<4Ruf0O>CG>66RUwzMPWcOvx#}ajie{1C z=V&s*GI5MZXNbtNNzI!wn)`vX*fJBu`zk*qz@1QO?hwd%t%xW^QDDG^2mu=!dqmrk6J?w1@w zo!XYWFp-x$-(QL69B@K6l^wDEq^B3gD-IR1Q5{lSr7UOW?s;RGHJ{KiEJamXs>n** zz8IuZd%}1|#4SW23dO8?OKzaFIU9_7KRVGEuO}SKx%7N&S-)$~TC7+urq8xKZ=XIt zt}t=55!uK8ESL(5^bK@in=(>9K=pA#o<}0pJwm3-vyE^zA*ejwH*pWoXm)uqH8RG6 zC8$XaJ`Au>#kDJ|J&7aNl?*PdA}&|LK}~BxlwX;~4I$ zT@>|Ee5`U_avv9PyAKCn3bcsPSbwPzoV+%7#Q1psI6nCxV|XU=L-1_+OfF0M@0(KF z_jh}<4)B`y$UcgWdy{_{v|_4Yy+Fr+6YQ6^exI80m%=zI&;;v=y|01O{G$6SvS}Y8Fs@ty6mqUJqsW?#(wjM*&(8Ma)UK1~jwU z4dp={gKvZpw*@cLT+pEklOyzxmNVN9f3?h7TuCQ~kUus1q$>{|h4`@TTW|ua7>=qZ z7!jVoDBAOUDmoubwTM}ukyLDLy=kruhxQc8iPaXfo)J}LTseYoz?-a6nrJjT3^wf4^US_`mJa2 z9azV{>fLN>-Gfr)dp52JLoo2ZF*{bUNA7}hTi&M9jcI}_SK|2XdV9BWh1*w2Ul?MI zeDc%mrxo@~SjnXxn34=KV%4&o%ONA)P4_xMHY9({L74qyNP(@g2SoBLBdd1%@*1Vl zQE=|GEE<*UWzw)@Mp3xKJwW5n_pPM!0^fCz_N@U!tF!)F|n{c7HMOxnYBGRpd;__h%gism{`mQ~_*`pu)elwO7M$apu3%JvVkdy#BL#^OXsqfWCZ z`AdhK|Ba=a154(k2ng zNo4cfY-~iFg-5C-pApH>xF+TT-=4q?f2c7Rhsp_fzDK`%NozgQSkCyR*H$~IPp51n z*2a74)1%cm{R5iJg%m$r7y6qap%WcyKACPhBtjmR{`4+GtojFW1K&3g*D2bgXumyu<)5M&8M*tkDCycHU zuaj3>CF+XNH1)q8?UPnarK*nb<~qJ}OMP^f<2x)8?o3_;?PE`Sa%knb=HgG6L)JKsD=gl4q% zaZXJ-7tFv|2Ua0dSKS!`+?@;Wr2}(0`z>3W{dozOAflb~V4(L%9M->#$VTYAU0bQ1T$z_Xqa z;pQ!Pf`14q088Y=)pPg#ME%Ucpe7SMfL6(WH)Xi8QE9#WCWXWE@j+E#Gnz0`n6_ zH|=@Pb}JLKzdOO!x@PXXSk$ZJFh4M}L3G1|J4YTGV{nL~yEC^Q%MsGe9AbdVhsG4y zOB(fLarzDVl5}Wo5`tRRKM$`XEy+b3Ns68Sj#}cwN#H|??wDgzc)D7-ZeqgvF`~^* z>OLlXaC3%})mOOZ_4 zImUQV`x+EnJ^jW_!&+#2?a%!2j`F8xPw3xAzN6PF8G1gAtkge_q30~`3Rvs*0Czxd zP#CrS=JX_t{oRPc-TJJ0r}b{@R0YM#5dF|3#Bboag`%OM1I5}`tCDU+00*JQJK)Duh`G`D$)vsVBxwEE*|dfc9OvJhXo;eogEHkaiw+j;)t3DutcU$?WK z4q^eHT@i6blCQby;YhUro*ie!kgSC~FaZH$XkGNIfHz5Fyaf}Fg&FO=cBi0#Gu_*0 zkwv*=JpZ{ufoHMsjAJxb@(>Z;-j<=p^fSSo)LqB)YB^|99lmJYH>_@{w~;mPHZ*WI zpT_PQC#yG-$d}f;7+kOduig+`6@W_t%q(TCKycgwR%ul)>w@7%)usph$*X<07Gxl( zWxT#)02wMC`n(viM4Ic+Eamq%-mdNn177wA225YIF3QOHqyv1zFMEKN>U`jSwdzbr zegmafLcYvIG>SKhaW3oANYv1*;KG8J z@J_=^Fpj9?!eh*Tqzxac%#KTLh}+e0=Pa>l9uheM7hk?c*aZ^ks%Jj*SJtjS4T4cx z%X1_Ll}IK~>EB73*3++%bLUGHoQU{a!!vhc2tBH-!P3EkHind&6Dv_BOxH1kHV8Ke zIEg&m+)`DthQ$&2@hkEBJs(Lsy4>#T+?x75*QWD)a9|%*`icI)VoczNO8t2I*)Q*x zs`C~PaNq3{en{k2efqd90bGFL&0%@QF;tsb=INEdE!3HEs#qeT}AAs-sUcfQQ(^#6Xd)ku|j#p3NW;1 zk6r(vPCrTu-L(;W&=d>dHIS$jks?x<>@u7)dq-br^7@yZn}xxlJ|!M&jwq(En8YlV z#zykOY<+^2xqNux4^!S-5S$!ELx;h_7B2hklcUm*Xn>e~gLczow^Upa@+-$!4mVgx z^6IO$RfK$z10fYLh)wEoy=Rl4nu{dSCBP_T- zA10%pxwr92qGtCS;;mWY!HNX8{#}q`>8d+a(mhFIK zSy=$Hp&6-LxzN@4!rR=v;nOk9L6i_O`@nurT;)zKCrB*2cv)+^2$Y;1S^W)m4_;k)|-oy(Thqok2QhW8^!G;23}L`eJ@Ze2$u zUx7HiTYx0uqcd1=k3sp;xxp0Us;~r*R%t3$0ek9+KS78ZQBZN-ea`NPyDsJVr+^w` zr-0PJ;5Y3t#}_zld{B%P{m^4R);V@{EyW>RH9r#Z8uYdQO2!e;b(LObE-X>(wqx2U z6|2c*kTpwm^Ik#^_P2c9O`9|A*|+5KO95|-4vFj|H)>6hqm$%Z+Ifi#**$k+I}yda z7r@wD5$;T2h?=%gT;Y>|KvJHo1bBOXHlv-}+<~IHHEy5Qi(oo5g!nJ4!TS&A2kF>o z2i?TqUI<*5qV+X%bt4G4{aJLk*^Lks0UIozS+=DdmHnQeHWNtgD`YrLkv&-5KYHASE zYW?y+MeU|>BV|tF@)Kfy@>u@@q+uJbj0%UFwJ2U&h?2qK=M!Pn3(!IKKMWEiB*BxT zl3WsXUFc-Wk*V7|^qmjK+kgXd3)=o`&~|KS?PjP-wMP&T3q366J|g#=`-Eh3knkAt ztXs!mHhb?&PN~s0r73|4*q1V+TgGdq7_^)LqQ31XQ2tCbi*O>EM&k|C5b-{xnyT`q zspC@7k0CSYBz~EaKQRChI*E43)iQrKCxE9=9h)7HIr>0NI!TbO|8=uKIEJ#$lnr2J zeh1FtWLb$+*U1s^H`~%mo#(Z{SnW6K!~DYvrmR7#aJ074O?eciET5psg)^e zY+CF^YVH1dmJA`p{+M$Hs+(V&*A&4PN&C$5InoE>ql4of3ib<8#M4Hy1&cN*A6idlqUAJ(*OEQaI+ZP+Vf1W9oi!zBdU|>%|{hr8-&`nSp zHrk<_d5)1@N&hf2x$J0IpS0XjatAPIxrfi>y-90zMCqn9wlpA7_-apC)id{f53J^H zu*ZQLWpNu#u5z68I%k(aMHj68G~slZ@|z9fzTi7_N2x$^&sc2q?HO*DOZD)w#q&vX z$MAFx_lr_VFyuSm+fYWqd6t4}Fe=L3ncJz(A9ii_E|x5FBsA-VkV5OTD`4>Fy&(9P zk=C1x-{k!PUU#v-cZzT2 z^5oO7FMIB!KfCu?xyqH7G5x~R{esKVghh_K%*H-p#Q#BdkPOf@weOh!qVRv4(zH=% zY#twaR`==P?^M|@Fco@;@kKRhA4>o5NW7QsS^QMZ>jf~klEGm1f#^rJB8}^mub9rY zUTO|FlfqPzI&fd1(h5SSKBY;g-qg3n!6YVL2Na_(xQRUKADNgrau&z;qQkVf^-UyA z)eHIostF2r*u-(pGP8n)`gE0OPSJ`$uK`M$-wTlP69vu_K4DOcPO?V3dO$g;YZUi) z;F+1mP~1DxUnfLztQ5C4-7zaxNkI$EVu=#@UkeI7XIHmeYHmjZF!zW?UA{}I|Ixx^ zpMr;2u8%RE5o3!MrUkuc2vltEDm;?G+S>)x8=wgoKzQeFQUlq1VT50J;*frtsgC`CPb2ap0qi?cL_+4g!x?6@NHB1h0;J}>KDBS^8$3@ZNg-E&zPa-Rz z(d|xMlsx$@d5(?u0XBELKhmOI$RMoZMg%KSNj<-<#sKL=G%6N@^VaA@K}uy4?tdND zQ{(uG$wUE-jVq_&G#14@Zww!36FUFYmhSEV{CKnv-TeyBrj&q1{5WK9zm7XD5YMId z5lX_i)%VCmSqh{_uwi7}jv214q;qG2tpGDz5&cgmCjp=_r znMY1`Mo!-qdaY0(jDvZiX4q+(E4L4aN z#o9g0ii0pQp3e79yXj-jqT%FUwxYitHzX!+(fwP3(oA^Mg*SkqVyh!U?iKAn22uq9 zKQao(SBsJHkw&u!RJ+ndN48Q@VX$h9@7-Z$Y>4T1nw$?V87%H-XR}@*`v6J3qtd@~ zIoHJU8oCIWPZ6K49K)WE`Q*4j*{gf6)eC zVeaPiKbF;g1H+bYjyTqLhwIM-$(m2rXi;*chUM1j=~IcO{U5fDlyli$Jyv|?flAH? zq_x8mv&C(s-kgc|Q@E(6Q4%*F(tAcrZeeR#dn3o|3(F?=yI9hz3GV z$;ONP`0O)oH;8eT{F1_AdRS}6((ZGFWe6;((*Q=?enM?TU?r85?Dc-%_E8*|-EXF< zwfZ(J1(V}74sk4hY7EC_J`M@bPtqh;tNIkP`~ZoL(5u2ew$NRz#FcJAnqFz-{(BTF z?sws?=F^5h#g$A&*;3Us`2#qVA$-46TvgoCjd7;awgquSlB;jEP~7HGGw&Auz!TE? zY8N`Y7M96)nz#=M{6h&^qv{#~r1b2$>?C|AHi+ZMSyF7~Y995ya$9`lsL<28J-Xj@ z2c++DLB?t2_S+3B*9rQ#Yzf}^=G-n>V?Iwv^@$euKGUQ~mJ0QrFU(Peel#&m*5Wur zXz=|w%{B^jwQ-3Z0!uqO<|m`41<=V z8bwDO#8_|3di8oFs<}+bKa1XdAAhHOQ2gRU+iynq4|2qTIyarV$?0iz{l#|W4f-Ad zuE~mjepb=YNoE#pHmx_*un}NV&__yfg8Ab1u?DarFtI=99J@PR=L^w1)pgG-6(=JX zp*kZI(!eW`?3sxvfJJgL9k|;$zSlHVd>A>UDkf+5AGs^x+G3bV%SE_oV>tWAG&f-orDIMkXg08D%5x=gHDg6 zBR~i}nTF++p6@JH_vW0#K#}|}DUqLGhR;W4cLJg~xiej4Hvl(ux4JPG8QIoXeQ z00&mC{5Yod_itkVw38oqSG5X>N6!@!=_aW|_D8rZ<=XZ_Uj`|PHJ)x9cn@+#o3*9L zmSGB8oy316&00;@TsVyVQk#{@)t%4S?IlTQP9guhLOBN7i%r0^=lNH9nAGBX&0Mk? z?Pj=Q^x0G}02{k`F@5%%NUjcc&)$nL{^zy5QA?uX(`o^7LDNi*7+B{hQN%oe$-!Y6 z=eoF1cr&A$9yazI4Q(}-l0Yh`MsfrhVaPviCXWSHOV`!7Q!2KW#eS{m=rDhhqf0BS0_FIt1?M8uVlcaPvg4@v9ir2NUo znm??65NK?_7ltE+vc?sdA#R_c)x=+X%AIk%b?fuP{i|pH$x> zs2(ULD9!!crc}Ux0|pHhBh2G8P;BV|NImY}6m6%8wg%mGYL9i+57>mnzmpyj61M+2 zsQd?z{O4@(zZWJDz{V~n4pDc4sr`=~`OiZIkh^Kv+-WzdT`!IopQu6XEuu4Iq&u4M zJ?~9QZaS^PFlmrRNLN92Imo%9J%zGd^z#qx=@{x#zV_1NPca>5OADq1Bw;?Zj1pU< zBhw%k3da7wHD6dKQpST#heET%*QjLhp&F9siG`Od&!0;BW z1xKoA^LZ+1NeH2Ygi;ek%GOF`K}lIDuq#4sl&ykU{w-TlYCFBOepXg2AzO~#C|8!2 zhUlYFicna!2CIgqmZpJLd|Dd7i0?cSe@2>9&`QcDyNzA}aw?%D)PiNasUnC4>omSb ziMG0G=I&>Pb^6x$ED)`>!n^4r{=ZFguv0$(vBP@?biSSqE*xv?t)5Gueq z%aVMqQ+XO?a2{p+`AQWxg0<|1yzB;V?TW#NFKNi3;sa~UJ^7G*X4yclL*=PXg);DG zEJy|4l`x;`8pYNY!O*S+FB*0x+QPF;qTd#Mj5GHC;tUh#|8YwH1=|1OOdKMn4s%kA z`Cp*@XI%pTjQu7JbG(xs&Rkv%GtV90bQ_ut9(>Or!1tZKM{1a(d|13ulZV}&*cFgf zvd!N{!y`f|UUEh-zHuDKRo#zZ93SVg}mgXyOslk zl`nxVjJ24j?0@1Z2qASy#KpI*YX;+Np|%zmx3;n5mDXwzVy*Y=_MIPqUh(XbgbhxI zQqDHZzCC=UE-ZYAVi_b)!uJo7V7U{NR4F%Ik5#qR!8DdmYqp`m@aee{N6ZkQ(bc4n zaz**o_huW~m{(6Gk0${>lmv!{37eP6JCQYj(;T)r&Cy{wPn(*2VUk`(mUe>pS1!8C zjZ36SmC>i2>ut`nZX~u|776^%I43l{@_+5-ZHD^`Jf1u7%Y{d6R-9gmoHb!$E)bR2)lPK(A@DO zz15=MQCZ30XG(R@0(*-O}5P5>4MB;l0Q7ugPp;61XK zY^&!Q8tTrIY-xN&k2XjJc!$E(A;U@I(5wyexXs3j2kH>W3)LaPpT06F?ft}$x>6s!t`mRxDqoA z6Exj4=Gu{y^zXaBrWABk08;$9CjK-}{P{Q;HQn(iWBh*?@%@XX2{I%R(Yp7 zu77nrX3rx_b^d$vn5kT`OV0Y!X)@a#SXXZrl~@c6abN(Y?KZl&xHwbYsKq-|O#UC< z_vrWZKfLe11^WN+zL@OtHo(yA@zx33|KWXr_xYCy9KilA-gN%5E`4AHv0G5(7Tb3* z6HDmK7ZJZ-?|T?gNalbgoP1|6$BG>G;E?J}{0sk!LIO(-Mh%NU1{LQEY_;UNtqdi) z_~E`gxzn3k6Dm!KAjoq3{8v}){EzX>yu;420bI=u4;`H37G{|6t*q}rPKu5d48Ncg z;kB2*LEu&DQ_s{#Vvwj^f2cp<+@}lH|fV#Y}kHC4{?qT;K zGN=?WlMQ&%YzdX;=^m zK)RgUqoXJLQk}$M##Lkv?ltU{eX-l*)C8f@aP|#_G~o5r>Opp{X)?FBRry~7Gm)LA zZ8C$Oah#%@51?zD6j4sEl6Md(Prf}KWR^t#bVL3PRtYRU5nn=-pSZvRU8_2N2-fZpjuAMEkLAXN*}5QiGcFPD-I z{9p|n6}s(&YYn#zB~YU5l)=xBZ{sF(@-k?!*8upcLRk7LXwlC?p#|4Yes;V0Hx35O z-3~s2>nt=(ynj%MjgyQ3W=w}T3P(cR4EM&c?M+#OAXT6`g=L17{|TXn5OZ5&O(CyRURD0PvOPk%Gi>!tihPni zFP96!IsmfcvLM0tqM%lG?1Z4A-zk^6+x6*ZrZKFGA5`OECf|+w4_;Yo336dJwNmTG zTIcR>3kL@KK96@o+2FNw;nnetK@-KNs_ST8lyf9aP;=)IO?+U(;}HSm9m-~33gJh& zM|?#D%5z%i!nGYJTN`)4lw=5oHI!U4^(4W5yu^1H5?l5+jo>FRjjEC+{mt=r>KB`H znTT@z=Ltcq06~mPg1zU75^8hXb1X-vU}vFIZ9D5d_l?)7=`E;GB2|iJyB8Y$1E$^& za81ZWyaNf{f`R5UEd%W$BwwyEFxXd~;8HJQSdjKNoz~B%LL6jUN;{3vnnrn`XkJ&V z+SsL*&1eM2rL_`J6A*$a3>Eg~V%Pcwq~#sk?eo|sAODzr*!Y`HN;FCQk(pxf-58ZeVGp;Gno7d6s=B7oRY)4wu+HZMf%3cr%_S>U%-rq`4l6GF9NCqo z+}^Ll3^bN-!XRY>CXpxHR7X5K#Vl-Enxh5+1b%v%)6twbtWLDE*s;{Z*wL|1HOoZg zb&}z=e^UH7*8^O^#O}`^XN+qbVy*alc5h0yYT>A!#U2z>FGj5(5Io4UQc+6h_u~kDV8^W1A@HMbV{i z%V7e zz*9dh>=t31Y@=vll4hrk5j{*}p==g4JuL^SK-}Aut5Z9kr7c%$%n83CR{loRh;1t} z+#ikQ@nR_MiiQ|A>P2^eG>fBZZ`JH^x7MHFkJHUhlMQA^nT!r}cM!Y}^4a%nsiDLy z4ZK%b#-l1(w(7nv-9dHE+=-7tGlGF?ek6LV6zQNRcet(dWdV$d%U`MTUoq*gS3b8j zirLXQ&v zKZef2ucXmr{Dg}T%rEk&9ls7wD4g3wuUtS<-K_Pu>&_JgA60VqzjVT}f`XJi z4i*Ylo+D0fEwWkPV6vw9ZlI$v^X$e$MY)DAlH5vi>(<_a3mMP=;bzv3I};x~5pBN* zyA16u7+?gjj(M6-ETexnAcSpM+cx;+zOUV`?*a74p~s)ktzW%q5Y`h9o4#Oojxm~C ztuCk(4i&5P1;ms9@4MS69*7>~q$ok(hY1u(9x^T2}Zv3N?Lc4D9T|SMUuk@-$ zco-pM-B8WdoQr3(W>kJdwxNUa6c_n|NKL`D)r`Z0sD9iwcY;b08<_&>C>(5{n z(jl;fgZP*PvF%ywFz`D6k&>A9^X=hEnB>Ll)2m(w^}%NnhxUyRsDkf&6^#x@NI*{* zd&R-)y*kzc=^4L{0|YBfvb1Gm@B@0%r{CLF9n`p+dZs=`NfY_fw*_kl`FZ+}V(MpJ zIO5^74c?=_y!RjMZ~94k**6*O(mJxc#jV7zH|p&}*AIhpmSy6n_6zuKC^uy^VMhj} z*wHalu24u&d%dY#a;)K&RaEBsXRGZt>rb2o?55E)CO&L7dw4P1Xbp8Y3YIMh41M-q z7xnFl=x|5n5iah#gYj^7Qa9qx%+Pav)k4-Jl$7@Mch>iDYco3Ztybr3wl4E`+tOzb zjkM@HZM{$+Oy}0{*e;JQmrZ_|A&CoV(|~l;37vH?6!~WcUQlwUHceT5$|xH zO+KEH2>qsr;u>3BOFhW!wNxG{$mu8YyIngByZOc@hu$p{nK(5w8c*DS`C84n%ydN4 z(rDU`(9mtW^a7$i)vXsnzU*8>f#OcB`BQYJyXB4<&u&M4fwwCcW>40&M)=Ooo(IU-6n>C9Av%y!>*zx@Oa#IbK zZw>{mB~rzt5#Rcqrpr&ZG@ftD@N^n#$4}v0A&U_`MN9J%D_8RiNj?8!NQ_d!HkW;~ z7Wc=7fxGzI^3plT=XEX>HY91nM{oHx6F5?jRj%}}=I+0V{PO*r=~=9W*);Vs=qnT!DJb)1ow}{qH^{{5T^6*JE^j^!n{rf62F*rEi$A84yTT)p zarR_!k_st~aYh5K>lPW?ubc5aldIfJn+^_q+Am-7x+lkZR^)G?*9~{NoAZT1>Sy_y zletr@@*Q$Lks^r~UX@OlmmQD_(pQ~Fa@e~iO2%WQG#T-C&SX$ie&t___-Pv18citw z0nBn7nhoBk`I?<{e1CW%X5_?2j2wM3>j|qBq#Kcg(?&ezyBrgrxvts2dE_C(kTY`U z-t!Y1aNoUw#if{=t2>4qcU$bOzR}8a1sh0zc9%=Vs>$ z?8*}|l_=@sKXiv|8IrYjElDzUc`e4UTeduQ4m#ep>A$j*D-(;Z(ZP4Lgi(}Uq%60o zLoONjU0w21M#3xdi1qGmy5w9S+bKc5b2USW)lcO%iJOBhV)7FN$8qL!`-|6giCfly z6(>)OGd6QK=fu)m#A-CgKAWIuWgsAb`UvpuV=~WAHrVP<;V?_5o7$J{`EVq6kxDG- zYRIyQh)f|772Ovfgn6II00e2|{}X<#Z%ZaV3w?|+Tm`E=oPmy?}b;s zjNr|6Zlk7osc(jq&r=)}uvPT-rMwz4nc#E%#xVaJ*U7r%m$YOG$q4|`(Gbq0c)b|v zEr9uMnUmId?z`Do3YAA86y(1Pe?Wsi*x$H1%95YWDMBRUW1-Z)cA0TXr;1qVrz!9d(eBbxSfFr0s z0!^(?>u8{Zd_^rY_}0PX*hDVGI*>^ZWxMDX@{UJH zJQEp%=Q+j;GTzhR5n_Y><5t1nDRRCFtqr)t{od*eW~{D$QFi)Xg@r?8rEc0pg=2F( zgY4T=S~Cd=qItukq+|6mbxcA!bmN7R>nZ) znZS0#^De@>KZ>*gH<+3mNMvTOXVioD5q&ZD)Gyac zwNU->hb7#PX zqD_Z(0f*gYqkSTF;}1k3-?iGVy*zF?k^%f=&@iAX?d3BzX`kS2y_vbL_W}-Zg-@?% zD3RvRPT(Ygo_ogWPM#{-^Ok?fJqgLrnb1;dxpwd#1K-KSsowb?|Ay{O23=8l@jDG- zk|_1C9wZzT}*7<_EMApv{+GQ>&mBQCR0*M~=F7 zjdQeMOvYnKW-$w;o%rYNA(}Bl+GMn?U6Qs;znYo{@QtgOMN@TS9q%SN?8mE6RXjZL zBv+*3w1guH1Rx(8!5BoUB}#%Sv8C^4y~PY}#?ZQ8>a>@}!!slwC1o4G;=&5u7lVKK zpnV^hi6s{G82dMgtGA-Rol6^h$m0A}ID(R{a|1ZOCrzn0r>q<_dj%30HrA+TezVJh zUs>-83J5l<0y)-UU=_WtQ(G{|%6epa_x2}&6zk)EySB@5u%K_h3RkSzTKaDj$%73u zk0GntD& zBCz8MkGp3)p?g@pa?_--#XE4At(y2mN=x!g#&D8`<%(EMfGsMnqOhZlVbkQxGB(U# zN$#07UXs;rHs-l3?wkgCXywztE;9h-eXo)I3mN!BBlOtzk+kxXJex+tD?Aj7J|q4D zx%z04&HUCxgIV3BHC)~nJAgwm7OM#jN3JgX@n4G=YtJEhqn6$Uk_~*PXV|OQ+O>2K z?ZAP-XY?uY)4ao~w}Y?=c70_H?lQ$p`Hs(tnNRjt@2WFf{kCmyej1Cfu=8hVf0gFv zE3hxI_%;>7u}VozL|n{V6h7tP=^6iWb33GjWfOwcFyZ2oG->CnnqnpnF-@Y(z`KeX5=1F~TMv@Y>5p-P{^B5<{ zoX_))dGjfZpjw5%@NZ8}P)F5qP(b4y8l}E0r$nZ^MX|Nqi~*4R(s&g;0PbtTtX7vP zP$hv79lFYE<02wA1ef}N0EG^^ZtQRkr6RC`es&Y6dvzr z>*8(Hr$ry+b35+S_Cwt|>qm~i-C-+Jn{G?I1I3TV~lAvOV~O~&COVOSu|t#x&o=R;__=?yY=ViO&A=M zG7x`=qJPGzLf0O(Xv!p3GWA^SZ^5#rGcYESMQ&s)R=U7>jW_tTQ`ViV_?doO^+uhc zM*9NIzc8pYowaV_r;!lf`lOzB{KoRe{pwaVKUrUH`c#|1{yhC+ANiS7^MEs_YW{}= z9f%Ecd~rLy-{4q%?#K=@9&NZAH4#s3r=i9z=Otje1zj~Cpr#7z)L_(A5p6NSl(L6I zz-HfdgkM*-BHw+*PrjJ=qZ6&p__9qzUtN;{q_tViD1`F*+z#Dt=+MA?D1^KEMvmSg zoMUK5Gc+BZN<94| zq?9zAJ9d=!@&>`TzES7h(+|JWw}t8f$I${q^;1{IsT(yW_cb_5Z@(%I>zgLXtwCxI zeeF*Tdxi24_4jKYa5Rxh(Hy0Nz08fHoGEw6imt()jOxxl!RrI9zty^i(t{W-e`{C{ zBKfLs=Awl#GK^~liZ?LVnSZ7m{@E7&w8X*i9di;MK7!Kt#Q64^&#?Uk{<&|6SyATJ zymWS1odlJjcT(J}H-)J7IO)b0VOY;Mz~Mb`C18tbJZRHMN4u65J%yJr3O-0EWFGQc zErJC(f=AVv?MY4QG3mn}suLOYe=cEQ`~uoM+Ti9Izb3Ekj2@Q(wy8Ropg^R|5a;ZS zW&4!O8}V|N7rYyg4|+Ot50VC^WAF4tAO;#YTgue^>Mb3mVu%GX_IyH(#^QETx5(Gr zU9E&tR6WtF6o*hH^yi&aps!M0^K@7bB+xiA1RJ^ zWE5T*6&UWWC>Xx?Y(ak|Qyv&Cx3>`nalbs@+%D-iu$2u7 zgU#)Fb|_iqH8MCSbtUujnn%&yxH4(lJ+Eli&A)YxvU7q=$PSTAHkp=*d_1kmi`R7A z8q^6O`u!z_a=;^7IkeJwQbtYo7>VqI1hb(iNeW0j zvek||OFVaYgI%p1nt1?eciK;MEi5nX8e6XHatkYH^gDda zreisHhOMTl9ti!xkfD^~{NNmupb&Frt}#UIlUz;XskAs2!5`mPV0fs^vf3cHGV}xS zgc$LLNOvyacjr%H9mPUGcLKauWBX6XhJnL~>+kUi7O>JjaDrRbB>mB19K^OeDJPd- z{r#Xmy+yRfGAKb_`Ezp)tdlv~^+pzwV%Ji&BDO_mF79k<1esrDYyHPOjIPK}bF#;! zD_m6{8?;NUb9Sh_6u%}BlsbxDDYeO#u8OK;kS#^J$(*QzM!u3WuPa8kFmOViJG8}E z5_e?%Mp5^)>K&F)qk0JD&||LESDZNmx_z^_4mirUDOPrMyt7&VCn2?f5H$F2TiJAh zQcQcjULHl0?1r~$LW#aRrK#l4!=f(WN%iTT3$O((SfuFKd)a ztKomfND#kX=jD6T^9?0i@JQrg?^pS}ye|BgAc{VBu`a^GMvQxXfVvNIh(6|G8VMuN zlqS`7N=F2*_}4KtaOY|?5_$}+#4OloBR?kBuB>f>_JsDXxWH!(0h;a!#t|G-=AmNS z+xcZ(a;OsOn`E0|7J5j{{$+m%AE^Gy=HNur^40L{qaYJeLskM#@?mg2e<61BTKRzA zA?ou-iZ#WMC#6zXEORX70Xt}Agj4bmSDqSy8eDOfDIqwjFZC?}ZLWI_lr3>4V^eGZ zEs@*`hfzzz8+>xXHYp>iCfl6$0_T{n+MP|FW-qEd#JnE-U?svYi8?DhOoOghni4}p zh_}Bz5;=rxv@pCf@%9J3&j3qo-+zF%l@aC`<6(yIOGn7&Ornwfuj0JrmsB=?|1PQN z8J}s~KX}|-&Xn??$206sK)(=Pllkkb>o-`^dZ&a^pNpBJRPC$3bw4m2P2-tyy(@Sl zWPN6k$i+_{)V56pjGpYk&aE7JPG#cTvev1->$fPpQ@*DgPV~Y>aFST0VQnf!+`F=r z(eN*2^G-Mh#Fs=RJsvIiYd@(gYH!?!zgI+s{?uuar?aVm*{Fpia8^vpg&wI6jL3tE zK7bdX!M*q7S*tNwGS`NA6SFJ~Zind)4oNjxh*|xB99kq$AkrD}bo;>uB3h4|ePxUm z>EoJRtUr$pCKve+6-n#=qOaU0n~lh*QGY}1x3(jn{|CTFt1(4^vhtE|<|Hg19xxYl zbVir%kWv=DL60I-B(+s4?+1Fp7F}}lhZ*5byn7wU<%s&kk%xP-q4q(xWT99(!R*C z9kThyVzf;sUQyxVA2&Kv82DycEshL%cPoiTYXTAZI97BZO#F0 zd}F442INlG#Y?ytc`d)#2lT_nwZ=_}T72Yk9mqwcN0h8_tz<&maZGX1~a$AaU z34f6aqv<)|thnZx>dmlYtFvO+y4gDAseCO^&C)iM{J|f2o7c!NK5c9-&^Ox?9ej;k z%W&Dxrsi4)G@S}hNL~K3*Qin-c^0s`GY z^ZG6&%S1z4K>qS_Dye}-lIm8{>!mpl$sDhzN^j|cr851)`~HCQYFuV3lJ$9b%b^{PXDI(%VusLzP}*&dvATnR9LPZ#XrhOwQGZ@Lw9Ku-jD` zk3so`W2Jb@qgigJudx{qW1@=h80*uwZ=)@*%+8DMF1+tCPV90&V5N}CC1XmdVl{C# z;rbtSsq(I!j~E*|UYqf;p$T zBIekN-i00kWb9Zc$vsl?x9JbR{uGNaWVBACE9BxklpiQ7(eb|CNiCD&uheK^GIVdM zDo|dQ0&n%duRm!o%0zmnmPll=@n(u61S3yEG$>g<6KGL{ffyVl_9h!5Pb;PKvuI?6G`SQVlNK0JY4oDuo>JRtMJ%8LU2`{sXLa_;9u}Zi10aanXYP8tl1m zue;E4zD0{H#H~!IWQ!{OlXx0B8hH_y6k5Pc6s297U8n%YG0tLm-`fKDO!&yK#2%fZ zq%_?mEZk*Uq+x!Z``(8hH!N(-&Vp~H0(3ZkD?^HGL9M2>reGr!2sxzk_!NxT-y!V- zY=HzS)Eutqtv=&>%aLxbWURs2XCDKGkMMeyd||lc2^};b?G-MGgRw_!WMzc(jZ!x& zu9JQa^!#{au_7=E6fNRXC;K-$)oZEMJ~gj4sBKnb z?v8L+dt*aesp{qba0l8n?OyRqpCt1I5tB8yyI8SL?va}2R4*4O!E@gnr@3WXaUtiB zoMwTlr&l6pPFJ{=)Zh(T;{(3%a!?yxL-u=2>)%=3Q_ZnvngYv1Ysm}ITjOWJn{u^x z9MmK9&!|6DjC$pc2m_csrpX_&#Te+I2U^vrA!Da13%lryrpW}(XJ}befL^{H-K-mI zAz|Gz&LWi#k)E|*D&DlOJJUV-i2LU{cpIyJ3o(#mgiS8z&r8DbQXhKb|OaBGXyeLq!6wJiNab>dDOVLI&eixqJ0i%6$RlW!% z_bjG(cy##VA_dyf4?XV={fiy-giDYOAD$?jswHH4fV zkB-MD4qFU5{fuk+#a=?OyZawTcf^U{wlD5(tuSzi0R>7su!ez?{lZ+z=?BfmquE*> z@dXz_*li(ZtJXuprf=E9)DPz;TpdXN0eF6n@OQtFH!ZXr4bU#=_&H?JVf)@Joq@vp zN5bEC8=&fX>HJbI@>Rnh6pwrgmM1oLx*4eHtI$84-QX0RJm8!Q$fe5dKzj?9E4U22~ncU*ISnzIFS5A$baBg%Ki+8*9_H5UdD!tQ+RsK4g08nh)T$Zh%lfyq@4{iO7xD1vlq+m}v59br z+5=GiV_~_i=D@F7g@sQMUMgYS7bZ~YkewRiHSUtMrsiFo{#YjiO@F^Z@E7%is=f?{ z*fPANqU;jW@~G6I6;?xaACEY0lTljtWy+@aUOWTs@bIS7VGqr?dC2l=Y5Ced!G(qn z4^ z%J_E&6`sM{+fa7Wj7%qsoNfoIy!$fLhIsczmlGpkx1c|A@6!i=3enf^eFe?i+63%l znN`Gk3b`MO#ku z|9+G&Gs3a2(Y;ph@oDFXnt042R&4gBC4;kN4S6euPKAr2QPXVizK$K^@c{jE-)TC- z7sOq^X~}(UbYJ0(SBzloUpj85?Kf0C_+1a0mAd<2vm!QjQI&`kn}-ZFA_561KiA_xZ&4R}&A``yeLZ zkQvyN$WzqxVL(UMJ}HfZ*8WH>pEK*h+x`A%V-GUDAF#TJ*tmqA_(T%zF{9%!k%F)O z7Pa!CGAfe2@3!qM=!t7^@_guwC29Y(eL^oqx5F2IPyOXQHA4g0vh%vz_z$DkrJEZT z*KhyIXGKyWKwRYTVTSt`lng>{^CIvr5p^)V6LM2Kgf+8Px9T{bdFqH_?CaPF8aRvY z*>;00#ZSZ(fG8hyvzX*QSPF_yI4H$O_UC0~e!r{x9=1vjh%Trn@L@StGW5j?Y|d!} zvZZzdy5ADYcH3&p4f6k1%yGWDzs)|(5}SbVaLcK>Roz89en1Y9in8Rv4F`{QrpOIx zPHc?y=u%CQ#31IM+PA`BGkrGPG%h?ZO<#lW0y<5MmZNAl4~zM^B*Hc|%c5$cknS{*r9bN>y(;`-574_vP;98~q`<0yxgKyk(`j&OFPw`fdNu2XdZ?(uOK7@(lXmW zpdHNWl5S>DDy*VeigiD)sw=CapiBcW}2VNLSqY4o;^G*oVSp=ZENL> z?M{JNfozLQzh z6*p)&I$>x#%wfnx6+9mPX@LfRi>=3deMms}g0e|Ci{oHTq;EQcURWP8riPd6G2|)X}RLyj=)jZsDS-`t1 zDuLp7nKG4sm01~;?6Y)wG8ffdNb8J>X?*(ofIyIBPQZvB^#m<~D;xp{e04F(v=+=5 z-Tp&S;%_M5PIhG_-sh#y$pnN!qs3!xMfAY2y~qD5 zVem;TF|Na2G?maq6zKtdy!bPi)FJTbC86M4rak18BFwKa{Ys7zQV}N%t&0csO{4H( zzUW{kGcu>6gPOn~gYo7=eLC9KtT;Z46|4+{eFx|8m(Fs}Db-J^fw=fcQcM0}l>~hk zqQx!_s8vuc$F}}MRx84xruWzOh`&Z;ga_+eWd=Q+R;{;@;XWmFid`6k`2sJ#h{FRWL z1Glvl)40Pn5?V~(#pXJ{Tx7Oz4Mc}hXK`oZVzkU)dPWd?v|AM{Cr?;Ql9AYCfqxX( z6N&1JU=RsRce3ZBZF?qK2g(AL)KE^v#uP!l?Gc<^!4pH&bZJ-xuP(gI6WapT>j`>P zzY!L~o<@D2iAM#?y18|DhND4Q>yR*1!!tsq!_S*piM7t)MgK|3BCMpwt`m*OBvl&? z=_1)qpwS@J$_7ld>nxNJXlYoO&U6Hog=W*5=syc6M`28|r4c~ON>li0 zbc$G%yV)GO2b)+oKJm#$K5t*aCuj_vkG0QWv5UAQp+JBf9v)g34!aLNdi{YsO_?Bw zT9{UN3`@b0Fw2;@(?hnCKr_6EJCWQG!sf%3 zsnZsBSc%{ifB?%&67Av+3x&&S9MF|4iV_(?H~9zxnzqtdPr11;ma(Xw*c>kqg75rF2s=36UZj;$S`kmsA<`6o~UinzmKtIF2p;)K3r=B&$WSETiq`n7$9+ zlbs0sXxb}IhD)^sDMoy0GK`y_ORj}k!|W>dIh&qOCbA#1D;Ns>s4;KIGH>;n8isk{ zey>0+XU#9zh}X2*Ct3@1MkHhU!e^*yPcNTYe2c|>jFXU!%#iy0~ynLea*T~3ZT zgpb8~rh_!=5&Rh_zm2zr2Kbg&F?cL3C{(Kx> zQ4D8iRX%B7`9o@no?m! zuW=fK;p9DB3img#)cGV0E^vYvFp%V{F|-t=lOk@lH5J!DOfxi>Ep?k8?sbK$%OaRf z8RJ>z1K+%cu<5jWA`&j6WXI~d@l)rstWs%P{E{A?^BOSSZopJ>SLhkp_2J2NEo#`l zVQA-?;=oa+9^H_eG`F`=Mx^%hw_EuLnz#|}YoV21?cNS7_^C(Js0x zY-a2geR0Vz!X(O>b$)7MzBau-shP<2fF*OPPUN#I-&6UOlSi5X)m%RSb7C@O@f(zd{#MdmZQuUu)F{9W} zdKgfPL|2;exGoBtGsjo+Vb-^ZIlyQmO2RLnEoESI5P&2KNYZYzk$gK+o~cwrq*xa5 zQ`c)j)U}C-fn1;Ddr}&KEW)Q!hEc#iVGNh9+X&G=xo_xRjpP=gBu1o|KEQ`>2ia!; z$V`FoU6<0foGYEyagjKiIic!y7|^~_`O!0da?%5UNJ+}pwJszK4iqIZCcldwWNPQ8 zV?sLF*0P%eTz+s9jqUlXGR64bhz_nE(lDiBXFgH;Bx&(CE>M4T`PQ)|L$p0SNE#@Y z>{n##5^qZG9I+|HvAkucE=#P1$PLouR?2YOtQ@Ix>U-q-(no2(@LNL2>mNl>%r;OM zTZL4}?3#|{klu-}{UW55V?TK+1ZN?^N+s7UTm;YO163BMX7Ev}eA^byPKXD>!s?Neya(;Jfrec?>t(C++NKm;{wvSXF=^-0qPwQ2@EvH>8SxLUSF*Y%cbFjFRgX{Y&aa6=Y z%gFY^Ru|rJHc#)FRZy47M~t~UtdYGO|Ah`8ZXBY^rt_1*uqw?Uqx_+_qiIWGoq-9x zJ->_h&EB4yx;-6KNUl@@ibIf&RskL;{c{&@GcqLobUm@Zkb#2nR!XCru+TjSwLqt} zg6kI!%N;ltGoib}f*vCK(wGjOj7R3op7GKDX!zizuG}$AxZ$2o)ANlHsdzED2MiVs zvOu;v)4(?ppj{6tla1VUy2ra@(d>{o*EqL7x)%EX0Z!uryoQ4B+?z&8>-lTTjn4F8 z_CQyY;;^cHFjH`Z0cnng)NJHf1`CV=Ave3yWnR}I=xcqL8|iLKB=yg-u+K7P5 zK#g(WUUtTRY*2@;K)fz9M0x zNAS19#yEf@IUnEEt!p06oxG6($Mtd=dS;DrnRe`&J9_SHw#V_eKJMT&mF(`lc}rW= z2V3aCv8-I1b6m5h$d7Lw4wN5dH+22V14^-bN<;9LV- z_+wh|hou5RRW^#aW@VonZ($k8cP}zoGo3%4VE;EFi3unNge~_d!ogLKi$f-+(kvmf zhcjZIs@xnMsFBiYH3_sL1b(EUU*LAA@KDKtl2qHKo}HEvbzp4&5JNWGCOd#%l5yXN zr^aNyvT{o$Ev8qv_#e;Lnc}|R`h6MdX{ivv3wWY0oSC~wpr#p|{U4yzLD()f-ZvE| zxKDj2^AK+%*VpB~2skO$A8jiU2f`M40DA@z|#T1J1x@IdNlY}Tx`<3suD7H20<}#v>wym@_V-3)Nog7nSFGdx! zUjzFD1^VvdECU$e8@h88t!bY-Iu%3wOb^k!yS6!F`A)J4(mP)^Tb@f+9MMU66tS-E zh2}qsl=%-(*32d2&Ieg*7xy&4Z*Y6@^l|Y~S!FyYVSCeuPxut(vZzWWCZSK1EXiWD z6EQXovNsC?Tr^!!GPG6EuWph)>puF#0_+7{rrCiD+{m_Ltt)o34zrB{V@8W{o(~ES z_m8&}IE7<9WS)yK4u#6af!b2IgWFJ}?#9ujO(D+UEJD`HKPXuVbyK_wOF%!drImLd zYpb0353H<4p>v$)agolAWOu?*-x zZW5m*4V$#4VQ3Prg`|9PXbBBn2S-@yQCw-N~ulZ6#?J6Vp4Ix?z{{cRp%KG3d1N|ENuA3qDHMf{4;x&)S%|lU~0yN%a zZhr5FI~#*ZAA_wThH9Wi>EKZhcU^Ha(mRgc5&f3zj^73N$O;b|jEE{KF)HNyH4~0B zwACdjKd%LHPMaH-hzev@&*CZk8Ww7AmE;@wixOQjs*`jjSNsV**7u+66={}9w*ZgY z%9~vy`aW0p69*FujG_uPYaFYzQEE1cH|vOaB5y~osLhUc`sVu>icu91R{N%tg%49|LAZ1(bZCHuz|5+m)#0^XH-I9_Ji zqm)*?qR=?DL3}M5n1J(SLf;BW#weg2Y&9{Q+!HoSS8XQU`1s|oLI)XiCUQ|dfwerl zU^@Gvkzy-^?*~`;uC6`RG8fxOx!Xx?Wo!atS@U1?st4^uc(SrB;+C7crn0i=aK^kn zaZW4vBUHTOC#9Hu!DpzSp@STxKnwq-mB0vKWuvM9Emygu8o{5r`x>>Mn=W7LWZ{+Z zy8`_>_capr4Q^EVJOS)ylA1q7Nak#Q-;}5rDI=poK!Tt7aUUCyD}D{}@K%bkk)#Cr z4Q`jmCb5d`Obeu|fu?HRWzcxY^}8%iVf{Masx5w&qjFVFa(D>D8_iOqADMV_3d3WR z?7g1Z`z?KSDC0IK$`MD(JDTj4(s|@Mu{55ZF$ARR2>G3b5HP83ZU-ao*s+wU5E<>9e4!lgeOQA-MR~}=p?FbgfykZq=aBSX*{3u_X zxjJfn(|g^>(l?lP7v^gtSYeqP+@d6fc9PjDBb!sYCHNdlhDRYwc_Rm?)K1}ffjnfd znq4jB++h8Mb@gPDOtzDm03yk_Hp$axU%_FBGq{mIb70iMJ&uW<`M^cRnTn`4JxQtK z?#$Fc45WEPG*0imtCZ3aW8jG|;wb72Z5OlL3p}0J_fNLY>=)Uv3~jXeBun67(B?7A z4&*my6-62+H==VkQ7FgK&Sv4!l>=K1Xctdze7Q?iRD7EE&l>l5aqT7hP#H33V9P=_ zdu86K$yVDOF{oJ6I@pdna*l?F76dyiRQ)-B-_w3vpIG21#937PWd63VG|jI}GyX>D zUC$@H(|-u5%IX=n@HrjV@{8Fo;hv^5FEWPV%mS4_)FEEm=9Y-$qxTg|J0!!RUf%UE z2{m+ntqpWHAOuj3#rXktn^l)@_W1H8bv3g_z&_s9wU%e$WU38Y@WErOVy{fClSwK zVm62VKe$e<{ba;H+Cn02IhHgevwo%-H7s&@)5XN2E!Wa7^J0yI6E@m$iYN?b>)>&f z!D7(dGB%#;HP6ErsOjscFzAZtg2!XEItdB;Ll^4rgEe9J3YkCZ>hhPFU>cNS?J*=} zb6Qj%T?6Bi$32veE}&lRBB8+-1mi^{s&l~zjOB`aqP&eqaY>K1n44YZu}@^Z^XS! z1n_t=TBwY=ZHe3r5G>N>cJ&4 z$m@Z8S&bB#(76_1t>?W_!P(Khha#7p)#~KN#*WC(NfOUD{sRyPHt({YAfK1t*j$j@ z<@4UaDVTpKwBu@)%q{VCG@m6j$C&RxVi1g-rW$!bgLV5|hmYTy9=dJd7V81a zJ!2Z_v+)$^JRm%E-7h!&neG_yXWGd9bcisrhJ*MD8>b&G>0Aze{)$}Anwk_Q5kgxJ zetLjYrcK-c&~DT} zQ5Ds{`Q_{G@>e`u&~-ge!TXqG&F^dlJlEh4LZ%9;%}(nhVN-9T-L@$C_^-eY{@fCp zFGbXP9stKXLlHx-J{WvKG9VYV&0TBswK!a|MA!>Ef&p8qea3?c5^=9*4yjy}eu8e6pZjrXLI;=Ga%JBSlnPeMkd$qbWOm48R;LD5$pTUJveqirJdP z&PAUa<839L&>klXjQ~+?Ccjgr(Vv$d^CiCP^ubcTN|&v<75@RHf0dz8mE5Q>TB;y@ zHs+giqT+wQipMI(#nl;B9x;lkWag$4n4hsM{3W^=8N`a_fmgfckf%eLv)&<|m%d*b z=IH~vWMkYHO`8d3`=5o0JnmXp7wP)nXIm98)r>Oi%P7`MuZ2`{;nFv}Ec zdNgXN#|BSPmEyDbaM5*suseQN2@HZp62;z*-wIc&zK8rzA(&$a$p79yYY16(rV|_ zU#!pH1J+D=m!i3c1V?r7>7y@y!a|=^67Ty?5VSfg&HkUp?Ho4X+N0KUo15RYUEHx? K$A`)PZvp^n3{Oe` literal 0 HcmV?d00001 diff --git a/tests/e2e-integration/integration/files/FileJSDataverseRepository.spec.ts b/tests/e2e-integration/integration/files/FileJSDataverseRepository.spec.ts index 6c84bf89e..b9124e709 100644 --- a/tests/e2e-integration/integration/files/FileJSDataverseRepository.spec.ts +++ b/tests/e2e-integration/integration/files/FileJSDataverseRepository.spec.ts @@ -365,22 +365,30 @@ describe('File JSDataverse Repository', () => { }) }) - it('gets all the files by dataset persistentId after adding a thumbnail to the files', async () => { - const datasetResponse = await FileHelper.createImage().then((file) => - DatasetHelper.createWithFiles([file]) - ) - if (!datasetResponse.files) throw new Error('Files not found') + it('gets all the files by dataset persistentId after adding a thumbnail to the files', () => { + FileHelper.createImage() + .then((fileData) => cy.wrap(DatasetHelper.createWithFiles([fileData]))) + .then((datasetResponse) => { + expect(datasetResponse.files).to.exist + + return cy.wrap( + datasetRepository.getByPersistentId( + datasetResponse.persistentId, + DatasetNonNumericVersion.DRAFT + ) + ) + }) + .then((dataset) => { + expect(dataset).to.exist - const dataset = await datasetRepository.getByPersistentId( - datasetResponse.persistentId, - DatasetNonNumericVersion.DRAFT - ) - if (!dataset) throw new Error('Dataset not found') + if (!dataset) throw new Error('Dataset not found') - await fileRepository - .getAllByDatasetPersistentId(dataset.persistentId, dataset.version) + return cy.wrap( + fileRepository.getAllByDatasetPersistentId(dataset.persistentId, dataset.version) + ) + }) .then((files) => { - expect(files[0].metadata.thumbnail).to.not.be.undefined + expect(files?.[0]?.metadata?.thumbnail).to.not.be.undefined }) }) diff --git a/tests/e2e-integration/shared/files/FileHelper.ts b/tests/e2e-integration/shared/files/FileHelper.ts index 44b75abf9..3dd1965ed 100644 --- a/tests/e2e-integration/shared/files/FileHelper.ts +++ b/tests/e2e-integration/shared/files/FileHelper.ts @@ -59,15 +59,11 @@ export class FileHelper extends DataverseApiHelper { } } - static async createImage(): Promise { - const fileBinary = await this.generateImgData() - if (!fileBinary) { - throw new Error('File could not be fetched') - } - return { - file: fileBinary, + static createImage(): Cypress.Chainable { + return this.generateImgData().then((blob) => ({ + file: blob, jsonData: JSON.stringify({ description: 'This is an example file' }) - } + })) } static generateCsvData(): string { @@ -94,17 +90,10 @@ export class FileHelper extends DataverseApiHelper { return faker.lorem.sentence() } - static async generateImgData(): Promise { - return await fetch('https://picsum.photos/id/237/200') - .then((response) => { - if (!response.ok) { - throw new Error('Network response was not ok') - } - return response.blob() - }) - .catch(() => { - throw new Error('Files could not be fetched') - }) + static generateImgData(): Cypress.Chainable { + return cy + .fixture('images/dog-640x480.jpg', 'binary') + .then((binary: string) => Cypress.Blob.binaryStringToBlob(binary, 'image/jpeg')) } static async download(id: number) {