From 1d29a7ef354c9a0f15df80b86fdce7ce2f86e12c Mon Sep 17 00:00:00 2001 From: Aymeric Toulouse Date: Wed, 25 Sep 2024 12:13:03 -0400 Subject: [PATCH] feat: CQDG-849 implement quick filter --- src/graphql/quickFilter/queries.ts | 126 ++++++++ src/locales/en.ts | 6 + src/locales/fr.ts | 6 + src/utils/translation.ts | 6 + src/views/DataExploration/index.tsx | 283 +++++++++++++++++- .../DataExploration/utils/quickFilter.ts | 127 ++++++++ 6 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 src/graphql/quickFilter/queries.ts create mode 100644 src/views/DataExploration/utils/quickFilter.ts diff --git a/src/graphql/quickFilter/queries.ts b/src/graphql/quickFilter/queries.ts new file mode 100644 index 00000000..1ab5275a --- /dev/null +++ b/src/graphql/quickFilter/queries.ts @@ -0,0 +1,126 @@ +import { gql } from '@apollo/client'; + +export const GET_QUICK_FILTER_EXPLO = gql` + query getQuickFilterExploFacets($sqon: JSON) { + Participant { + aggregations(filters: $sqon, include_missing: false) { + study__study_code { + buckets { + key + doc_count + } + } + observed_phenotypes__name { + buckets { + key + doc_count + } + } + mondo__name { + buckets { + key + doc_count + } + } + icd_tagged__name { + buckets { + key + doc_count + } + } + relationship_to_proband { + buckets { + key + doc_count + } + } + sex { + buckets { + key + doc_count + } + } + age_at_recruitment { + buckets { + key + doc_count + } + } + mondo_tagged__age_at_event { + buckets { + key + doc_count + } + } + ethnicity { + buckets { + key + doc_count + } + } + observed_phenotype_tagged__source_text { + buckets { + key + doc_count + } + } + mondo_tagged__source_text { + buckets { + key + doc_count + } + } + + biospecimens__sample_type { + buckets { + key + doc_count + } + } + biospecimens__biospecimen_tissue_source { + buckets { + key + doc_count + } + } + biospecimens__age_biospecimen_collection { + buckets { + key + doc_count + } + } + + files__dataset { + buckets { + key + doc_count + } + } + files__data_category { + buckets { + key + doc_count + } + } + files__data_type { + buckets { + key + doc_count + } + } + files__sequencing_experiment__experimental_strategy { + buckets { + key + doc_count + } + } + files__file_format { + buckets { + key + doc_count + } + } + } + } + } +`; diff --git a/src/locales/en.ts b/src/locales/en.ts index 51d4f007..be3a9c24 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -513,6 +513,12 @@ const en = { next: 'Next', view: '{value} / view', }, + quickFilter: { + emptyMessage: 'Min. 3 characters', + menuTitle: 'Quick filter', + placeholder: 'Search...', + results: 'Results', + }, seeLess: 'See less', seeMore: 'See more', ferload: 'Ferload', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index c82a83c8..0fd1678a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -515,6 +515,12 @@ const fr = { next: 'Suivant', view: '{value} / écran', }, + quickFilter: { + emptyMessage: 'Min. 3 caractères', + menuTitle: 'Quick filter', + placeholder: 'Chercher...', + results: 'Résultats', + }, seeLess: 'Voir moins', seeMore: 'Voir plus', ferload: 'Ferload', diff --git a/src/utils/translation.ts b/src/utils/translation.ts index c6c4bf54..4e6444e0 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -114,6 +114,12 @@ export const getFiltersDictionary = (): FiltersDict => ({ messages: { errorNoData: intl.get('global.filters.messages.empty'), }, + quickFilter: { + emptyMessage: intl.get('global.quickFilter.emptyMessage'), + menuTitle: intl.get('global.quickFilter.menuTitle'), + placeholder: intl.get('global.quickFilter.placeholder'), + results: intl.get('global.quickFilter.results'), + }, }); export const getQueryBuilderDictionary = ( diff --git a/src/views/DataExploration/index.tsx b/src/views/DataExploration/index.tsx index 108eae9a..c80b3357 100644 --- a/src/views/DataExploration/index.tsx +++ b/src/views/DataExploration/index.tsx @@ -1,11 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; import intl from 'react-intl-universal'; +import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { ExperimentOutlined, FileSearchOutlined, UserOutlined } from '@ant-design/icons'; +import { + ExperimentOutlined, + FileSearchOutlined, + SearchOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { + IFilter, + IFilterGroup, + TExtendedMapping, + VisualType, +} from '@ferlab/ui/core/components/filters/types'; +import useQueryBuilderState, { + updateActiveQueryField, + updateActiveQueryFilters, +} from '@ferlab/ui/core/components/QueryBuilder/utils/useQueryBuilderState'; import SidebarMenu, { ISidebarMenuItem } from '@ferlab/ui/core/components/SidebarMenu'; +import { + CheckboxQFOption, + FacetOption, + TitleQFOption, +} from '@ferlab/ui/core/components/SidebarMenu/QuickFilter'; +import { underscoreToDot } from '@ferlab/ui/core/data/arranger/formatting'; +import { getFilterGroup, getFilterType } from '@ferlab/ui/core/data/filters/utils'; +import { TermOperators } from '@ferlab/ui/core/data/sqon/operators'; +import { MERGE_VALUES_STRATEGIES } from '@ferlab/ui/core/data/sqon/types'; +import { getSelectedFilters } from '@ferlab/ui/core/data/sqon/utils'; +import { IExtendedMappingResults, TAggregationBuckets } from '@ferlab/ui/core/graphql/types'; import ScrollContent from '@ferlab/ui/core/layout/ScrollContent'; +import { removeUnderscoreAndCapitalize, titleCase } from '@ferlab/ui/core/utils/stringUtils'; import { Spin } from 'antd'; import { INDEXES } from 'graphql/constants'; import { ExtendedMappingResults } from 'graphql/models'; +import { AGGREGATION_QUERY } from 'graphql/queries'; +import { GET_QUICK_FILTER_EXPLO } from 'graphql/quickFilter/queries'; +import { getFilters } from 'graphql/utils/Filters'; +import capitalize from 'lodash/capitalize'; +import get from 'lodash/get'; import PageContent from 'views/DataExploration/components/PageContent'; import FileSearch from 'views/DataExploration/components/Searchs/FileSearch'; import FileSetSearch from 'views/DataExploration/components/Searchs/FileSetSearch'; @@ -26,14 +60,28 @@ import { import FilterList, { TCustomFilterMapper } from 'components/uiKit/FilterList'; import useGetExtendedMappings from 'hooks/graphql/useGetExtendedMappings'; +import { WrapperApi } from 'services/api/wrapper'; +import { remoteSliceActions } from 'store/remote/slice'; import { RemoteComponentList } from 'store/remote/types'; import { mapFilterForBiospecimen, mapFilterForFiles, mapFilterForParticipant, } from 'utils/fieldMapper'; +import { + getFacetsDictionary, + getFiltersDictionary, + getQueryBuilderDictionary, +} from 'utils/translation'; import { formatHpoTitleAndCode, formatMondoTitleAndCode } from './utils/helper'; +import { + getFieldWithoutPrefix, + getIndexFromQFValueFacet, + getSelectedOptionsByQuery, + getSqonForQuickFilterFacetValue, + getSqonForQuickFilterFacetView, +} from './utils/quickFilter'; import styles from './index.module.css'; @@ -139,11 +187,228 @@ const filtersContainer = ( }; const DataExploration = () => { + const dispatch = useDispatch(); const { tab } = useParams<{ tab: string }>(); + const { activeQuery } = useQueryBuilderState(DATA_EXPLORATION_QB_ID); const participantMappingResults = useGetExtendedMappings(INDEXES.PARTICIPANT); const fileMappingResults = useGetExtendedMappings(INDEXES.FILE); const biospecimenMappingResults = useGetExtendedMappings(INDEXES.BIOSPECIMEN); const studyMappingResults = useGetExtendedMappings(INDEXES.STUDY); + const [isLoading, setIsLoading] = useState(false); + const [quickFilterData, setQuickFilterData] = useState<{ Participant: { aggregations: any } }>(); + const [forceClose, setForceClose] = useState(false); + + const quickfilterOpenRemote = (field: string): boolean => { + if (field === 'observed_phenotype__name') { + dispatch( + remoteSliceActions.openRemoteComponent({ + id: RemoteComponentList.HPOTree, + props: { + visible: true, + }, + }), + ); + return true; + } + if (field === 'mondo__name') { + dispatch( + remoteSliceActions.openRemoteComponent({ + id: RemoteComponentList.MondoTree, + props: { + visible: true, + }, + }), + ); + + return true; + } + + return false; + }; + + const fetchFacets = useCallback(async () => { + const { data } = await WrapperApi.graphqlRequest<{ + data: { Participant: { aggregations: any } }; + }>({ + query: GET_QUICK_FILTER_EXPLO.loc?.source.body, + variables: { + sqon: getSqonForQuickFilterFacetValue(activeQuery), + }, + }); + if (data) setQuickFilterData(data?.data); + }, [JSON.stringify(activeQuery)]); + + useEffect(() => { + fetchFacets(); + }, [fetchFacets]); + + const getMappingByIndex = (index: string): IExtendedMappingResults => { + switch (index) { + case INDEXES.BIOSPECIMEN: + return biospecimenMappingResults; + case INDEXES.FILE: + return fileMappingResults; + case INDEXES.PARTICIPANT: + default: + return participantMappingResults; + } + }; + + const getQFSuggestions = async ( + searchText: string, + setOptions: React.Dispatch>, + setTotal: React.Dispatch>, + setSelectedOptions: React.Dispatch>, + ) => { + setIsLoading(true); + let totalResult = 0; + const regexp = new RegExp('(?:^|\\W)' + searchText, 'gi'); + const facetDictionary = getFacetsDictionary(); + const suggestions: (TitleQFOption | CheckboxQFOption)[] = []; + + Object.entries(quickFilterData?.Participant.aggregations).forEach(([key, value]) => { + const facetName = get( + facetDictionary, + underscoreToDot(getFieldWithoutPrefix(key)), + removeUnderscoreAndCapitalize(getFieldWithoutPrefix(key)).replace(' ', ' '), + ); + const facetType = participantMappingResults.data.find( + (mapping) => mapping.field === underscoreToDot(key), + )?.type; + + const facetValueMapping = getQueryBuilderDictionary(() => facetName).query + ?.facetValueMapping?.[underscoreToDot(key)]; + + const bucketFiltered: (TitleQFOption | CheckboxQFOption)[] = []; + + (value as TAggregationBuckets)?.buckets?.map((bucket: { key: string; doc_count: number }) => { + const label = capitalize(facetValueMapping?.[bucket.key]) || titleCase(bucket.key); + const index = getIndexFromQFValueFacet(key); + + if (regexp.exec(label)) { + ++totalResult; + bucketFiltered.push({ + key: bucket.key, + label, + docCount: bucket.doc_count, + type: facetType ? getFilterType(facetType) : VisualType.Checkbox, + facetKey: key, + index: index, + }); + } + }); + + const isFacetNameMatch = regexp.exec(facetName); + if (isFacetNameMatch || bucketFiltered.length > 0) { + if (isFacetNameMatch) ++totalResult; + + suggestions.push({ + key: key, + label: facetName, + type: 'title', + index: getIndexFromQFValueFacet(key), + }); + suggestions.push(...bucketFiltered); + } + }); + + setSelectedOptions(getSelectedOptionsByQuery(activeQuery)); + setTotal(totalResult); + setOptions(suggestions); + setIsLoading(false); + }; + + const handleFacetClick = async ( + setFacetOptions: React.Dispatch>, + option: TitleQFOption, + ) => { + setIsLoading(true); + + if (quickfilterOpenRemote(option.key)) { + setForceClose(true); + return; + } + + const { data } = await WrapperApi.graphqlRequest<{ + data: any; + }>({ + query: AGGREGATION_QUERY( + option.index, + [getFieldWithoutPrefix(option.key)], + getMappingByIndex(option.index), + ).loc?.source.body, + + variables: { + sqon: getSqonForQuickFilterFacetView(activeQuery, option.index), + }, + }); + + const found = (getMappingByIndex(option.index)?.data || []).find( + (f: TExtendedMapping) => f.field === underscoreToDot(getFieldWithoutPrefix(option.key)), + ); + + const getAgg = () => { + switch (option.index) { + case INDEXES.BIOSPECIMEN: + return data?.data.biospecimen.aggregations[getFieldWithoutPrefix(option.key)]; + case INDEXES.FILE: + return data?.data.file.aggregations[getFieldWithoutPrefix(option.key)]; + case INDEXES.PARTICIPANT: + default: + return data?.data.participant.aggregations[getFieldWithoutPrefix(option.key)]; + } + }; + + const aggregations = getAgg(); + + const filterGroup = getFilterGroup({ + extendedMapping: found, + aggregation: aggregations, + rangeTypes: [], + filterFooter: false, + headerTooltip: false, + dictionary: getFacetsDictionary(), + noDataInputOption: false, + }); + + const filters = + getFilters({ [`${option.key}`]: aggregations as TAggregationBuckets }, option.key) || []; + + const onChange = (fg: IFilterGroup, f: IFilter[]) => { + updateActiveQueryFilters({ + queryBuilderId: DATA_EXPLORATION_QB_ID, + filterGroup: fg, + selectedFilters: f, + index: getIndexFromQFValueFacet(option.key), + }); + }; + + const selectedFilters = getSelectedFilters({ + queryBuilderId: DATA_EXPLORATION_QB_ID, + filters, + filterGroup, + }); + + setFacetOptions({ + filterGroup, + filters, + onChange, + selectedFilters, + }); + setIsLoading(false); + }; + + const addQFOptionsToQB = (options: CheckboxQFOption[], operator: TermOperators) => + options.forEach((option: CheckboxQFOption) => + updateActiveQueryField({ + queryBuilderId: DATA_EXPLORATION_QB_ID, + field: underscoreToDot(getFieldWithoutPrefix(option.facetKey)), + value: [option.key], + index: option.index, + merge_strategy: MERGE_VALUES_STRATEGIES.APPEND_VALUES, + operator, + }), + ); const menuItems: ISidebarMenuItem[] = [ { @@ -195,7 +460,21 @@ const DataExploration = () => { queryBuilderField={'mondo.name'} titleFormatter={formatMondoTitleAndCode} /> - + , + isLoading, + forceClose, + handleClear: () => setForceClose(false), + }} + /> { + if (facetKey.startsWith('files__biospecimens__')) return INDEXES.BIOSPECIMEN; + else if (facetKey.startsWith('files__')) return INDEXES.FILE; + else return INDEXES.PARTICIPANT; +}; + +export const getFieldWithoutPrefix = (facetKey: string): string => { + if (facetKey.startsWith('files__biospecimens__')) return facetKey.slice(21); + else if (facetKey.startsWith('files__')) return facetKey.slice(7); + else return facetKey; +}; + +export const getFieldWithPrefixParticipant = (index: string, field: string): string => { + switch (index) { + case INDEXES.BIOSPECIMEN: + return `files.biospecimens.${field}`; + case INDEXES.FILE: + return `files.${field}`; + default: + return field; + } +}; + +export const getFieldWithPrefixBiospecimen = (index: string, field: string): string => { + switch (index) { + case INDEXES.PARTICIPANT: + return `participant.${field}`; + case INDEXES.FILE: + return `files.${field}`; + default: + return field; + } +}; + +export const getFieldWithPrefixFile = (index: string, field: string): string => { + switch (index) { + case INDEXES.PARTICIPANT: + return `participants.${field}`; + case INDEXES.BIOSPECIMEN: + return `participants.biospecimens.${field}`; + default: + return field; + } +}; + +export const getFieldWithPrefixUnderscore = (index: string, field: string): string => { + switch (index) { + case INDEXES.BIOSPECIMEN: + return `files__biospecimens__${field}`; + case INDEXES.FILE: + return `files__${field}`; + default: + return field; + } +}; + +export const getSqonForQuickFilterFacetValue = (activeQuery: ISyntheticSqon): ISyntheticSqon => { + const activeQueryUpdated = cloneDeep(activeQuery); + activeQueryUpdated.content.forEach((sqonContent: TSyntheticSqonContentValue) => { + const originalIndex = (sqonContent as IValueFilter).content.index; + const originalField = (sqonContent as IValueFilter).content.field; + const fieldPrefixed = originalIndex + ? getFieldWithPrefixParticipant(originalIndex, originalField) + : originalField; + (sqonContent as IValueFilter).content.index = INDEXES.PARTICIPANT; + (sqonContent as IValueFilter).content.field = fieldPrefixed; + }); + return activeQueryUpdated; +}; + +export const getSqonForQuickFilterFacetView = ( + activeQuery: ISyntheticSqon, + index: string, +): ISyntheticSqon => { + const activeQueryUpdated = cloneDeep(activeQuery); + activeQueryUpdated.content.forEach((sqonContent: TSyntheticSqonContentValue) => { + const originalIndex = (sqonContent as IValueFilter).content.index; + const originalField = (sqonContent as IValueFilter).content.field; + let fieldPrefixed = originalField; + if (index === INDEXES.PARTICIPANT && originalIndex) + fieldPrefixed = getFieldWithPrefixParticipant(originalIndex, originalField); + else if (index === INDEXES.BIOSPECIMEN && originalIndex) + fieldPrefixed = getFieldWithPrefixBiospecimen(originalIndex, originalField); + else if (index === INDEXES.FILE && originalIndex) + fieldPrefixed = getFieldWithPrefixFile(originalIndex, originalField); + (sqonContent as IValueFilter).content.index = INDEXES.PARTICIPANT; + (sqonContent as IValueFilter).content.field = fieldPrefixed; + }); + return activeQueryUpdated; +}; + +export const getSelectedOptionsByQuery = (activeQuery: ISyntheticSqon) => { + const selectedOptions: CheckboxQFOption[] = []; + + activeQuery.content.forEach((sqonContent: TSyntheticSqonContentValue) => { + const originalIndex = (sqonContent as IValueFilter).content.index; + const originalField = (sqonContent as IValueFilter).content.field; + const fieldPrefixed = originalIndex + ? getFieldWithPrefixUnderscore(originalIndex, originalField) + : originalField; + + (sqonContent as IValueFilter).content.value.forEach((v) => { + if (typeof v === 'string') { + const option: CheckboxQFOption = { + key: v, + label: v, + type: VisualType.Checkbox, + index: originalIndex || INDEXES.PARTICIPANT, + docCount: 0, + facetKey: fieldPrefixed, + }; + selectedOptions.push(option); + } + }); + }); + return selectedOptions; +};