diff --git a/package-lock.json b/package-lock.json index df9042009..007c3c4ac 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": "v2.0.0-pr344.7810d21", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.61", "@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-pr344.7810d21", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr344.7810d21/78d5c45a70f60d36ff6001ad8b5bc34e090c9f4a", - "integrity": "sha512-oUTS/09YXFztuFqG0CZrSPQi18dnPVkxoErK3f2fwFGg7i6evKTdaFwxYPVg3zhguP+wygSokXsRD+ZVd5NHAg==", + "version": "2.0.0-alpha.61", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.61/1be6ec895a4d6dbc875b4a10727c4741c852e6b0", + "integrity": "sha512-225/ihgNRBxcivu8tYfbB+21QT5eSEYDqKSLrUQh61aZa74w407axjsWp1sVEPZcLqZpr+CgI9JppoGTeS4qnA==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 8c8cb92c6..abf8590f5 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": "v2.0.0-pr344.5fd4982", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.61", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -193,4 +193,4 @@ "overrides": { "@parcel/watcher": "2.1.0" } -} +} \ No newline at end of file diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 476a900fa..580bc8a1d 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# Non Published Changes + +- **SelectAdvanced:** Fix word wrapping in options list to prevent overflow and ensure long text is displayed correctly. + # [2.0.2](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@2.0.1...@iqss/dataverse-design-system@2.0.2) (2024-06-23) ### Bug Fixes diff --git a/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss index 957e076d4..cc86a8093 100644 --- a/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss +++ b/packages/design-system/src/lib/components/select-advanced/SelectAdvanced.module.scss @@ -154,6 +154,7 @@ &__checkbox-input { display: flex; + flex-wrap: wrap; align-items: center; padding-left: 0; @@ -164,15 +165,16 @@ } label { - width: 100%; padding-left: 0.5rem; padding-block: 0.25rem; + white-space: wrap; } } } .option-item-not-multiple { margin-bottom: 0.125rem; + white-space: wrap; cursor: pointer; transition: background-color 0.1s ease-in-out; } diff --git a/public/locales/en/advancedSearch.json b/public/locales/en/advancedSearch.json new file mode 100644 index 000000000..c0e2a58aa --- /dev/null +++ b/public/locales/en/advancedSearch.json @@ -0,0 +1,97 @@ +{ + "pageTitle": "Advanced Search", + "clearForm": "Clear Form", + "collections": { + "name": { + "label": "Name", + "description": "The project, department, university, professor, or journal this collection will contain data for.", + "invalid": { + "maxLength": "Name must be at most {{maxLength}} characters." + } + }, + "affiliation": { + "label": "Affiliation", + "description": "The organization with which this collection is affiliated.", + "invalid": { + "maxLength": "Affiliation must be at most {{maxLength}} characters." + } + }, + "alias": { + "label": "Identifier", + "description": "Short name used for the URL of this collection.", + "invalid": { + "maxLength": "Identifier must be at most {{maxLength}} characters." + } + }, + "description": { + "label": "Description", + "description": "A summary describing the purpose, nature or scope of this collection.", + "invalid": { + "maxLength": "Description must be at most {{maxLength}} characters." + } + }, + "subject": { + "label": "Subject", + "description": "Domain-specific Subject Categories that are topically relevant to this Collection.", + "invalid": { + "maxLength": "Subject must be at most {{maxLength}} characters." + } + } + }, + "files": { + "name": { + "label": "Name", + "description": "The name given to identify the file.", + "invalid": { + "maxLength": "Name must be at most {{maxLength}} characters." + } + }, + "description": { + "label": "Description", + "description": "A summary describing the file and its variables.", + "invalid": { + "maxLength": "Description must be at most {{maxLength}} characters." + } + }, + "fileType": { + "label": "File Type", + "description": "The file type, e.g. Comma Separated Values, Plain Text, R, etc.", + "invalid": { + "maxLength": "File Type must be at most {{maxLength}} characters." + } + }, + "dataFilePersistentId": { + "label": "Data File Persistent ID", + "description": "The unique persistent identifier for the file.", + "invalid": { + "maxLength": "Data File Persistent ID must be at most {{maxLength}} characters." + } + }, + "variableName": { + "label": "Variable Name", + "description": "The name of the variable's column in the data frame.", + "invalid": { + "maxLength": "Variable Name must be at most {{maxLength}} characters." + } + }, + "variableLabel": { + "label": "Variable Label", + "description": "A short description of the variable.", + "invalid": { + "maxLength": "Variable Label must be at most {{maxLength}} characters." + } + }, + "fileTags": { + "label": "File Tags", + "description": "Terms such as \"Documentation\", \"Data\", or \"Code\" that have been applied to files.", + "invalid": { + "maxLength": "File Tags must be at most {{maxLength}} characters." + } + } + }, + "datasets": { + "invalid": { + "maxLength": "{{fieldName}} must be at most {{maxLength}} characters." + } + } +} diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 6dd1a6a17..255191f07 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -33,6 +33,7 @@ "datasetFilterTypeLabel": "Datasets", "fileFilterTypeLabel": "Files", "searchThisCollectionPlaceholder": "Search this collection...", + "advancedSearch": "Advanced Search", "publish": { "title": "Publish Collection", "button": "Publish", diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index cc77f33fa..5a2f5462b 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -1,6 +1,8 @@ { "collection": "Collection", + "collections": "Collections", "dataset": "Dataset", + "datasets": "Datasets", "file": "File", "files": "Files", "page": "Page", @@ -24,6 +26,7 @@ "submitSearch": "Submit Search", "dragHandleLabel": "press space to select and keys to drag", "unknown": "Unknown", + "find": "Find", "pageNumberNotFound": { "heading": "Page Number Not Found", "message": "The page number you requested does not exist. Please try a different page number." diff --git a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts index 0ec642c33..606555279 100644 --- a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts +++ b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts @@ -24,6 +24,7 @@ export interface MetadataField { isControlledVocabulary: boolean displayFormat: string isRequired: boolean + isAdvancedSearchFieldType: boolean displayOrder: number controlledVocabularyValues?: string[] childMetadataFields?: Record diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 889e3d05e..7331ef96f 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -117,6 +117,12 @@ const SignUpPage = lazy(() => })) ) +const AdvancedSearchPage = lazy(() => + import('../sections/advanced-search/AdvancedSearchFactory').then(({ AdvancedSearchFactory }) => ({ + default: () => AdvancedSearchFactory.create() + })) +) + export const routes: RouteObject[] = [ { element: , @@ -180,6 +186,15 @@ export const routes: RouteObject[] = [ ), errorElement: }, + { + path: Route.ADVANCED_SEARCH, + element: ( + }> + + + ), + errorElement: + }, { path: Route.AUTH_CALLBACK, element: diff --git a/src/search/domain/models/SearchFields.ts b/src/search/domain/models/SearchFields.ts new file mode 100644 index 000000000..1e8c1e708 --- /dev/null +++ b/src/search/domain/models/SearchFields.ts @@ -0,0 +1,21 @@ +/** + * This class is inspired by https://github.com/IQSS/dataverse/blob/develop/src/main/java/edu/harvard/iq/dataverse/search/SearchFields.java + */ + +export class SearchFields { + public static readonly DATAVERSE_NAME = 'dvName' + public static readonly DATAVERSE_ALIAS = 'dvAlias' + public static readonly DATAVERSE_AFFILIATION = 'dvAffiliation' + public static readonly DATAVERSE_DESCRIPTION = 'dvDescription' + public static readonly DATAVERSE_SUBJECT = 'dvSubject' + + public static readonly FILE_NAME = 'fileName' + public static readonly FILE_DESCRIPTION = 'fileDescription' + public static readonly FILE_TYPE_SEARCHABLE = 'fileType' + public static readonly FILE_PERSISTENT_ID = 'filePersistentId' + + public static readonly VARIABLE_NAME = 'variableName' + public static readonly VARIABLE_LABEL = 'variableLabel' + + public static readonly FILE_TAG_SEARCHABLE = 'fileTags' +} diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 54f372888..2adbe8334 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -22,7 +22,8 @@ export enum Route { FEATURED_ITEM = '/featured-item/:parentCollectionId/:featuredItemId', NOT_FOUND_PAGE = '/404', AUTH_CALLBACK = '/auth-callback', - SIGN_UP = '/sign-up' + SIGN_UP = '/sign-up', + ADVANCED_SEARCH = '/collections/:collectionId/search' } export const RouteWithParams = { @@ -66,7 +67,8 @@ export const RouteWithParams = { return `/files/replace?${searchParams.toString()}` }, FEATURED_ITEM: (parentCollectionId: string, featuredItemId: string) => - `/featured-item/${parentCollectionId}/${featuredItemId}` + `/featured-item/${parentCollectionId}/${featuredItemId}`, + ADVANCED_SEARCH: (collectionId: string) => `/collections/${collectionId}/search` } export enum QueryParamKey { diff --git a/src/sections/account/my-data-section/MyDataItemsPanel.tsx b/src/sections/account/my-data-section/MyDataItemsPanel.tsx index 4c6bda73b..a9c7b9801 100644 --- a/src/sections/account/my-data-section/MyDataItemsPanel.tsx +++ b/src/sections/account/my-data-section/MyDataItemsPanel.tsx @@ -9,7 +9,7 @@ import { ItemsListType } from '@/sections/collection/collection-items-panel/items-list/ItemsList' import { MyDataFilterPanel } from '@/sections/account/my-data-section/my-data-filter-panel/MyDataFilterPanel' -import { SearchPanel } from '@/sections/collection/collection-items-panel/search-panel/SearchPanel' +import { SearchInput } from '@/sections/collection/collection-items-panel/search-input/SearchInput' import { ItemTypeChange } from '@/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters' import { MyDataSearchCriteria } from '@/sections/account/my-data-section/MyDataSearchCriteria' import { useGetMyDataAccumulatedItems } from '@/sections/account/my-data-section/useGetMyDataAccumulatedItems' @@ -269,7 +269,7 @@ export const MyDataItemsPanel = ({
- { + const { t } = useTranslation('advancedSearch') + const { setIsLoading } = useLoading() + const [previousAdvancedSearchFormData, setPreviousAdvancedSearchFormData] = + useState(null) + + const { collection, isLoading: isLoadingCollection } = useCollection( + collectionRepository, + collectionId + ) + const { + metadataBlocksInfo, + isLoading: isLoadingCollectionMetadataBlocks, + error: errorCollectionMetadataBlocks + } = useGetCollectionMetadataBlocksInfo({ + collectionId, + metadataBlockInfoRepository + }) + + const isLoadingData = isLoadingCollection || isLoadingCollectionMetadataBlocks + + useEffect(() => { + if (!isLoadingData) { + setIsLoading(false) + } + }, [isLoadingData, setIsLoading]) + + useEffect(() => { + const previousAdvancedSearchData = + AdvancedSearchHelper.getPreviousAdvancedSearchQueryFromLocalStorage() + + // Check if the local storage data matches the current collectionId + if (previousAdvancedSearchData?.collectionId === collectionId) { + setPreviousAdvancedSearchFormData(previousAdvancedSearchData.formData) + } else { + // Otherwise, delete the local storage entry + AdvancedSearchHelper.clearPreviousAdvancedSearchQueryFromLocalStorage() + } + }, [collectionId]) + + if (!isLoadingCollection && !collection) { + return + } + + if (errorCollectionMetadataBlocks) { + return ( +
+ {errorCollectionMetadataBlocks} +
+ ) + } + + if (isLoadingData || isLoadingCollectionMetadataBlocks || !collection) { + return + } + + const normalizedMetadataBlocksInfo = + MetadataFieldsHelper.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfo) + + const formDefaultValues = AdvancedSearchHelper.getFormDefaultValues( + normalizedMetadataBlocksInfo, + previousAdvancedSearchFormData + ) + + return ( +
+ +
+

{t('pageTitle')}

+
+ + + + +
+ ) +} diff --git a/src/sections/advanced-search/AdvancedSearchFactory.tsx b/src/sections/advanced-search/AdvancedSearchFactory.tsx new file mode 100644 index 000000000..3aae7eb34 --- /dev/null +++ b/src/sections/advanced-search/AdvancedSearchFactory.tsx @@ -0,0 +1,33 @@ +import { ReactElement } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' +import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { MetadataBlockInfoJSDataverseRepository } from '@/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' +import { AdvancedSearch } from './AdvancedSearch' + +const collectionRepository = new CollectionJSDataverseRepository() +const metadataBlockInfoRepository = new MetadataBlockInfoJSDataverseRepository() + +export class AdvancedSearchFactory { + static create(): ReactElement { + return + } +} + +function AdvancedSearchWithSearchParams() { + const { collectionId } = useParams<{ collectionId: string }>() as { + collectionId: string + } + const [searchParams] = useSearchParams() + const collectionPageCurrentFilterQueries = + searchParams.get(CollectionItemsQueryParams.FILTER_QUERIES) ?? undefined + + return ( + + ) +} diff --git a/src/sections/advanced-search/AdvancedSearchHelper.ts b/src/sections/advanced-search/AdvancedSearchHelper.ts new file mode 100644 index 000000000..8f2503448 --- /dev/null +++ b/src/sections/advanced-search/AdvancedSearchHelper.ts @@ -0,0 +1,315 @@ +import { + MetadataBlockInfo, + MetadataField, + TypeClassMetadataFieldOptions +} from '@/metadata-block-info/domain/models/MetadataBlockInfo' +import { Utils } from '@/shared/helpers/Utils' +import { + AdvancedSearchFormData, + CollectionsFields, + FilesFields +} from './advanced-search-form/AdvancedSearchForm' +import { SearchFields } from '@/search/domain/models/SearchFields' +import { MetadataFieldsHelper } from '../shared/form/DatasetMetadataForm/MetadataFieldsHelper' + +export class AdvancedSearchHelper { + public static previousAdvancedSearchQueryLSKey = 'previousAdvancedSearchQuery' + + public static filterSearchableMetadataBlockFields( + blocks: MetadataBlockInfo[] + ): MetadataBlockInfo[] { + return blocks.map((block) => { + const flattenedFields: Record = {} + + for (const field of Object.values(block.metadataFields)) { + const { childMetadataFields, ...rest } = field + + const isCompound = field.typeClass === 'compound' + const isSearchable = field.isAdvancedSearchFieldType + + if (!isCompound && isSearchable) { + flattenedFields[field.name] = rest + } + + // Always include child fields, if any + if (childMetadataFields) { + for (const [childKey, childField] of Object.entries(childMetadataFields)) { + if (childField.isAdvancedSearchFieldType) { + flattenedFields[childKey] = childField + } + } + } + } + + return { + ...block, + metadataFields: flattenedFields + } + }) + } + + public static getFormDefaultValues( + metadataBlocks: MetadataBlockInfo[], + previousAdvancedSearchFormData: AdvancedSearchFormData | null + ): AdvancedSearchFormData { + const searchableMetadataBlockFields = this.filterSearchableMetadataBlockFields(metadataBlocks) + + const flattenedMetadataFields: Record = {} + + for (const block of searchableMetadataBlockFields) { + for (const field of Object.values(block.metadataFields)) { + const isControlledVocabulary = + field.typeClass === TypeClassMetadataFieldOptions.ControlledVocabulary + + flattenedMetadataFields[field.name] = isControlledVocabulary + ? previousAdvancedSearchFormData?.datasets[field.name] || [] + : previousAdvancedSearchFormData?.datasets[field.name] || '' + } + } + + return { + collections: { + [SearchFields.DATAVERSE_NAME]: + previousAdvancedSearchFormData?.collections?.[SearchFields.DATAVERSE_NAME] || '', + [SearchFields.DATAVERSE_ALIAS]: + previousAdvancedSearchFormData?.collections?.[SearchFields.DATAVERSE_ALIAS] || '', + [SearchFields.DATAVERSE_AFFILIATION]: + previousAdvancedSearchFormData?.collections?.[SearchFields.DATAVERSE_AFFILIATION] || '', + [SearchFields.DATAVERSE_DESCRIPTION]: + previousAdvancedSearchFormData?.collections?.[SearchFields.DATAVERSE_DESCRIPTION] || '', + [SearchFields.DATAVERSE_SUBJECT]: + previousAdvancedSearchFormData?.collections?.[SearchFields.DATAVERSE_SUBJECT] || [] + }, + datasets: { + ...flattenedMetadataFields + }, + files: { + [SearchFields.FILE_NAME]: + previousAdvancedSearchFormData?.files?.[SearchFields.FILE_NAME] || '', + [SearchFields.FILE_DESCRIPTION]: + previousAdvancedSearchFormData?.files?.[SearchFields.FILE_DESCRIPTION] || '', + [SearchFields.FILE_TYPE_SEARCHABLE]: + previousAdvancedSearchFormData?.files?.[SearchFields.FILE_TYPE_SEARCHABLE] || '', + [SearchFields.FILE_PERSISTENT_ID]: + previousAdvancedSearchFormData?.files?.[SearchFields.FILE_PERSISTENT_ID] || '', + [SearchFields.VARIABLE_NAME]: + previousAdvancedSearchFormData?.files?.[SearchFields.VARIABLE_NAME] || '', + [SearchFields.VARIABLE_LABEL]: + previousAdvancedSearchFormData?.files?.[SearchFields.VARIABLE_LABEL] || '', + [SearchFields.FILE_TAG_SEARCHABLE]: + previousAdvancedSearchFormData?.files?.[SearchFields.FILE_TAG_SEARCHABLE] || '' + } + } + } + + public static constructSearchQuery(formData: AdvancedSearchFormData): string { + const collectionQuery = this.constructCollectionQuery(formData.collections) + const datasetQuery = this.constructDatasetQuery(formData.datasets) + const fileQuery = this.constructFileQuery(formData.files) + + const queries: string[] = [] + + if (collectionQuery) { + queries.push(collectionQuery) + } + + if (datasetQuery) { + queries.push(datasetQuery) + } + + if (fileQuery) { + queries.push(fileQuery) + } + + const query = this.constructQuery(queries, false, false) + + return query + } + + private static constructCollectionQuery(fields: CollectionsFields): string { + const queryStrings: string[] = [] + + if (fields[SearchFields.DATAVERSE_NAME]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.DATAVERSE_NAME, + fields[SearchFields.DATAVERSE_NAME].trim() + ) + ) + } + + if (fields[SearchFields.DATAVERSE_ALIAS]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.DATAVERSE_ALIAS, + fields[SearchFields.DATAVERSE_ALIAS].trim() + ) + ) + } + + if (fields[SearchFields.DATAVERSE_AFFILIATION]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.DATAVERSE_AFFILIATION, + fields[SearchFields.DATAVERSE_AFFILIATION].trim() + ) + ) + } + + if (fields[SearchFields.DATAVERSE_DESCRIPTION]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.DATAVERSE_DESCRIPTION, + fields[SearchFields.DATAVERSE_DESCRIPTION].trim() + ) + ) + } + + if (fields[SearchFields.DATAVERSE_SUBJECT].length > 0) { + const subjectQueries = fields[SearchFields.DATAVERSE_SUBJECT].map( + (value) => `${SearchFields.DATAVERSE_SUBJECT}:"${value}"` + ) + queryStrings.push(this.constructQuery(subjectQueries, false)) + } + + return this.constructQuery(queryStrings, true) + } + + private static constructFileQuery(fields: FilesFields): string { + const queryStrings: string[] = [] + + if (fields[SearchFields.FILE_NAME]?.trim()) { + queryStrings.push( + this.constructFieldQuery(SearchFields.FILE_NAME, fields[SearchFields.FILE_NAME].trim()) + ) + } + + if (fields[SearchFields.FILE_DESCRIPTION]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.FILE_DESCRIPTION, + fields[SearchFields.FILE_DESCRIPTION].trim() + ) + ) + } + + if (fields[SearchFields.FILE_TYPE_SEARCHABLE]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.FILE_TYPE_SEARCHABLE, + fields[SearchFields.FILE_TYPE_SEARCHABLE].trim() + ) + ) + } + + if (fields[SearchFields.FILE_PERSISTENT_ID]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.FILE_PERSISTENT_ID, + fields[SearchFields.FILE_PERSISTENT_ID].trim() + ) + ) + } + + if (fields[SearchFields.VARIABLE_NAME]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.VARIABLE_NAME, + fields[SearchFields.VARIABLE_NAME].trim() + ) + ) + } + + if (fields[SearchFields.VARIABLE_LABEL]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.VARIABLE_LABEL, + fields[SearchFields.VARIABLE_LABEL].trim() + ) + ) + } + + if (fields[SearchFields.FILE_TAG_SEARCHABLE]?.trim()) { + queryStrings.push( + this.constructFieldQuery( + SearchFields.FILE_TAG_SEARCHABLE, + fields[SearchFields.FILE_TAG_SEARCHABLE].trim() + ) + ) + } + + return this.constructQuery(queryStrings, true) + } + + private static constructDatasetQuery(fields: Record) { + const queryStrings: string[] = [] + + for (const [field, value] of Object.entries(fields)) { + // Replace back again slashes with dots + const originalField = MetadataFieldsHelper.replaceSlashWithDot(field) + + if (Array.isArray(value) && value.length > 0) { + const arrayQueries = value.map((value) => `${originalField}:"${value}"`) + queryStrings.push(this.constructQuery(arrayQueries, false)) + } else if (typeof value === 'string' && value.trim()) { + queryStrings.push(this.constructFieldQuery(originalField, value.trim())) + } + } + + return this.constructQuery(queryStrings, true) + } + + private static constructQuery( + queryStrings: string[], + isAnd: boolean, + surroundWithParens = true + ): string { + const nonEmpty = queryStrings.filter((str) => str.trim() !== '') + + if (nonEmpty.length === 0) return '' + + const combined = nonEmpty.join(isAnd ? ' AND ' : ' OR ') + + return surroundWithParens && nonEmpty.length > 1 ? `(${combined})` : combined + } + + private static constructFieldQuery(field: string, value: string): string { + const words = value.trim().split(' ') + + if (words.length === 1) { + return `${field}:${words[0]}` + } + const joinedWords = words.map((word) => `${field}:${word}`).join(' ') + + return `(${joinedWords})` + } + + public static saveAdvancedSearchQueryToLocalStorage( + collectionId: string, + formData: AdvancedSearchFormData + ): void { + localStorage.setItem( + this.previousAdvancedSearchQueryLSKey, + JSON.stringify({ + collectionId, + formData + }) + ) + } + + public static getPreviousAdvancedSearchQueryFromLocalStorage(): { + collectionId: string + formData: AdvancedSearchFormData + } | null { + const localStoragePreviousAdvancedSearchQuery = Utils.getLocalStorageItem<{ + collectionId: string + formData: AdvancedSearchFormData + }>(AdvancedSearchHelper.previousAdvancedSearchQueryLSKey) + + return localStoragePreviousAdvancedSearchQuery + } + + public static clearPreviousAdvancedSearchQueryFromLocalStorage(): void { + localStorage.removeItem(this.previousAdvancedSearchQueryLSKey) + } +} diff --git a/src/sections/advanced-search/advanced-search-form/AdvancedSearchForm.tsx b/src/sections/advanced-search/advanced-search-form/AdvancedSearchForm.tsx new file mode 100644 index 000000000..39ce3bbcf --- /dev/null +++ b/src/sections/advanced-search/advanced-search-form/AdvancedSearchForm.tsx @@ -0,0 +1,163 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { FormProvider, useForm } from 'react-hook-form' +import { ArrowClockwise } from 'react-bootstrap-icons' +import { Accordion, Button } from '@iqss/dataverse-design-system' +import { MetadataBlockInfo } from '@/metadata-block-info/domain/models/MetadataBlockInfo' +import { SearchFields } from '@/search/domain/models/SearchFields' +import { MetadataBlockName } from '@/dataset/domain/models/Dataset' +import { CollectionItemsQueryParams } from '@/collection/domain/models/CollectionItemsQueryParams' +import { Route } from '@/sections/Route.enum' +import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { CollectionsSearchFields } from './CollectionsSearchFields' +import { MetadataBlockSearchFields } from './MetadataBlockSearchFields' +import { FilesSearchFields } from './FilesSearchFields' +import { AdvancedSearchHelper } from '../AdvancedSearchHelper' + +interface AdvancedSearchFormProps { + collectionId: string + formDefaultValues: AdvancedSearchFormData + metadataBlocks: MetadataBlockInfo[] + collectionFilterQueries?: string +} + +export interface AdvancedSearchFormData { + collections: CollectionsFields + datasets: DatasetsFields + files: FilesFields +} + +export interface CollectionsFields { + [SearchFields.DATAVERSE_NAME]: string + [SearchFields.DATAVERSE_ALIAS]: string + [SearchFields.DATAVERSE_AFFILIATION]: string + [SearchFields.DATAVERSE_DESCRIPTION]: string + [SearchFields.DATAVERSE_SUBJECT]: string[] +} + +export type DatasetsFields = Record + +export type FilesFields = { + [SearchFields.FILE_NAME]: string + [SearchFields.FILE_DESCRIPTION]: string + [SearchFields.FILE_TYPE_SEARCHABLE]: string + [SearchFields.FILE_PERSISTENT_ID]: string + [SearchFields.VARIABLE_NAME]: string + [SearchFields.VARIABLE_LABEL]: string + [SearchFields.FILE_TAG_SEARCHABLE]: string +} + +export const AdvancedSearchForm = ({ + collectionId, + formDefaultValues, + metadataBlocks, + collectionFilterQueries +}: AdvancedSearchFormProps) => { + const { t } = useTranslation('shared') + const { t: tAdvancedSearch } = useTranslation('advancedSearch') + const [resetKey, setResetKey] = useState(0) + const navigate = useNavigate() + + const formMethods = useForm({ + mode: 'onChange', + defaultValues: formDefaultValues + }) + + const handleSubmit = (data: AdvancedSearchFormData) => { + const advancedSearchQuery = AdvancedSearchHelper.constructSearchQuery(data) + const searchParams = new URLSearchParams() + searchParams.set(CollectionItemsQueryParams.QUERY, advancedSearchQuery) + searchParams.set( + CollectionItemsQueryParams.TYPES, + [CollectionItemType.COLLECTION, CollectionItemType.DATASET, CollectionItemType.FILE].join(',') + ) + // We navigate to the collection page with the previous filter queries if they exist + if (collectionFilterQueries) { + searchParams.set(CollectionItemsQueryParams.FILTER_QUERIES, collectionFilterQueries) + } + + navigate(`${Route.COLLECTIONS_BASE}/${collectionId}?${searchParams.toString()}`) + + AdvancedSearchHelper.saveAdvancedSearchQueryToLocalStorage(collectionId, data) + } + + const handleClearForm = () => { + formMethods.reset(AdvancedSearchHelper.getFormDefaultValues(metadataBlocks, null)) + AdvancedSearchHelper.clearPreviousAdvancedSearchQueryFromLocalStorage() // Clear local storage in case there was a previous search saved. + setResetKey((prev) => prev + 1) // This is a workaround to force re-render components that depend on the form values. + } + + const subjectFieldControlledVocab: string[] = useMemo( + () => + metadataBlocks.find((block) => block.name === MetadataBlockName.CITATION)?.metadataFields[ + 'subject' + ].controlledVocabularyValues as string[], + [metadataBlocks] + ) + + const metadataBlockNames: string[] = metadataBlocks.map((block) => block.name) + + const searchableMetadataBlockFields = + AdvancedSearchHelper.filterSearchableMetadataBlockFields(metadataBlocks) + + return ( + +
+
+ + +
+ + + + {t('collections')} + + + + + + {/* Datasets Metadata blocks */} + {searchableMetadataBlockFields.map((metadataBlock) => { + if (Object.keys(metadataBlock.metadataFields).length === 0) { + return null // Skip empty metadata blocks + } + + return ( + + + {`${t('datasets')}: ${metadataBlock.displayName}`} + + + + + + ) + })} + + {/* Files */} + + {t('files')} + + + + + + + +
+
+ ) +} diff --git a/src/sections/advanced-search/advanced-search-form/CollectionsSearchFields.tsx b/src/sections/advanced-search/advanced-search-form/CollectionsSearchFields.tsx new file mode 100644 index 000000000..c285058aa --- /dev/null +++ b/src/sections/advanced-search/advanced-search-form/CollectionsSearchFields.tsx @@ -0,0 +1,158 @@ +import { useTranslation } from 'react-i18next' +import { Controller, UseControllerProps, useFormContext } from 'react-hook-form' +import { Col, Form } from '@iqss/dataverse-design-system' +import { SearchFields } from '@/search/domain/models/SearchFields' + +interface CollectionsSearchFieldsProps { + subjectControlledVocabulary: string[] +} + +export const CollectionsSearchFields = ({ + subjectControlledVocabulary +}: CollectionsSearchFieldsProps) => { + const { t } = useTranslation('advancedSearch', { keyPrefix: 'collections' }) + const { control } = useFormContext() + + const rules: (localeKey: string) => UseControllerProps['rules'] = (localeKey: string) => ({ + maxLength: { + value: 100, + message: t(`${localeKey}.invalid.maxLength`, { maxLength: 100 }) + } + }) + + return ( +
+ + + {t('name.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('alias.label')} + + + ( + + + {error?.message} + + )} + /> + + + + + {t('affiliation.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('description.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('subject.label')} + + ( + + + {error?.message} + + )} + /> + +
+ ) +} diff --git a/src/sections/advanced-search/advanced-search-form/FilesSearchFields.tsx b/src/sections/advanced-search/advanced-search-form/FilesSearchFields.tsx new file mode 100644 index 000000000..54ffa1485 --- /dev/null +++ b/src/sections/advanced-search/advanced-search-form/FilesSearchFields.tsx @@ -0,0 +1,197 @@ +import { useTranslation } from 'react-i18next' +import { Controller, UseControllerProps, useFormContext } from 'react-hook-form' +import { Col, Form } from '@iqss/dataverse-design-system' +import { SearchFields } from '@/search/domain/models/SearchFields' + +export const FilesSearchFields = () => { + const { t } = useTranslation('advancedSearch', { keyPrefix: 'files' }) + const { control } = useFormContext() + + const rules: (localeKey: string) => UseControllerProps['rules'] = (localeKey: string) => ({ + maxLength: { + value: 100, + message: t(`${localeKey}.invalid.maxLength`, { maxLength: 100 }) + } + }) + + return ( + <> + + + {t('name.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('description.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('fileType.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('dataFilePersistentId.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('variableName.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('variableLabel.label')} + + ( + + + {error?.message} + + )} + /> + + + + + {t('fileTags.label')} + + ( + + + {error?.message} + + )} + /> + + + ) +} diff --git a/src/sections/advanced-search/advanced-search-form/MetadataBlockSearchFields.tsx b/src/sections/advanced-search/advanced-search-form/MetadataBlockSearchFields.tsx new file mode 100644 index 000000000..eea844e29 --- /dev/null +++ b/src/sections/advanced-search/advanced-search-form/MetadataBlockSearchFields.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from 'react-i18next' +import { Controller, useFormContext } from 'react-hook-form' +import { Col, Form } from '@iqss/dataverse-design-system' +import { + MetadataField, + TypeClassMetadataFieldOptions +} from '@/metadata-block-info/domain/models/MetadataBlockInfo' + +interface MetadataBlockSearchFieldsProps { + metadataFields: Record +} + +export const MetadataBlockSearchFields = ({ metadataFields }: MetadataBlockSearchFieldsProps) => { + return ( + <> + {Object.entries(metadataFields).map(([fieldKey, fieldInfo]) => { + const isControlledVocabulary = + fieldInfo.typeClass === TypeClassMetadataFieldOptions.ControlledVocabulary + + if (isControlledVocabulary) { + return + } + + return + })} + + ) +} + +interface TextFieldProps { + fieldInfo: MetadataField +} + +const TextField = ({ fieldInfo }: TextFieldProps) => { + const { t } = useTranslation('advancedSearch', { keyPrefix: 'datasets' }) + const { control } = useFormContext() + + return ( + + + {fieldInfo.displayName} + + ( + + + {error?.message} + + )} + /> + + ) +} + +interface VocabularyMultipleFieldProps { + fieldInfo: MetadataField +} + +const VocabularyMultipleField = ({ fieldInfo }: VocabularyMultipleFieldProps) => { + const { t } = useTranslation('advancedSearch', { keyPrefix: 'datasets' }) + const { control } = useFormContext() + + return ( + + + {fieldInfo.displayName} + + ( + + 10} + onChange={onChange} + isInvalid={invalid} + ref={ref} + inputButtonId={`datasets.${fieldInfo.name}`} + /> + {error?.message} + + )} + /> + + ) +} diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss index f852e9d6c..efb6c6204 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.module.scss @@ -1,4 +1,5 @@ @import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import 'src/assets/mixins'; .items-panel { display: flex; @@ -11,15 +12,25 @@ flex-direction: column; gap: 0.5rem; + .add-data-slot { + align-self: flex-end; + } + + .advanced-search-link { + @include link-hover-underlined; + + width: fit-content; + min-width: fit-content; + margin-right: auto; + font-size: 14px; + } + @media (min-width: 768px) { flex-direction: row; + gap: 1rem; align-items: center; justify-content: space-between; } - - .add-data-slot { - align-self: flex-end; - } } .bottom-wrapper { diff --git a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx index 39397f69b..492801de4 100644 --- a/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx +++ b/src/sections/collection/collection-items-panel/CollectionItemsPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { useSearchParams } from 'react-router-dom' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import { Stack } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' @@ -23,9 +23,10 @@ import { ItemsList, ItemsListType } from '@/sections/collection/collection-items-panel/items-list/ItemsList' -import { SearchPanel } from '@/sections/collection/collection-items-panel/search-panel/SearchPanel' +import { SearchInput } from '@/sections/collection/collection-items-panel/search-input/SearchInput' import { ItemTypeChange } from '@/sections/collection/collection-items-panel/filter-panel/type-filters/TypeFilters' import { SelectedFacets } from '@/sections/collection/collection-items-panel/selected-facets/SelectedFacets' +import { RouteWithParams } from '@/sections/Route.enum' import styles from './CollectionItemsPanel.module.scss' interface CollectionItemsPanelProps { @@ -305,15 +306,36 @@ export const CollectionItemsPanel = ({ setIsLoading(isLoadingItems) }, [isLoadingItems, setIsLoading]) + const advancedSearchLinkURL: string = useMemo(() => { + const searchParams = new URLSearchParams() + if (currentSearchCriteria.filterQueries && currentSearchCriteria.filterQueries.length > 0) { + const filterQueriesWithFacetValueEncoded = currentSearchCriteria.filterQueries.map((fq) => { + const [facetName, facetValue] = fq.split(':') + return `${facetName}:${encodeURIComponent(facetValue)}` + }) + + searchParams.set( + CollectionItemsQueryParams.FILTER_QUERIES, + filterQueriesWithFacetValueEncoded.join(',') + ) + } + return `${RouteWithParams.ADVANCED_SEARCH(collectionId)}?${searchParams.toString()}` + }, [collectionId, currentSearchCriteria.filterQueries]) + return (
- + + + {t('advancedSearch')} + +
{addDataSlot}
diff --git a/src/sections/collection/collection-items-panel/search-input/SearchInput.module.scss b/src/sections/collection/collection-items-panel/search-input/SearchInput.module.scss new file mode 100644 index 000000000..1974d5018 --- /dev/null +++ b/src/sections/collection/collection-items-panel/search-input/SearchInput.module.scss @@ -0,0 +1,9 @@ +.search-form { + @media (min-width: 768px) { + min-width: 325px; + } +} + +.search-input-group { + margin-bottom: 0 !important; +} diff --git a/src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx b/src/sections/collection/collection-items-panel/search-input/SearchInput.tsx similarity index 50% rename from src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx rename to src/sections/collection/collection-items-panel/search-input/SearchInput.tsx index a91053135..311e7d88a 100644 --- a/src/sections/collection/collection-items-panel/search-panel/SearchPanel.tsx +++ b/src/sections/collection/collection-items-panel/search-input/SearchInput.tsx @@ -2,21 +2,21 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button, Form } from '@iqss/dataverse-design-system' import { Search } from 'react-bootstrap-icons' -import styles from './SearchPanel.module.scss' +import styles from './SearchInput.module.scss' -interface SearchPanelProps { +interface SearchInputProps { currentSearchValue?: string isLoadingCollectionItems: boolean onSubmitSearch: (searchValue: string) => void placeholderText: string } -export const SearchPanel = ({ +export const SearchInput = ({ currentSearchValue = '', isLoadingCollectionItems, onSubmitSearch, placeholderText -}: SearchPanelProps) => { +}: SearchInputProps) => { const { t } = useTranslation('shared') const [searchValue, setSearchValue] = useState(currentSearchValue) @@ -38,28 +38,23 @@ export const SearchPanel = ({ }, [currentSearchValue]) return ( -
-
- - - */} -
+
+ + +