Skip to content

Commit

Permalink
Merge pull request #191 from bento-platform/feat/search/individual-ma…
Browse files Browse the repository at this point in the history
…tches

feat(search): show individual match results when available
  • Loading branch information
davidlougheed authored Sep 30, 2024
2 parents a7e9692 + 023efe4 commit cc26483
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 59 deletions.
21 changes: 12 additions & 9 deletions src/js/components/Beacon/BeaconQueryUi.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 3 additions & 2 deletions src/js/components/Search/MakeQueryOption.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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;

Expand Down
9 changes: 4 additions & 5 deletions src/js/components/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 ? (
<Loader />
Expand Down
20 changes: 12 additions & 8 deletions src/js/components/Search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -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 !== '';
Expand All @@ -19,6 +22,7 @@ const SearchResults = () => {
hasInsufficientData={hasInsufficientData}
message={message}
individualCount={individualCount}
individualMatches={individualMatches}
biosampleCount={biosampleCount}
biosampleChartData={biosampleChartData}
experimentCount={experimentCount}
Expand Down
114 changes: 84 additions & 30 deletions src/js/components/Search/SearchResultsPane.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) => (
<a href={`${PORTAL_URL}/data/explorer/individuals/${id}`} target="_blank" rel="noreferrer">
{id}
</a>
),
},
],
[td]
);
const individualTableData = useMemo<IndividualResultRow[]>(
() => (individualMatches ?? []).map((id) => ({ id })),
[individualMatches]
);

return (
<div style={{ paddingBottom: 8, display: 'flex', justifyContent: 'center', width: '100%' }}>
<div className="search-results-pane">
<Card
style={{
borderRadius: '10px',
Expand All @@ -47,12 +74,21 @@ const SearchResultsPane = ({
<Row gutter={16}>
<Col xs={24} lg={4}>
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
<Statistic
title={td('Individuals')}
value={hasInsufficientData ? td(message) : individualCount}
valueStyle={{ color: COUNTS_FILL }}
prefix={<TeamOutlined />}
/>
<div
onClick={individualMatches?.length ? () => setPanePage('individuals') : undefined}
className={[
'search-result-statistic',
...(panePage === 'individuals' ? ['selected'] : []),
...(individualMatches?.length ? ['enabled'] : []),
].join(' ')}
>
<Statistic
title={td('Individuals')}
value={hasInsufficientData ? td(message) : individualCount}
valueStyle={{ color: COUNTS_FILL }}
prefix={<TeamOutlined />}
/>
</div>
<Statistic
title={td('Biosamples')}
value={hasInsufficientData ? '----' : biosampleCount}
Expand All @@ -67,26 +103,43 @@ const SearchResultsPane = ({
/>
</Space>
</Col>
<Col xs={24} lg={10}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
{td('Biosamples')}
</Typography.Title>
{!hasInsufficientData && biosampleChartData.length ? (
<PieChart data={biosampleChartData} height={PIE_CHART_HEIGHT} sort={true} dataMap={translateMap} />
) : (
<CustomEmpty text="No Results" />
)}
</Col>
<Col xs={24} lg={10}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
{td('Experiments')}
</Typography.Title>
{!hasInsufficientData && experimentChartData.length ? (
<PieChart data={experimentChartData} height={PIE_CHART_HEIGHT} sort={true} dataMap={translateMap} />
) : (
<CustomEmpty text="No Results" />
)}
</Col>
{panePage === 'charts' ? (
<>
<Col xs={24} lg={10}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
{td('Biosamples')}
</Typography.Title>
{!hasInsufficientData && biosampleChartData.length ? (
<PieChart data={biosampleChartData} height={PIE_CHART_HEIGHT} sort={true} dataMap={translateMap} />
) : (
<CustomEmpty text="No Results" />
)}
</Col>
<Col xs={24} lg={10}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
{td('Experiments')}
</Typography.Title>
{!hasInsufficientData && experimentChartData.length ? (
<PieChart data={experimentChartData} height={PIE_CHART_HEIGHT} sort={true} dataMap={translateMap} />
) : (
<CustomEmpty text="No Results" />
)}
</Col>
</>
) : (
<Col xs={24} lg={20}>
<Button icon={<LeftOutlined />} type="link" onClick={() => setPanePage('charts')}>
{td('Charts')}
</Button>
<Table<IndividualResultRow>
columns={individualTableColumns}
dataSource={individualTableData}
rowKey="id"
bordered={true}
size="small"
/>
</Col>
)}
</Row>
</Card>
</div>
Expand All @@ -98,6 +151,7 @@ export interface SearchResultsPaneProps {
hasInsufficientData: boolean;
message: string;
individualCount: number;
individualMatches?: string[];
biosampleCount: number;
biosampleChartData: ChartData[];
experimentCount: number;
Expand Down
5 changes: 3 additions & 2 deletions src/js/components/Search/SelectOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%' };
Expand All @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions src/js/components/SiteSider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/js/features/search/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { useAppSelector } from '@/hooks';

export const useSearchQuery = () => useAppSelector((state) => state.query);
3 changes: 3 additions & 0 deletions src/js/features/search/query.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type QueryState = {
experimentChartData: ChartData[];
message: string;
individualCount: number;
individualMatches?: string[];
};

const initialState: QueryState = {
Expand All @@ -36,6 +37,7 @@ const initialState: QueryState = {
experimentCount: 0,
experimentChartData: [],
individualCount: 0,
individualMatches: undefined,
};

const query = createSlice({
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/js/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type KatsuSearchResponse =
| {
biosamples: Biosamples;
count: number;
matches?: string[];
experiments: Experiments;
}
| { message: string };
Expand Down
2 changes: 2 additions & 0 deletions src/public/locales/en/default_translation_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"Count": "Count",
"Portal": "Portal",
"missing": "missing",
"Individual": "Individual",
"Individuals": "Individuals",
"in": "in",
"Get Data": "Get Data",
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit cc26483

Please sign in to comment.