diff --git a/src/js/components/Beacon/BeaconQueryUi.tsx b/src/js/components/Beacon/BeaconQueryUi.tsx index 04d4d13e..a0d28d82 100644 --- a/src/js/components/Beacon/BeaconQueryUi.tsx +++ b/src/js/components/Beacon/BeaconQueryUi.tsx @@ -1,15 +1,9 @@ import type { ReactNode } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react'; -import { useAppSelector, useAppDispatch, useTranslationDefault, useQueryWithAuthIfAllowed } from '@/hooks'; import { Button, Card, Col, Form, Row, Space, Tooltip, Typography } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; import { useIsAuthenticated } from 'bento-auth-js'; -import Filters from './Filters'; -import BeaconSearchResults from './BeaconSearchResults'; -import BeaconErrorMessage from './BeaconErrorMessage'; -import VariantsForm from './VariantsForm'; -import { makeBeaconQuery } from '@/features/beacon/beaconQuery.store'; -import type { BeaconQueryPayload, FormFilter, FormValues, PayloadFilter, PayloadVariantsQuery } from '@/types/beacon'; + import { WRAPPER_STYLE, FORM_ROW_GUTTERS, @@ -18,9 +12,18 @@ import { BUTTON_STYLE, CARD_STYLES, } from '@/constants/beaconConstants'; - import { BOX_SHADOW } from '@/constants/overviewConstants'; +import { makeBeaconQuery } from '@/features/beacon/beaconQuery.store'; +import { useSearchQuery } from '@/features/search/hooks'; +import { useAppSelector, useAppDispatch, useTranslationDefault, useQueryWithAuthIfAllowed } from '@/hooks'; +import type { BeaconQueryPayload, FormFilter, FormValues, PayloadFilter, PayloadVariantsQuery } from '@/types/beacon'; + import Loader from '@/components/Loader'; +import Filters from './Filters'; +import BeaconSearchResults from './BeaconSearchResults'; +import BeaconErrorMessage from './BeaconErrorMessage'; +import VariantsForm from './VariantsForm'; + const { Text, Title } = Typography; // TODOs // example searches, either hardcoded or configurable @@ -58,7 +61,7 @@ const BeaconQueryUi = () => { const isFetchingBeaconConfig = useAppSelector((state) => state.beaconConfig.isFetchingBeaconConfig); const beaconAssemblyIds = useAppSelector((state) => state.beaconConfig.beaconAssemblyIds); - const querySections = useAppSelector((state) => state.query.querySections); + const { querySections } = useSearchQuery(); const hasApiError = useAppSelector((state) => state.beaconQuery.hasApiError); const apiErrorMessage = useAppSelector((state) => state.beaconQuery.apiErrorMessage); diff --git a/src/js/components/Search/MakeQueryOption.tsx b/src/js/components/Search/MakeQueryOption.tsx index 5d44836b..731d1469 100644 --- a/src/js/components/Search/MakeQueryOption.tsx +++ b/src/js/components/Search/MakeQueryOption.tsx @@ -1,12 +1,13 @@ import { useCallback } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Row, Col, Checkbox } from 'antd'; import OptionDescription from './OptionDescription'; import SelectOption from './SelectOption'; +import { useSearchQuery } from '@/features/search/hooks'; import { useAppSelector, useTranslationCustom, useTranslationDefault } from '@/hooks'; import type { Field } from '@/types/search'; -import { useLocation, useNavigate } from 'react-router-dom'; import { buildQueryParamsUrl, queryParamsWithoutKey } from '@/utils/search'; const MakeQueryOption = ({ queryField }: MakeQueryOptionProps) => { @@ -19,7 +20,7 @@ const MakeQueryOption = ({ queryField }: MakeQueryOptionProps) => { const { title, id, description, config, options } = queryField; const { maxQueryParameters } = useAppSelector((state) => state.config); - const { queryParamCount, queryParams } = useAppSelector((state) => state.query); + const { queryParamCount, queryParams } = useSearchQuery(); const isChecked = id in queryParams; diff --git a/src/js/components/Search/Search.tsx b/src/js/components/Search/Search.tsx index 08b0aa61..5593716b 100644 --- a/src/js/components/Search/Search.tsx +++ b/src/js/components/Search/Search.tsx @@ -9,8 +9,9 @@ import { makeGetKatsuPublic, setQueryParams } from '@/features/search/query.stor import { useAppDispatch, useAppSelector, useTranslationCustom } from '@/hooks'; import { buildQueryParamsUrl } from '@/utils/search'; -import type { QueryParams } from '@/types/search'; import Loader from '@/components/Loader'; +import { useSearchQuery } from '@/features/search/hooks'; +import type { QueryParams } from '@/types/search'; const checkQueryParamsEqual = (qp1: QueryParams, qp2: QueryParams): boolean => { const qp1Keys = Object.keys(qp1); @@ -32,7 +33,7 @@ const RoutedSearch = () => { isFetchingData: isFetchingSearchData, attemptedFieldsFetch, attemptedFetch, - } = useAppSelector((state) => state.query); + } = useSearchQuery(); // TODO: allow disabling max query parameters for authenticated and authorized users when Katsu has AuthZ // const maxQueryParametersRequired = useAppSelector((state) => state.config.maxQueryParametersRequired); @@ -117,9 +118,7 @@ const SEARCH_SECTION_STYLE = { maxWidth: 1200 }; const Search = () => { const t = useTranslationCustom(); - const { isFetchingFields: isFetchingSearchFields, querySections: searchSections } = useAppSelector( - (state) => state.query - ); + const { isFetchingFields: isFetchingSearchFields, querySections: searchSections } = useSearchQuery(); return isFetchingSearchFields ? ( diff --git a/src/js/components/Search/SearchResults.tsx b/src/js/components/Search/SearchResults.tsx index 8a16e1cd..5ae84d99 100644 --- a/src/js/components/Search/SearchResults.tsx +++ b/src/js/components/Search/SearchResults.tsx @@ -1,14 +1,17 @@ -import { useAppSelector } from '@/hooks'; +import { useSearchQuery } from '@/features/search/hooks'; import SearchResultsPane from './SearchResultsPane'; const SearchResults = () => { - const isFetchingData = useAppSelector((state) => state.query.isFetchingData); - const biosampleCount = useAppSelector((state) => state.query.biosampleCount); - const biosampleChartData = useAppSelector((state) => state.query.biosampleChartData); - const experimentCount = useAppSelector((state) => state.query.experimentCount); - const experimentChartData = useAppSelector((state) => state.query.experimentChartData); - const individualCount = useAppSelector((state) => state.query.individualCount); - const message = useAppSelector((state) => state.query.message); + const { + isFetchingData, + biosampleCount, + biosampleChartData, + experimentCount, + experimentChartData, + individualCount, + individualMatches, + message, + } = useSearchQuery(); // existing code treats non-empty message as sign of insufficient data const hasInsufficientData = message !== ''; @@ -19,6 +22,7 @@ const SearchResults = () => { hasInsufficientData={hasInsufficientData} message={message} individualCount={individualCount} + individualMatches={individualMatches} biosampleCount={biosampleCount} biosampleChartData={biosampleChartData} experimentCount={experimentCount} diff --git a/src/js/components/Search/SearchResultsPane.tsx b/src/js/components/Search/SearchResultsPane.tsx index 1412ab18..4ea09ea2 100644 --- a/src/js/components/Search/SearchResultsPane.tsx +++ b/src/js/components/Search/SearchResultsPane.tsx @@ -1,19 +1,25 @@ -import { useCallback } from 'react'; -import { Card, Col, Row, Statistic, Typography, Space } from 'antd'; -import { TeamOutlined } from '@ant-design/icons'; +import { useCallback, useMemo, useState } from 'react'; +import { Card, Col, Row, Statistic, Typography, Space, Table, Button } from 'antd'; +import { LeftOutlined, TeamOutlined } from '@ant-design/icons'; import { BiDna } from 'react-icons/bi'; import { PieChart } from 'bento-charts'; + import CustomEmpty from '../Util/CustomEmpty'; import ExpSvg from '../Util/ExpSvg'; + +import { PORTAL_URL } from '@/config'; import { BOX_SHADOW, COUNTS_FILL, PIE_CHART_HEIGHT } from '@/constants/overviewConstants'; import { useTranslationDefault, useTranslationCustom } from '@/hooks'; import type { ChartData } from '@/types/data'; +type IndividualResultRow = { id: string }; + const SearchResultsPane = ({ isFetchingData, hasInsufficientData, message, individualCount, + individualMatches, biosampleCount, biosampleChartData, experimentCount, @@ -23,8 +29,29 @@ const SearchResultsPane = ({ const t = useTranslationCustom(); const translateMap = useCallback(({ x, y }: { x: string; y: number }) => ({ x: t(x), y }), [t]); + const [panePage, setPanePage] = useState<'charts' | 'individuals'>('charts'); + + const individualTableColumns = useMemo( + () => [ + { + dataIndex: 'id', + title: td('Individual'), + render: (id: string) => ( + + {id} + + ), + }, + ], + [td] + ); + const individualTableData = useMemo( + () => (individualMatches ?? []).map((id) => ({ id })), + [individualMatches] + ); + return ( -
+
- } - /> +
setPanePage('individuals') : undefined} + className={[ + 'search-result-statistic', + ...(panePage === 'individuals' ? ['selected'] : []), + ...(individualMatches?.length ? ['enabled'] : []), + ].join(' ')} + > + } + /> +
- - - {td('Biosamples')} - - {!hasInsufficientData && biosampleChartData.length ? ( - - ) : ( - - )} - - - - {td('Experiments')} - - {!hasInsufficientData && experimentChartData.length ? ( - - ) : ( - - )} - + {panePage === 'charts' ? ( + <> + + + {td('Biosamples')} + + {!hasInsufficientData && biosampleChartData.length ? ( + + ) : ( + + )} + + + + {td('Experiments')} + + {!hasInsufficientData && experimentChartData.length ? ( + + ) : ( + + )} + + + ) : ( + + + + columns={individualTableColumns} + dataSource={individualTableData} + rowKey="id" + bordered={true} + size="small" + /> + + )}
@@ -98,6 +151,7 @@ export interface SearchResultsPaneProps { hasInsufficientData: boolean; message: string; individualCount: number; + individualMatches?: string[]; biosampleCount: number; biosampleChartData: ChartData[]; experimentCount: number; diff --git a/src/js/components/Search/SelectOption.tsx b/src/js/components/Search/SelectOption.tsx index e700dbc0..f1ce9dc5 100644 --- a/src/js/components/Search/SelectOption.tsx +++ b/src/js/components/Search/SelectOption.tsx @@ -3,7 +3,8 @@ import { useCallback, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Select } from 'antd'; -import { useAppSelector, useTranslationCustom } from '@/hooks'; +import { useTranslationCustom } from '@/hooks'; +import { useSearchQuery } from '@/features/search/hooks'; import { buildQueryParamsUrl } from '@/utils/search'; const SELECT_STYLE: CSSProperties = { width: '100%' }; @@ -14,7 +15,7 @@ const SelectOption = ({ id, isChecked, options }: SelectOptionProps) => { const { pathname } = useLocation(); const navigate = useNavigate(); - const { queryParams } = useAppSelector((state) => state.query); + const { queryParams } = useSearchQuery(); const defaultValue = queryParams[id] || options[0]; const handleValueChange = useCallback( diff --git a/src/js/components/SiteSider.tsx b/src/js/components/SiteSider.tsx index a1b7e61c..aac2a4a3 100644 --- a/src/js/components/SiteSider.tsx +++ b/src/js/components/SiteSider.tsx @@ -7,10 +7,11 @@ import type { MenuProps, SiderProps } from 'antd'; import Icon, { PieChartOutlined, SearchOutlined, SolutionOutlined } from '@ant-design/icons'; import BeaconSvg from '@/components/Beacon/BeaconSvg'; -import { useAppSelector, useTranslationDefault } from '@/hooks'; +import { useSearchQuery } from '@/features/search/hooks'; +import { useTranslationDefault } from '@/hooks'; +import { BentoRoute } from '@/types/routes'; import { buildQueryParamsUrl } from '@/utils/search'; import { getCurrentPage } from '@/utils/router'; -import { BentoRoute } from '@/types/routes'; const { Sider } = Layout; @@ -27,7 +28,7 @@ const SiteSider: React.FC<{ const navigate = useNavigate(); const location = useLocation(); const td = useTranslationDefault(); - const queryParams = useAppSelector((state) => state.query.queryParams); + const { queryParams } = useSearchQuery(); const currentPage = getCurrentPage(); const handleMenuClick: OnClick = useCallback( diff --git a/src/js/features/search/hooks.ts b/src/js/features/search/hooks.ts new file mode 100644 index 00000000..af6b20e0 --- /dev/null +++ b/src/js/features/search/hooks.ts @@ -0,0 +1,3 @@ +import { useAppSelector } from '@/hooks'; + +export const useSearchQuery = () => useAppSelector((state) => state.query); diff --git a/src/js/features/search/query.store.ts b/src/js/features/search/query.store.ts index 0de16664..95ed868e 100644 --- a/src/js/features/search/query.store.ts +++ b/src/js/features/search/query.store.ts @@ -20,6 +20,7 @@ export type QueryState = { experimentChartData: ChartData[]; message: string; individualCount: number; + individualMatches?: string[]; }; const initialState: QueryState = { @@ -36,6 +37,7 @@ const initialState: QueryState = { experimentCount: 0, experimentChartData: [], individualCount: 0, + individualMatches: undefined, }; const query = createSlice({ @@ -64,6 +66,7 @@ const query = createSlice({ state.experimentCount = payload.experiments.count; state.experimentChartData = serializeChartData(payload.experiments.experiment_type); state.individualCount = payload.count; + state.individualMatches = payload.matches; // Undefined if no permissions }); builder.addCase(makeGetKatsuPublic.rejected, (state) => { state.isFetchingData = false; diff --git a/src/js/types/search.ts b/src/js/types/search.ts index d333968f..240c7c71 100644 --- a/src/js/types/search.ts +++ b/src/js/types/search.ts @@ -36,6 +36,7 @@ export type KatsuSearchResponse = | { biosamples: Biosamples; count: number; + matches?: string[]; experiments: Experiments; } | { message: string }; diff --git a/src/public/locales/en/default_translation_en.json b/src/public/locales/en/default_translation_en.json index f1f519a7..82ebcad0 100644 --- a/src/public/locales/en/default_translation_en.json +++ b/src/public/locales/en/default_translation_en.json @@ -5,6 +5,7 @@ "Count": "Count", "Portal": "Portal", "missing": "missing", + "Individual": "Individual", "Individuals": "Individuals", "in": "in", "Get Data": "Get Data", @@ -43,6 +44,7 @@ "Dates": "Dates", "Description": "Description", "Download DATS File": "Download DATS File", + "Charts": "Charts", "Manage Charts": "Manage Charts", "Remove this chart": "Remove this chart", "Width": "Width", diff --git a/src/public/locales/fr/default_translation_fr.json b/src/public/locales/fr/default_translation_fr.json index 37a2abdf..86106965 100644 --- a/src/public/locales/fr/default_translation_fr.json +++ b/src/public/locales/fr/default_translation_fr.json @@ -5,6 +5,7 @@ "Count": "Observations", "Portal": "Portail", "missing": "manquant(s)", + "Individual": "Participant", "Individuals": "Participants", "in": "en", "Get Data": "Obtenir", @@ -43,6 +44,7 @@ "Dates": "Dates", "Description": "Description", "Download DATS File": "Télécharcher le fichier DATS", + "Charts": "Tableaux", "Manage Charts": "Gestion des tableaux", "Remove this chart": "Supprimer ce tableau", "Width": "Largeur", diff --git a/src/styles.css b/src/styles.css index 6ab5c840..bbce5bf8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -59,3 +59,35 @@ body { background-color: #0958d9; color: #91caff; } + +.search-results-pane { + padding-bottom: 8px; + display: flex; + justify-content: center; + width: 100%; +} + +/* BEGIN search results pane */ + +.search-result-statistic { + padding: 8px 16px; + margin-left: -16px; + border-radius: 8px; +} +.search-result-statistic.enabled { + cursor: pointer; + border: 1px solid #d9d9d9; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.02); + transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); /* from antd */ +} +.search-result-statistic.enabled:hover { + border-color: #4096ff; +} +.search-result-statistic.selected { + background-color: #fafafa; +} +.search-result-statistic.enabled.selected { + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.02), inset 0 2px 5px rgba(0, 0, 0, 0.08); +} + +/* END search results pane */