diff --git a/package-lock.json b/package-lock.json index a848250ae..713da3164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.10", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.11", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3674,9 +3674,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.10", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.10/41edaabb0072793dd0a3438871d27ee60388bb58", - "integrity": "sha512-rBC3aaAgR26NRmXoGTQAFnxRlmKWca5xq8VuqC58DpBogvwXxLKM/5tvxFnjgKAuSeyDxJpfxSZeorCaTsSvwA==", + "version": "2.0.0-alpha.11", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.11/77723d5bcef1f38f1dcfa0fd195d2edc2baa7ed9", + "integrity": "sha512-KKgrCeKT9tplhRUxjzgaI2fg8X6OfH2DAnFzDdcFQpoJejwBH4BSbp4d58zG7WxrjRI+sP0Iw5A1o8fsc9TOqw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index bf283f587..c74c8e142 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.10", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.11", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 43cee150e..612049c77 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -31,6 +31,8 @@ "error": "There was an error publishing your collection." }, "publishedAlert": "Your collection is now public.", + "addFacetFilter": "Add {{labelName}} facet filter", + "removeSelectedFacet": "Remove {{labelName}} facet filter", "share": { "shareCollection": "Share Collection", "helpText": "Share this collection on your favorite social media networks." diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 11a71c0eb..67c02042e 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -5,6 +5,8 @@ "cancel": "Cancel", "close": "Close", "continue": "Continue", + "more": "More...", + "less": "Less...", "share": "Share", "pageNumberNotFound": { "heading": "Page Number Not Found", diff --git a/src/collection/domain/models/CollectionItemSubset.ts b/src/collection/domain/models/CollectionItemSubset.ts index 3fbc3b0b7..85cdee9a7 100644 --- a/src/collection/domain/models/CollectionItemSubset.ts +++ b/src/collection/domain/models/CollectionItemSubset.ts @@ -4,6 +4,7 @@ import { FileItemTypePreview } from '../../../files/domain/models/FileItemTypePr export interface CollectionItemSubset { items: CollectionItem[] + facets: CollectionItemsFacet[] totalItemCount: number } @@ -11,3 +12,14 @@ export type CollectionItem = | CollectionItemTypePreview | DatasetItemTypePreview | FileItemTypePreview + +export interface CollectionItemsFacet { + name: string + friendlyName: string + labels: CollectionItemsFacetLabel[] +} + +interface CollectionItemsFacetLabel { + name: string + count: number +} diff --git a/src/collection/domain/models/CollectionItemsQueryParams.ts b/src/collection/domain/models/CollectionItemsQueryParams.ts new file mode 100644 index 000000000..ed5d4c2cc --- /dev/null +++ b/src/collection/domain/models/CollectionItemsQueryParams.ts @@ -0,0 +1,8 @@ +export enum CollectionItemsQueryParams { + SORT = 'sort', + ORDER = 'order', + START = 'start', + TYPES = 'types', + QUERY = 'q', + FILTER_QUERIES = 'fqs' +} diff --git a/src/collection/domain/models/CollectionSearchCriteria.tsx b/src/collection/domain/models/CollectionSearchCriteria.ts similarity index 100% rename from src/collection/domain/models/CollectionSearchCriteria.tsx rename to src/collection/domain/models/CollectionSearchCriteria.ts diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index c7851292f..5dc53a9a4 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -55,6 +55,7 @@ export class CollectionJSDataverseRepository implements CollectionRepository { return { items: collectionItemsPreviewsMapped, + facets: jsCollectionItemSubset.facets, totalItemCount: jsCollectionItemSubset.totalItemCount } }) diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 742fd12f9..2a04b4be5 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -26,8 +26,6 @@ export const RouteWithParams = { export enum QueryParamKey { VERSION = 'version', PERSISTENT_ID = 'persistentId', - QUERY = 'q', - COLLECTION_ITEM_TYPES = 'types', PAGE = 'page', COLLECTION_ID = 'collectionId' } diff --git a/src/sections/collection/CollectionHelper.ts b/src/sections/collection/CollectionHelper.ts index 3727e9d61..3684e1bb9 100644 --- a/src/sections/collection/CollectionHelper.ts +++ b/src/sections/collection/CollectionHelper.ts @@ -1,6 +1,7 @@ -import { Collection } from '@/collection/domain/models/Collection' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' -import { QueryParamKey } from '../Route.enum' +import { Collection } from '@/collection/domain/models/Collection' import { UpwardHierarchyNode } from '@/shared/hierarchy/domain/models/UpwardHierarchyNode' export class CollectionHelper { @@ -9,11 +10,11 @@ export class CollectionHelper { ? parseInt(searchParams.get('page') as string, 10) : 1 - const searchQuery = searchParams.get(QueryParamKey.QUERY) - ? decodeURIComponent(searchParams.get(QueryParamKey.QUERY) as string) + const searchQuery = searchParams.get(CollectionItemsQueryParams.QUERY) + ? decodeURIComponent(searchParams.get(CollectionItemsQueryParams.QUERY) as string) : undefined - const typesParam = searchParams.get(QueryParamKey.COLLECTION_ITEM_TYPES) ?? undefined + const typesParam = searchParams.get(CollectionItemsQueryParams.TYPES) ?? undefined const typesQuery = typesParam ?.split(',') @@ -22,7 +23,16 @@ export class CollectionHelper { Object.values(CollectionItemType).includes(type as CollectionItemType) ) as CollectionItemType[] - return { pageQuery, searchQuery, typesQuery } + const filtersParam = searchParams.get(CollectionItemsQueryParams.FILTER_QUERIES) ?? undefined + + const filtersQuery: FilterQuery[] | undefined = filtersParam + ? (filtersParam + ?.split(',') + .map((filterQuery) => decodeURIComponent(filterQuery)) + .filter((decodedFilter) => /^[^:]+:[^:]+$/.test(decodedFilter)) as FilterQuery[]) + : undefined + + return { pageQuery, searchQuery, typesQuery, filtersQuery } } static isRootCollection(collectionHierarchy: Collection['hierarchy']) { diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx index 76664d5ae..9319eb2b0 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -1,19 +1,25 @@ import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { Stack } from '@iqss/dataverse-design-system' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' -import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' +import { + CollectionSearchCriteria, + FilterQuery +} from '@/collection/domain/models/CollectionSearchCriteria' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' import { useGetAccumulatedItems } from './useGetAccumulatedItems' import { UseCollectionQueryParamsReturnType } from '../useGetCollectionQueryParams' import { useLoadMoreOnPopStateEvent } from './useLoadMoreOnPopStateEvent' import { useLoading } from '@/sections/loading/LoadingContext' -import { QueryParamKey } from '../../Route.enum' import { CollectionHelper } from '../CollectionHelper' import { FilterPanel } from './filter-panel/FilterPanel' import { ItemsList } from './items-list/ItemsList' import { SearchPanel } from './search-panel/SearchPanel' import { ItemTypeChange } from './filter-panel/type-filters/TypeFilters' +import { RemoveAddFacetFilter } from './filter-panel/facets-filters/FacetFilterGroup' +import { SelectedFacets } from './selected-facets/SelectedFacets' import styles from './CollectionItemsPanel.module.scss' interface CollectionItemsPanelProps { @@ -30,7 +36,8 @@ interface CollectionItemsPanelProps { * 2. When the user scrolls to the bottom of the list and there are more items to load * 3. When the user submits a search query in the search panel * 4. When the user changes the item types in the filter panel - * 5. When the user navigates back and forward in the browser + * 5. When the user selects or removes a facet filter + * 6. When the user navigates back and forward in the browser * * It initializes the search criteria with the query params in the URL. * By default if no query params are present in the URL, the search query is empty and the item types are COLLECTION and DATASET. @@ -51,7 +58,10 @@ export const CollectionItemsPanel = ({ // This object will update every time we update a query param in the URL with the setSearchParams setter const currentSearchCriteria = new CollectionSearchCriteria( collectionQueryParams.searchQuery, - collectionQueryParams.typesQuery || [CollectionItemType.COLLECTION, CollectionItemType.DATASET] + collectionQueryParams.typesQuery || [CollectionItemType.COLLECTION, CollectionItemType.DATASET], + undefined, + undefined, + collectionQueryParams.filtersQuery ) const [paginationInfo, setPaginationInfo] = useState( @@ -62,6 +72,7 @@ export const CollectionItemsPanel = ({ const { isLoadingItems, accumulatedItems, + facets, totalAvailable, hasNextPage, error, @@ -97,20 +108,23 @@ export const CollectionItemsPanel = ({ if (searchValue === '') { // Update the URL without the search value, keep other querys setSearchParams((currentSearchParams) => { - currentSearchParams.delete(QueryParamKey.QUERY) + currentSearchParams.delete(CollectionItemsQueryParams.QUERY) return currentSearchParams }) } else { // Update the URL with the search value ,keep other querys and include all item types always - setSearchParams((currentSearchParams) => ({ - ...currentSearchParams, - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ - CollectionItemType.COLLECTION, - CollectionItemType.DATASET, - CollectionItemType.FILE - ].join(','), - [QueryParamKey.QUERY]: searchValue - })) + setSearchParams((currentSearchParams) => { + currentSearchParams.set( + CollectionItemsQueryParams.TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join( + ',' + ) + ) + + currentSearchParams.set(CollectionItemsQueryParams.QUERY, searchValue) + + return currentSearchParams + }) } // WHEN SEARCHING, WE RESET THE PAGINATION INFO AND KEEP ALL ITEM TYPES!! @@ -137,24 +151,73 @@ export const CollectionItemsPanel = ({ (itemType) => itemType !== type ) - // KEEP SEARCH VALUE IF EXISTS itemsListContainerRef.current?.scrollTo({ top: 0 }) const resetPaginationInfo = new CollectionItemsPaginationInfo() setPaginationInfo(resetPaginationInfo) - // Update the URL with the new item types, keep other querys and include the search value if exists - setSearchParams((currentSearchParams) => ({ - ...currentSearchParams, - [QueryParamKey.COLLECTION_ITEM_TYPES]: newItemsTypes.join(','), - ...(currentSearchCriteria.searchText && { - [QueryParamKey.QUERY]: currentSearchCriteria.searchText - }) - })) + // Update the URL with the new item types, keep other querys + setSearchParams((currentSearchParams) => { + currentSearchParams.set(CollectionItemsQueryParams.TYPES, newItemsTypes.join(',')) + + return currentSearchParams + }) const newCollectionSearchCriteria = new CollectionSearchCriteria( currentSearchCriteria.searchText, - newItemsTypes + newItemsTypes, + undefined, + undefined, + currentSearchCriteria.filterQueries + ) + + const totalItemsCount = await loadMore(resetPaginationInfo, newCollectionSearchCriteria, true) + + if (totalItemsCount !== undefined) { + const paginationInfoUpdated = resetPaginationInfo.withTotal(totalItemsCount) + setPaginationInfo(paginationInfoUpdated) + } + } + + const handleFacetChange = async (filterQuery: FilterQuery, removeOrAdd: RemoveAddFacetFilter) => { + const newFilterQueries = + removeOrAdd === RemoveAddFacetFilter.ADD + ? [ + ...new Set([ + ...(currentSearchCriteria?.filterQueries ?? /* istanbul ignore next */ []), + filterQuery + ]) + ] + : (currentSearchCriteria.filterQueries ?? /* istanbul ignore next */ []).filter( + (fQuery) => fQuery !== filterQuery + ) + + itemsListContainerRef.current?.scrollTo({ top: 0 }) + + const resetPaginationInfo = new CollectionItemsPaginationInfo() + setPaginationInfo(resetPaginationInfo) + + const newFilterQueriesWithFacetValueEncoded = newFilterQueries.map((fq) => { + const [facetName, facetValue] = fq.split(':') + return `${facetName}:${encodeURIComponent(facetValue)}` + }) + + // Update the URL with the new facets, keep other querys and include the search value if exists + setSearchParams((currentSearchParams) => { + currentSearchParams.set( + CollectionItemsQueryParams.FILTER_QUERIES, + newFilterQueriesWithFacetValueEncoded.join(',') + ) + + return currentSearchParams + }) + + const newCollectionSearchCriteria = new CollectionSearchCriteria( + currentSearchCriteria.searchText, + currentSearchCriteria.itemTypes, + undefined, + undefined, + newFilterQueries ) const totalItemsCount = await loadMore(resetPaginationInfo, newCollectionSearchCriteria, true) @@ -171,7 +234,10 @@ export const CollectionItemsPanel = ({ const newCollectionSearchCriteria = new CollectionSearchCriteria( collectionQueryParams.searchQuery, - collectionQueryParams.typesQuery + collectionQueryParams.typesQuery, + undefined, + undefined, + collectionQueryParams.filtersQuery ) const newPaginationInfo = new CollectionItemsPaginationInfo() @@ -183,6 +249,9 @@ export const CollectionItemsPanel = ({ } } + const showSelectedFacets = + currentSearchCriteria.filterQueries && currentSearchCriteria.filterQueries.length > 0 + useEffect(() => { setIsLoading(isLoadingItems) }, [isLoadingItems, setIsLoading]) @@ -202,24 +271,40 @@ export const CollectionItemsPanel = ({ - + + {showSelectedFacets && facets.length > 0 && ( + + handleFacetChange(filterQuery, RemoveAddFacetFilter.REMOVE) + } + selectedFilterQueries={currentSearchCriteria.filterQueries} + isLoadingCollectionItems={isLoadingItems} + /> + )} + + + ) diff --git a/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx index 64e33fca5..75c23e13a 100644 --- a/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx +++ b/src/sections/collection/collection-items-panel/filter-panel/FilterPanel.tsx @@ -4,17 +4,27 @@ import { Button, Offcanvas } from '@iqss/dataverse-design-system' import { FunnelFill } from 'react-bootstrap-icons' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' import { ItemTypeChange, TypeFilters } from './type-filters/TypeFilters' +import { CollectionItemsFacet } from '@/collection/domain/models/CollectionItemSubset' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' +import { FacetsFilters } from './facets-filters/FacetsFilters' +import { RemoveAddFacetFilter } from './facets-filters/FacetFilterGroup' import styles from './FilterPanel.module.scss' interface FilterPanelProps { currentItemTypes?: CollectionItemType[] onItemTypesChange: (itemTypeChange: ItemTypeChange) => void + facets: CollectionItemsFacet[] + currentFilterQueries?: FilterQuery[] + onFacetChange: (filterQuery: FilterQuery, removeOrAdd: RemoveAddFacetFilter) => void isLoadingCollectionItems: boolean } export const FilterPanel = ({ currentItemTypes, onItemTypesChange, + facets, + currentFilterQueries, + onFacetChange, isLoadingCollectionItems }: FilterPanelProps) => { const { t } = useTranslation('collection') @@ -45,6 +55,13 @@ export const FilterPanel = ({ currentItemTypes={currentItemTypes} isLoadingCollectionItems={isLoadingCollectionItems} /> + + diff --git a/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetFilterGroup.tsx b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetFilterGroup.tsx new file mode 100644 index 000000000..f41903012 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetFilterGroup.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import cn from 'classnames' +import { Button, Col, Row } from '@iqss/dataverse-design-system' +import { X as CloseIcon } from 'react-bootstrap-icons' +import { CollectionItemsFacet } from '@/collection/domain/models/CollectionItemSubset' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' +import styles from './FacetsFilters.module.scss' + +const FACETS_PER_VIEW = 5 + +export enum RemoveAddFacetFilter { + REMOVE = 'remove', + ADD = 'add' +} + +interface FacetFilterGroupProps { + facet: CollectionItemsFacet + facetSelectedLabels?: string[] + onFacetChange: (filterQuery: FilterQuery, removeOrAdd: RemoveAddFacetFilter) => void + isLoadingCollectionItems: boolean +} + +export const FacetFilterGroup = ({ + facet, + facetSelectedLabels, + onFacetChange, + isLoadingCollectionItems +}: FacetFilterGroupProps) => { + const { t } = useTranslation('collection') + const { t: tShared } = useTranslation('shared') + + const [visibleCount, setVisibleCount] = useState(FACETS_PER_VIEW) + + const handleShowMore = () => { + setVisibleCount((prev) => Math.min(prev + FACETS_PER_VIEW, facet.labels.length)) + } + + const handleShowLess = () => { + setVisibleCount((prev) => Math.max(prev - FACETS_PER_VIEW, FACETS_PER_VIEW)) + } + + const handleClickFacetLabel = (facetName: string, labelName: string) => { + const filterQuery: FilterQuery = `${facetName}:${labelName}` + const shouldRemoveOrAdd = facetSelectedLabels?.includes(labelName) + ? RemoveAddFacetFilter.REMOVE + : RemoveAddFacetFilter.ADD + + onFacetChange(filterQuery, shouldRemoveOrAdd) + } + + const showMoreButton = visibleCount < facet.labels.length + const showLessButton = visibleCount > FACETS_PER_VIEW + const showMoreLessButtons = + (showMoreButton || showLessButton) && facet.labels.length > FACETS_PER_VIEW + + return ( +
  • + {facet.friendlyName} +
      + {facet.labels.slice(0, visibleCount).map((label) => { + const isFacetLabelSelected = Boolean(facetSelectedLabels?.includes(label.name)) + + return ( +
    • + +
    • + ) + })} +
    + + {showMoreLessButtons && ( + + + {showLessButton && ( + + )} + + + {showMoreButton && ( + + )} + + + )} +
  • + ) +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.module.scss b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.module.scss new file mode 100644 index 000000000..75b36b0e7 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.module.scss @@ -0,0 +1,68 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.facets-list, +.labels-list { + margin-bottom: 0; + padding-left: 0; + list-style-type: none; +} + +.facets-list { + padding-top: 1rem; + + &:empty { + padding-top: 0; + } + + .facet-filter-group { + padding-bottom: 0.5rem; + + &:not(:last-child) { + border-bottom: solid 1px $dv-border-color; + } + + .facet-name { + font-weight: 600; + } + + .show-less-more { + button { + text-decoration: none; + } + + :last-child { + display: flex; + justify-content: flex-end; + } + } + + .labels-list { + li { + display: flex; + } + + .facet-label-button { + padding: 0; + text-align: left; + text-decoration: none; + text-wrap: nowrap; + + &.selected { + font-weight: 600; + } + + &:disabled { + color: $dv-primary-color; + } + + span { + text-wrap: wrap; + } + + svg { + min-width: fit-content; + } + } + } + } +} diff --git a/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.tsx b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.tsx new file mode 100644 index 000000000..d74cfacd6 --- /dev/null +++ b/src/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.tsx @@ -0,0 +1,60 @@ +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' +import { Stack } from '@iqss/dataverse-design-system' +import { CollectionItemsFacet } from '@/collection/domain/models/CollectionItemSubset' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' +import { FacetFilterGroup, RemoveAddFacetFilter } from './FacetFilterGroup' +import styles from './FacetsFilters.module.scss' + +interface FacetsFiltersProps { + facets: CollectionItemsFacet[] + currentFilterQueries?: FilterQuery[] + onFacetChange: (filterQuery: FilterQuery, removeOrAdd: RemoveAddFacetFilter) => void + isLoadingCollectionItems: boolean +} + +export const FacetsFilters = ({ + facets, + currentFilterQueries, + onFacetChange, + isLoadingCollectionItems +}: FacetsFiltersProps) => { + if (isLoadingCollectionItems && facets.length === 0) { + return + } + + return ( + + {facets.map((facet) => { + const facetSelectedLabels = currentFilterQueries + ?.filter((query) => query.split(':')[0] === facet.name) + .map((query) => query.split(':')[1]) + + return ( + + ) + })} + + ) +} + +const FacetsFiltersSkeleton = () => ( + +
    + {Array.from({ length: 3 }).map((_, index) => ( +
    + + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
    + ))} +
    +
    +) diff --git a/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss b/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss index 253026076..151e4516b 100644 --- a/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss +++ b/src/sections/collection/collection-items-panel/items-list/ItemsList.module.scss @@ -1,4 +1,10 @@ @import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import 'src/assets/variables'; + +.items-list-root-ref { + flex: 1; + height: 100%; +} .items-list { --inline-padding: 1rem; @@ -10,17 +16,24 @@ overflow-y: auto; border: 1px solid $dv-border-color; border-radius: 4px; + scroll-margin-top: calc(#{$header-aproximate-height} + 1rem); @media screen and (max-width: 768px) { --inline-padding: 0.5rem; } - @media screen and (min-width: 1280px) { + @media screen and (min-width: 992px) { height: 60vh; - max-height: 60vh; + min-height: 100%; + max-height: unset; + + &.only-one-or-two-items { + height: auto; + } } &.empty-or-error { + height: auto; padding-block: var(--inline-padding); } diff --git a/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx b/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx index 7eeee3709..f2cbb49d5 100644 --- a/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx +++ b/src/sections/collection/collection-items-panel/items-list/ItemsList.tsx @@ -5,6 +5,7 @@ import cn from 'classnames' import { type CollectionItem } from '@/collection/domain/models/CollectionItemSubset' import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' import { PaginationResultsInfo } from '@/sections/shared/pagination/PaginationResultsInfo' import { NO_COLLECTION_ITEMS } from '../useGetAccumulatedItems' import { ErrorItemsMessage } from './ErrorItemsMessage' @@ -28,6 +29,7 @@ interface ItemsListProps { paginationInfo: CollectionItemsPaginationInfo onBottomReach: (paginationInfo: CollectionItemsPaginationInfo) => void itemsTypesSelected: CollectionItemType[] + filterQueriesSelected: FilterQuery[] } export const ItemsList = forwardRef( @@ -44,7 +46,8 @@ export const ItemsList = forwardRef( hasSearchValue, paginationInfo, onBottomReach, - itemsTypesSelected + itemsTypesSelected, + filterQueriesSelected }: ItemsListProps, ref ) => { @@ -56,17 +59,20 @@ export const ItemsList = forwardRef( rootMargin: '0px 0px 250px 0px' }) - const showNoItemsMessage = !isLoadingItems && isEmptyItems && !hasSearchValue - const showNoSearchMatchesMessage = !isLoadingItems && isEmptyItems && hasSearchValue + const showNoItemsMessage = + !isLoadingItems && isEmptyItems && !hasSearchValue && filterQueriesSelected.length === 0 + const showNoSearchMatchesMessage = + !isLoadingItems && isEmptyItems && (hasSearchValue || filterQueriesSelected.length > 0) const showSentrySkeleton = hasNextPage && !error && !isEmptyItems const showNotSentrySkeleton = isLoadingItems && isEmptyItems return ( -
    +
    } @@ -141,7 +147,7 @@ export const InitialLoadingSkeleton = () => ( data-testid="collection-items-list-infinite-scroll-skeleton-header">
    - + diff --git a/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss index 7514bb2d1..efa7e29d2 100644 --- a/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss +++ b/src/sections/collection/collection-items-panel/items-list/file-card/FileCard.module.scss @@ -47,6 +47,7 @@ max-width: 64px; height: 48px; max-height: 48px; + object-fit: contain; vertical-align: top; } diff --git a/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.module.scss b/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.module.scss new file mode 100644 index 000000000..83ed78265 --- /dev/null +++ b/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.module.scss @@ -0,0 +1,31 @@ +@use 'sass:color'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.selected-facets-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .selected-facet-btn { + display: flex; + align-items: center; + padding-right: 2px; + padding-block: 2px; + color: color.adjust($dv-primary-color, $blackness: 5%); + font-weight: 600; + font-size: 80%; + background-color: color.adjust($dv-primary-color, $lightness: 50%); + + &:hover { + background-color: color.adjust($dv-primary-color, $lightness: 40%); + } + + &:active { + color: $dv-primary-color; + } + + svg { + min-width: fit-content; + } + } +} diff --git a/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.tsx b/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.tsx new file mode 100644 index 000000000..b89685617 --- /dev/null +++ b/src/sections/collection/collection-items-panel/selected-facets/SelectedFacets.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { Button } from '@iqss/dataverse-design-system' +import { X as CloseIcon } from 'react-bootstrap-icons' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' +import styles from './SelectedFacets.module.scss' + +interface SelectedFacetsProps { + selectedFilterQueries: FilterQuery[] + onRemoveFacet: (filterQuery: FilterQuery) => void + isLoadingCollectionItems: boolean +} + +export const SelectedFacets = ({ + selectedFilterQueries, + onRemoveFacet, + isLoadingCollectionItems +}: SelectedFacetsProps) => { + const { t } = useTranslation('collection') + + return ( +
    + {selectedFilterQueries.map((filterQuery) => { + const [_facetName, labelName] = filterQuery.split(':') + + return ( + + ) + })} +
    + ) +} diff --git a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx index 8179cc2a1..f2094589b 100644 --- a/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx +++ b/src/sections/collection/collection-items-panel/useGetAccumulatedItems.tsx @@ -3,6 +3,7 @@ import { getCollectionItems } from '@/collection/domain/useCases/getCollectionIt import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { CollectionItem, + CollectionItemsFacet, CollectionItemSubset } from '@/collection/domain/models/CollectionItemSubset' import { CollectionItemsPaginationInfo } from '@/collection/domain/models/CollectionItemsPaginationInfo' @@ -13,6 +14,7 @@ export const NO_COLLECTION_ITEMS = 0 type UseGetAccumulatedItemsReturnType = { isLoadingItems: boolean accumulatedItems: CollectionItem[] + facets: CollectionItemsFacet[] totalAvailable: number | undefined hasNextPage: boolean error: string | null @@ -37,6 +39,7 @@ export const useGetAccumulatedItems = ({ }: UseGetAccumulatedItemsParams): UseGetAccumulatedItemsReturnType => { const [isLoadingItems, setIsLoadingItems] = useState(false) const [accumulatedItems, setAccumulatedItems] = useState([]) + const [facets, setFacets] = useState([]) const [hasNextPage, setHasNextPage] = useState(true) const [totalAvailable, setTotalAvailable] = useState(undefined) const [error, setError] = useState(null) @@ -55,7 +58,7 @@ export const useGetAccumulatedItems = ({ setIsLoadingItems(true) try { - const { items, totalItemCount } = await loadNextItems( + const { items, facets, totalItemCount } = await loadNextItems( collectionRepository, collectionId, pagination, @@ -66,6 +69,8 @@ export const useGetAccumulatedItems = ({ setAccumulatedItems(newAccumulatedItems) + setFacets(facets) + setTotalAvailable(totalItemCount) const isNextPage = !resetAccumulated @@ -93,6 +98,7 @@ export const useGetAccumulatedItems = ({ return { isLoadingItems, accumulatedItems, + facets, totalAvailable, hasNextPage, error, diff --git a/src/sections/collection/useGetCollectionQueryParams.ts b/src/sections/collection/useGetCollectionQueryParams.ts index 292db143f..52baf0adc 100644 --- a/src/sections/collection/useGetCollectionQueryParams.ts +++ b/src/sections/collection/useGetCollectionQueryParams.ts @@ -1,11 +1,13 @@ import { useSearchParams } from 'react-router-dom' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { FilterQuery } from '@/collection/domain/models/CollectionSearchCriteria' import { CollectionHelper } from './CollectionHelper' export interface UseCollectionQueryParamsReturnType { pageQuery: number searchQuery?: string typesQuery?: CollectionItemType[] + filtersQuery?: FilterQuery[] } export const useGetCollectionQueryParams = (): UseCollectionQueryParamsReturnType => { diff --git a/src/sections/homepage/search-input/SearchInput.tsx b/src/sections/homepage/search-input/SearchInput.tsx index 204a09023..bd1d83b0e 100644 --- a/src/sections/homepage/search-input/SearchInput.tsx +++ b/src/sections/homepage/search-input/SearchInput.tsx @@ -2,8 +2,9 @@ import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Form, CloseButton } from '@iqss/dataverse-design-system' import { Search as SearchIcon } from 'react-bootstrap-icons' -import { QueryParamKey, Route } from '../../Route.enum' +import { Route } from '../../Route.enum' import { CollectionItemType } from '../../../collection/domain/models/CollectionItemType' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' import styles from './SearchInput.module.scss' export const SearchInput = () => { @@ -25,9 +26,9 @@ export const SearchInput = () => { const encodedSearchValue = encodeURIComponent(trimmedSearchValue) const searchParams = new URLSearchParams() - searchParams.set(QueryParamKey.QUERY, encodedSearchValue) + searchParams.set(CollectionItemsQueryParams.QUERY, encodedSearchValue) searchParams.set( - QueryParamKey.COLLECTION_ITEM_TYPES, + CollectionItemsQueryParams.TYPES, [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') ) diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index 1b45235e3..ddc46edfd 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -59,6 +59,8 @@ export class CollectionMockRepository implements CollectionRepository { numberOfFiles }) + const facets = CollectionItemsMother.createItemsFacets() + const isDefaultSelected = searchCriteria?.itemTypes?.length === 2 && searchCriteria?.itemTypes?.includes(CollectionItemType.COLLECTION) && @@ -72,6 +74,7 @@ export class CollectionMockRepository implements CollectionRepository { setTimeout(() => { resolve({ items: filteredByTypeItems, + facets: facets, totalItemCount: isDefaultSelected ? 6 : 200 // This is a fake number, its big so we can always scroll to load more items for the story }) }, FakerHelper.loadingTimout()) diff --git a/src/stories/collection/NoCollectionMockRepository.ts b/src/stories/collection/NoCollectionMockRepository.ts index 63d8c5e00..69c9c21e6 100644 --- a/src/stories/collection/NoCollectionMockRepository.ts +++ b/src/stories/collection/NoCollectionMockRepository.ts @@ -32,6 +32,7 @@ export class NoCollectionMockRepository extends CollectionMockRepository { setTimeout(() => { resolve({ items: [], + facets: [], totalItemCount: 0 }) }, FakerHelper.loadingTimout()) diff --git a/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx b/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx index 4d0e23056..28282f2b5 100644 --- a/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx +++ b/src/stories/collection/collection-items-panel/CollectionItemsPanel.stories.tsx @@ -52,6 +52,26 @@ export const WithAllFiltersAndSearchValue: Story = { ) } +export const WithFacetFiltersApplied: Story = { + render: () => ( + + ) +} + export const WithAddDataButtons: Story = { render: () => ( { beforeEach(() => { diff --git a/tests/component/sections/collection/CollectionHelper.spec.tsx b/tests/component/sections/collection/CollectionHelper.spec.tsx index a4e3dc000..4d0cdc780 100644 --- a/tests/component/sections/collection/CollectionHelper.spec.tsx +++ b/tests/component/sections/collection/CollectionHelper.spec.tsx @@ -1,3 +1,4 @@ +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' import { CollectionHelper } from '@/sections/collection/CollectionHelper' import { QueryParamKey } from '@/sections/Route.enum' import { @@ -12,14 +13,19 @@ const PAGE_NUMBER = 1 describe('CollectionHelper', () => { it('define collection query params correctly when all query params are in the url', () => { - const searchParams = new URLSearchParams({}) + const searchParams = new URLSearchParams({ + [CollectionItemsQueryParams.QUERY]: QUERY_VALUE, + [CollectionItemsQueryParams.TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET + ].join(','), + [QueryParamKey.PAGE]: PAGE_NUMBER.toString(), + [CollectionItemsQueryParams.FILTER_QUERIES]: [ + 'someFilter:someValue', + 'otherFilter:otherValue' + ].join(',') + }) - searchParams.set(QueryParamKey.QUERY, QUERY_VALUE) - searchParams.set( - QueryParamKey.COLLECTION_ITEM_TYPES, - [CollectionItemType.COLLECTION, CollectionItemType.DATASET].join(',') - ) - searchParams.set(QueryParamKey.PAGE, PAGE_NUMBER.toString()) const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) expect(collectionQueryParams.searchQuery).to.equal(DECODED_QUERY_VALUE) @@ -28,26 +34,33 @@ describe('CollectionHelper', () => { CollectionItemType.DATASET ]) expect(collectionQueryParams.pageQuery).to.equal(PAGE_NUMBER) + expect(collectionQueryParams.filtersQuery).to.deep.equal([ + 'someFilter:someValue', + 'otherFilter:otherValue' + ]) }) it('define collection query params correctly when only query param is in the url', () => { - const searchParams = new URLSearchParams({}) + const searchParams = new URLSearchParams({ + [CollectionItemsQueryParams.QUERY]: QUERY_VALUE + }) - searchParams.set(QueryParamKey.QUERY, QUERY_VALUE) const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) expect(collectionQueryParams.searchQuery).to.equal(DECODED_QUERY_VALUE) expect(collectionQueryParams.typesQuery).to.deep.equal(undefined) expect(collectionQueryParams.pageQuery).to.equal(1) + expect(collectionQueryParams.filtersQuery).to.equal(undefined) }) it('define collection query params correctly when only types query param is in the url', () => { - const searchParams = new URLSearchParams({}) + const searchParams = new URLSearchParams({ + [CollectionItemsQueryParams.TYPES]: [ + CollectionItemType.COLLECTION, + CollectionItemType.DATASET + ].join(',') + }) - searchParams.set( - QueryParamKey.COLLECTION_ITEM_TYPES, - [CollectionItemType.COLLECTION, CollectionItemType.DATASET].join(',') - ) const collectionQueryParams = CollectionHelper.defineCollectionQueryParams(searchParams) expect(collectionQueryParams.searchQuery).to.equal(undefined) @@ -65,6 +78,7 @@ describe('CollectionHelper', () => { expect(collectionQueryParams.searchQuery).to.equal(undefined) expect(collectionQueryParams.typesQuery).to.deep.equal(undefined) expect(collectionQueryParams.pageQuery).to.equal(1) + expect(collectionQueryParams.filtersQuery).to.equal(undefined) }) describe('isRootCollection', () => { 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 20dc0c072..601d0efff 100644 --- a/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/CollectionItemsPanel.spec.tsx @@ -17,7 +17,9 @@ const items = CollectionItemsMother.createItems({ numberOfFiles: 3 }) -const itemsWithCount: CollectionItemSubset = { items, totalItemCount } +const facets = CollectionItemsMother.createItemsFacets() + +const itemsWithCount: CollectionItemSubset = { items, facets, totalItemCount } describe('CollectionItemsPanel', () => { beforeEach(() => { @@ -34,7 +36,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -46,7 +49,11 @@ describe('CollectionItemsPanel', () => { describe('NoItemsMessage', () => { it('renders correct no items message when there are no collection, dataset or files', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -60,7 +67,8 @@ describe('CollectionItemsPanel', () => { CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE - ] + ], + filtersQuery: undefined }} addDataSlot={null} /> @@ -73,7 +81,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no collections', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -83,7 +95,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.COLLECTION] + typesQuery: [CollectionItemType.COLLECTION], + filtersQuery: undefined }} addDataSlot={null} /> @@ -94,7 +107,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no datasets', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -104,7 +121,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.DATASET] + typesQuery: [CollectionItemType.DATASET], + filtersQuery: undefined }} addDataSlot={null} /> @@ -115,7 +133,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no files', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -125,7 +147,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.FILE] + typesQuery: [CollectionItemType.FILE], + filtersQuery: undefined }} addDataSlot={null} /> @@ -136,7 +159,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no collections and datasets', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -146,7 +173,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.COLLECTION, CollectionItemType.DATASET] + typesQuery: [CollectionItemType.COLLECTION, CollectionItemType.DATASET], + filtersQuery: undefined }} addDataSlot={null} /> @@ -157,7 +185,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no collections and files', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -167,7 +199,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.COLLECTION, CollectionItemType.FILE] + typesQuery: [CollectionItemType.COLLECTION, CollectionItemType.FILE], + filtersQuery: undefined }} addDataSlot={null} /> @@ -178,7 +211,11 @@ describe('CollectionItemsPanel', () => { it('renders correct no items message when there are no datasets and files', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -188,7 +225,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: [CollectionItemType.DATASET, CollectionItemType.FILE] + typesQuery: [CollectionItemType.DATASET, CollectionItemType.FILE], + filtersQuery: undefined }} addDataSlot={null} /> @@ -200,7 +238,39 @@ describe('CollectionItemsPanel', () => { it('renders the no search results message when there are no items matching the search query', () => { const emptyItems: CollectionItem[] = [] - const emptyItemsWithCount: CollectionItemSubset = { items: emptyItems, totalItemCount: 0 } + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } + collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) + + cy.customMount( + + ) + + cy.findByText(/There are no collections, datasets, or files that match your search./).should( + 'exist' + ) + }) + + it('renders the no search results message when there are no items matching the facet filters', () => { + const emptyItems: CollectionItem[] = [] + const emptyItemsWithCount: CollectionItemSubset = { + items: emptyItems, + facets: [], + totalItemCount: 0 + } collectionRepository.getItems = cy.stub().resolves(emptyItemsWithCount) cy.customMount( @@ -210,7 +280,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: 'some search', - typesQuery: undefined + typesQuery: undefined, + filtersQuery: ['some:filter'] }} addDataSlot={null} /> @@ -231,7 +302,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -248,7 +320,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -267,7 +340,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -285,7 +359,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -304,6 +379,7 @@ describe('CollectionItemsPanel', () => { const first4Elements = items.slice(0, 4) const first4ElementsWithCount: CollectionItemSubset = { items: first4Elements, + facets, totalItemCount: 4 } collectionRepository.getItems = cy.stub().resolves(first4ElementsWithCount) @@ -315,7 +391,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -334,7 +411,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -358,7 +436,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -379,7 +458,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -402,7 +482,8 @@ describe('CollectionItemsPanel', () => { collectionQueryParams={{ pageQuery: 1, searchQuery: 'something', - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> @@ -417,6 +498,38 @@ describe('CollectionItemsPanel', () => { cy.findByRole('checkbox', { name: /Files/ }).uncheck() }) + it('show selected filters on top of items list', () => { + cy.customMount( + + ) + + cy.findAllByRole('button', { name: /Department/ }) + .should('exist') + .should('have.length', 2) + + cy.findAllByRole('button', { name: /Department/ }) + .first() + .click() + + cy.findAllByRole('button', { name: /Admin, Dataverse/ }) + .should('exist') + .should('have.length', 2) + + cy.findAllByRole('button', { name: /Admin, Dataverse/ }) + .first() + .click() + }) + it('it calls the loadItemsOnBackAndForwardNavigation on pop state event when navigating back and forward', () => { cy.customMount( { collectionQueryParams={{ pageQuery: 1, searchQuery: undefined, - typesQuery: undefined + typesQuery: undefined, + filtersQuery: undefined }} addDataSlot={null} /> diff --git a/tests/component/sections/collection/collection-items-panel/FacetsFilter.spec.tsx b/tests/component/sections/collection/collection-items-panel/FacetsFilter.spec.tsx new file mode 100644 index 000000000..1a2a9f82e --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/FacetsFilter.spec.tsx @@ -0,0 +1,98 @@ +import { FacetsFilters } from '@/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters' +import { CollectionItemsMother } from '@tests/component/collection/domain/models/CollectionItemsMother' +import styles from '@/sections/collection/collection-items-panel/filter-panel/facets-filters/FacetsFilters.module.scss' + +const facets = CollectionItemsMother.createItemsFacets() + +describe('FacetsFilters', () => { + it('should render skeleton while loading collection items and no facets', () => { + cy.customMount( + + ) + + cy.findByTestId('facets-filters-skeleton').should('exist') + }) + + it('should render selected facets with selected classname', () => { + const onFacetChange = cy.stub().as('onFacetChange') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Department/ }) + .should('exist') + .should('have.class', styles.selected) + + cy.findByRole('button', { name: /Journal/ }) + .should('exist') + .should('not.have.class', styles.selected) + }) + + it('should call onFacetChange when clicking on a not selected facet filter with the correct args', () => { + const onFacetChange = cy.stub().as('onFacetChange') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Journal/ }).click() + + cy.wrap(onFacetChange).should('be.calledWith', 'dvCategory:Journal', 'add') + }) + + it('should call onFacetChange when clicking on an alreadt selected facet filter with the correct args', () => { + const onFacetChange = cy.stub().as('onFacetChange') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Department/ }).click() + + cy.wrap(onFacetChange).should('be.calledWith', 'dvCategory:Department', 'remove') + }) + + it('show more and less functionality', () => { + const onFacetChange = cy.stub().as('onFacetChange') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /More.../ }).should('exist') + + // This will be the sixth label, we are only showing 5 by default + cy.findByRole('button', { name: /Foo/ }).should('not.exist') + + cy.findByRole('button', { name: /More.../ }).click() + + cy.findByRole('button', { name: /Foo/ }).should('exist') + + cy.findByRole('button', { name: /More.../ }).should('not.exist') + cy.findByRole('button', { name: /Less.../ }).should('exist') + + cy.findByRole('button', { name: /Less.../ }).click() + + cy.findByRole('button', { name: /Foo/ }).should('not.exist') + }) +}) diff --git a/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx b/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx index bf6d126da..dd74aefab 100644 --- a/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx +++ b/tests/component/sections/collection/collection-items-panel/FilterPanel.spec.tsx @@ -1,17 +1,23 @@ import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' import { FilterPanel } from '@/sections/collection/collection-items-panel/filter-panel/FilterPanel' +import { CollectionItemsMother } from '@tests/component/collection/domain/models/CollectionItemsMother' + +const facets = CollectionItemsMother.createItemsFacets() describe('FilterPanel', () => { it('should open and close correctly the off canvas in mobile view', () => { cy.viewport(375, 700) const onItemTypesChange = cy.stub().as('onItemTypesChange') + const onFacetChange = cy.stub().as('onFacetChange') cy.customMount( ) diff --git a/tests/component/sections/collection/collection-items-panel/SelectedFacets.spec.tsx b/tests/component/sections/collection/collection-items-panel/SelectedFacets.spec.tsx new file mode 100644 index 000000000..a198346a1 --- /dev/null +++ b/tests/component/sections/collection/collection-items-panel/SelectedFacets.spec.tsx @@ -0,0 +1,49 @@ +import { SelectedFacets } from '@/sections/collection/collection-items-panel/selected-facets/SelectedFacets' + +describe('SelectedFacets', () => { + it('should render the correct selected facets', () => { + const onRemoveFacet = cy.stub().as('onRemoveFacet') + + cy.customMount( + + ) + }) + + it('should call onRemoveFacet when clicking on a selected facet', () => { + const onRemoveFacet = cy.stub().as('onRemoveFacet') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Bar/ }).click() + + cy.wrap(onRemoveFacet).should('be.calledWith', 'Foo:Bar') + + cy.findByRole('button', { name: /Doe/ }).click() + + cy.wrap(onRemoveFacet).should('be.calledWith', 'Foo:Doe') + }) + + it('should disable the button when loading collection items', () => { + const onRemoveFacet = cy.stub().as('onRemoveFacet') + + cy.customMount( + + ) + + cy.findByRole('button', { name: /Bar/ }).should('be.disabled') + }) +}) diff --git a/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts b/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts index 37e33b7e2..5921e975b 100644 --- a/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts +++ b/tests/e2e-integration/e2e/sections/collection/CollectionItemsPanel.spec.ts @@ -1,6 +1,6 @@ import { CollectionItem } from '@/collection/domain/models/CollectionItemSubset' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' -import { QueryParamKey } from '@/sections/Route.enum' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' import { DatasetHelper } from '@tests/e2e-integration/shared/datasets/DatasetHelper' import { FileHelper } from '@tests/e2e-integration/shared/files/FileHelper' import { TestsUtils } from '@tests/e2e-integration/shared/TestsUtils' @@ -39,6 +39,8 @@ describe('Collection Items Panel', () => { }) beforeEach(async () => { + cy.viewport(1280, 720) + cy.intercept(SEARCH_ENDPOINT_REGEX).as('getCollectionItems') // Creates 8 datasets with 1 file each @@ -97,7 +99,7 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const firstExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + [CollectionItemsQueryParams.TYPES]: [ CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE @@ -108,7 +110,7 @@ describe('Collection Items Panel', () => { }) // 2 - Now perform a search in the input - cy.findByPlaceholderText('Search this collection...').type('Darwin{enter}') + cy.findByPlaceholderText('Search this collection...').type('Darwin{enter}', { force: true }) cy.wait('@getCollectionItems').then((interception) => { const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = @@ -129,12 +131,12 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const secondExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + [CollectionItemsQueryParams.TYPES]: [ CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE ].join(','), - [QueryParamKey.QUERY]: 'Darwin' + [CollectionItemsQueryParams.QUERY]: 'Darwin' }).toString() cy.url().should('include', `/collections?${secondExpectedURL}`) @@ -142,7 +144,7 @@ describe('Collection Items Panel', () => { // 3 - Clear the search and assert that the search is performed correctly and the url is updated correctly cy.findByPlaceholderText('Search this collection...').clear() - cy.findByRole('button', { name: /Search submit/ }).click() + cy.findByRole('button', { name: /Search submit/ }).click({ force: true }) cy.wait('@getCollectionItems').then((interception) => { const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = @@ -163,7 +165,7 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const thirdExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + [CollectionItemsQueryParams.TYPES]: [ CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE @@ -175,7 +177,7 @@ describe('Collection Items Panel', () => { // 4 - Uncheck the Collections checkbox - cy.findByRole('checkbox', { name: /Collections/ }).click() + cy.findByRole('checkbox', { name: /Collections/ }).click({ force: true }) cy.wait('@getCollectionItems').then((interception) => { const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = @@ -196,7 +198,7 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const fourthExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + [CollectionItemsQueryParams.TYPES]: [ CollectionItemType.DATASET, CollectionItemType.FILE ].join(',') @@ -206,7 +208,7 @@ describe('Collection Items Panel', () => { }) // 5 - Uncheck the Dataset checkbox - cy.findByRole('checkbox', { name: /Datasets/ }).click() + cy.findByRole('checkbox', { name: /Datasets/ }).click({ force: true }) cy.wait('@getCollectionItems').then((interception) => { const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = @@ -227,13 +229,13 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const fifthExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [CollectionItemType.FILE].join(',') + [CollectionItemsQueryParams.TYPES]: [CollectionItemType.FILE].join(',') }).toString() cy.url().should('include', `/collections?${fifthExpectedURL}`) }) - //6 - Navigate back with the browser and assert that the url is updated correctly and the items are displayed correctly as in step 4 + // 6 - Navigate back with the browser and assert that the url is updated correctly and the items are displayed correctly as in step 4 cy.go('back') cy.wait('@getCollectionItems').then((interception) => { @@ -255,7 +257,7 @@ describe('Collection Items Panel', () => { cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) const fourthExpectedURL = new URLSearchParams({ - [QueryParamKey.COLLECTION_ITEM_TYPES]: [ + [CollectionItemsQueryParams.TYPES]: [ CollectionItemType.DATASET, CollectionItemType.FILE ].join(',') @@ -263,5 +265,44 @@ describe('Collection Items Panel', () => { cy.url().should('include', `/collections?${fourthExpectedURL}`) }) + + // 7 - Selects a facet filter + cy.findByRole('button', { name: /Finch, Fiona/ }).click() + + cy.wait('@getCollectionItems').then((interception) => { + const { totalItemsInResponse, collectionsInResponse, datasetsInResponse, filesInResponse } = + extractInfoFromInterceptedResponse(interception) + + cy.findByTestId('items-list') + .should('exist') + .children() + .should('have.length', totalItemsInResponse) + + collectionsInResponse.length > 0 && + cy.findAllByTestId('collection-card').should('have.length', collectionsInResponse.length) + + datasetsInResponse.length > 0 && + cy.findAllByTestId('dataset-card').should('have.length', datasetsInResponse.length) + + filesInResponse.length > 0 && + cy.findAllByTestId('file-card').should('have.length', filesInResponse.length) + + const expectedURL = new URLSearchParams({ + [CollectionItemsQueryParams.TYPES]: [ + CollectionItemType.DATASET, + CollectionItemType.FILE + ].join(','), + [CollectionItemsQueryParams.FILTER_QUERIES]: [ + `authorName_ss:${encodeURIComponent('Finch, Fiona')}` + ].join(',') + }).toString() + + cy.url().should('include', `/collections?${expectedURL}`) + + // Assert that the selected facet filter is displayed + cy.findAllByRole('button', { name: /Finch, Fiona/ }) + .should('exist') + .should('have.length', 2) + }) }) }) diff --git a/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx b/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx index 09c19beab..52670d4f8 100644 --- a/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx +++ b/tests/e2e-integration/e2e/sections/homepage/Homepage.spec.tsx @@ -1,5 +1,5 @@ +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' import { CollectionItemType } from '../../../../../src/collection/domain/models/CollectionItemType' -import { QueryParamKey } from '../../../../../src/sections/Route.enum' describe('Homepage', () => { it('should navigate to the collections page with the search value encoded in the URL', () => { @@ -11,9 +11,9 @@ describe('Homepage', () => { const encodedSearchValue = encodeURIComponent(searchValue) const searchParams = new URLSearchParams() - searchParams.set(QueryParamKey.QUERY, encodedSearchValue) + searchParams.set(CollectionItemsQueryParams.QUERY, encodedSearchValue) searchParams.set( - QueryParamKey.COLLECTION_ITEM_TYPES, + CollectionItemsQueryParams.TYPES, [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') )