Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Beacon network UI #165

Merged
merged 105 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
cdfce1e
factor out beacon tooltips
gsfk May 15, 2024
893925a
add beacon network tab
gsfk May 15, 2024
e597806
temp network types
gsfk May 16, 2024
a18382b
redux for beacon network config
gsfk May 28, 2024
331fcc7
updates to beacon types
gsfk May 29, 2024
6010861
dead code
gsfk May 29, 2024
929dabc
second pass at network config redux
gsfk May 29, 2024
3a0f373
redux for single network beacon
gsfk May 29, 2024
e329bfd
first pass at beacon network query redux
gsfk May 29, 2024
81de8e4
beacon network redux
gsfk May 31, 2024
d95ee5f
beacon typing additions
gsfk May 31, 2024
f0a7a92
first pass of card for network beacon
gsfk May 31, 2024
1a7a63b
beacon org
gsfk May 31, 2024
7f45677
add network to tabbed dashboard
gsfk May 31, 2024
abafc28
add beacon network redux to store
gsfk May 31, 2024
69d9c27
first pass at common beacon / beacon network form UI
gsfk Jun 5, 2024
c8d64f0
network beacon redux
gsfk Jun 5, 2024
c729ec9
reducer for network overview
gsfk Jun 7, 2024
9e13ecb
toggle for network query filters
gsfk Jun 7, 2024
dd62264
first pass at beacon common filters components
gsfk Jun 7, 2024
36bd7a3
compute overview in client
gsfk Jun 10, 2024
6ebe900
network search results
gsfk Jun 10, 2024
a1340a6
add content to header of network search results
gsfk Jun 10, 2024
aa78fc0
add skeleton to beacon counts
gsfk Jun 11, 2024
bb50d87
network types
gsfk Jun 11, 2024
37ae4c3
temp fake stuff for network UI
gsfk Jun 11, 2024
3754560
eslint does not like obj.hasOwnProperty()
gsfk Jun 11, 2024
74be53c
first pass at network beacon details
gsfk Jun 11, 2024
f6abac7
misc network fixes and cleanup
gsfk Jun 11, 2024
508d14f
fix temp network root url
gsfk Jun 11, 2024
b2c4c04
fix network query url
gsfk Jun 11, 2024
c8996a2
temp beacon network root for demo
gsfk Jun 11, 2024
523eecb
remove unclear set theory operators
gsfk Jun 14, 2024
ac129ee
fix bento url for network beacons
gsfk Jun 19, 2024
7f8dda4
Merge branch 'main' into features/beacon-network
gsfk Jun 20, 2024
410b5a3
network routing for new side menu
gsfk Jun 20, 2024
3894394
have a single parent folder for beacon components
gsfk Jun 20, 2024
b8bfff2
no real need for a loader here
gsfk Jun 20, 2024
40a0749
common beacon filter components (for beacon & beacon network)
gsfk Jul 2, 2024
ca3a4ee
more beacon variants components to Beacon/BeaconCommon
gsfk Jul 3, 2024
da6bbf0
more more shared beacon stuff to BeaconCommon
gsfk Jul 3, 2024
7cffb5c
first pass at smaller beacon network detail
gsfk Jul 25, 2024
a2f760a
add small beacon network grid
gsfk Jul 25, 2024
c74d262
beacon network redux fixes
gsfk Aug 23, 2024
4ecef7a
rm hardcoding for beacon network url
gsfk Aug 23, 2024
3e73622
temp beacon network typing fixes
gsfk Aug 23, 2024
c0d2ebd
handle beacon network error in UI
gsfk Aug 26, 2024
71c201f
temp fix beacon types
gsfk Aug 27, 2024
71ab0a3
network beacon details
gsfk Aug 27, 2024
776ae36
network stats skeleton
gsfk Aug 28, 2024
8d0ae86
smaller beacon network cards
gsfk Sep 10, 2024
64ee366
first pass of beacon org description
gsfk Sep 10, 2024
48adb35
Merge branch 'main' into features/beacon-network
gsfk Sep 10, 2024
35eddf9
temp config setting
gsfk Sep 10, 2024
a6095cf
Merge branch 'main' into features/beacon-network
gsfk Sep 18, 2024
c1a93c3
plain redux thunk for beacon network query
gsfk Sep 19, 2024
12da924
env handling
gsfk Sep 19, 2024
052a5c4
add beacon stuff to network router
gsfk Sep 19, 2024
c2d847a
comment
gsfk Sep 19, 2024
2de084d
beacon network: cleanup imports, whitespace, dead code
gsfk Sep 19, 2024
39207d3
cleaner handling for beacon network redux
gsfk Sep 20, 2024
4cd517c
"isWaiting" for beacon network query
gsfk Sep 20, 2024
707c336
merge go -> webpack changes in beacon network branch
gsfk Sep 26, 2024
e602b21
handle network beacon api error
gsfk Sep 27, 2024
fed9cab
deduplicate code for beacon search form (use same code for network an…
gsfk Oct 1, 2024
f0ee31b
typing improvements for beacon / beacon network
gsfk Oct 1, 2024
95126e5
beacon network cleanup
gsfk Oct 3, 2024
7297afb
make a constants for no-results dashes
gsfk Oct 3, 2024
88bf361
disambiguate beacon network node from local beacon
gsfk Oct 3, 2024
6b12537
Merge branch 'main' into features/beacon-network
gsfk Oct 3, 2024
39c653f
linting
gsfk Oct 3, 2024
9adcc64
beacon query callbacks
gsfk Oct 3, 2024
703c0fa
more conditions inside hook
gsfk Oct 3, 2024
11ccfba
beacon network loader
gsfk Oct 3, 2024
6aeda42
lint
gsfk Oct 3, 2024
08a6d5a
lint
gsfk Oct 3, 2024
f5a55b4
beacon network node details
gsfk Oct 3, 2024
67bfa26
beacon network counts component
gsfk Oct 3, 2024
878cdd4
Merge remote-tracking branch 'origin/main' into features/beacon-network
davidlougheed Oct 3, 2024
d2d0e93
style: work on beacon network styling
davidlougheed Oct 9, 2024
6314139
style: ellipsis-overflow beacon network node org name
davidlougheed Oct 10, 2024
e61306f
style: use antd row/col for beacon network nodes
davidlougheed Oct 10, 2024
b9df821
lint: rm commented-out code
davidlougheed Oct 10, 2024
aa17b3d
lint: rm more commented-out code
davidlougheed Oct 10, 2024
7303a33
style: fix responsiveness issues for network
davidlougheed Oct 10, 2024
64c87b5
lint
davidlougheed Oct 10, 2024
2bb70b6
Merge pull request #201 from bento-platform/style/beacon-network
davidlougheed Oct 10, 2024
41dcf36
refact: useBeaconNetwork hook + some translation
davidlougheed Oct 10, 2024
fe8916c
fix: revert bad refactor in NetworkSearchResults
davidlougheed Oct 10, 2024
715b128
refact(beacon): memoization in query form UI
davidlougheed Oct 10, 2024
4c3a177
style: fix search result statistic padding when disabled
davidlougheed Oct 15, 2024
303dc42
Merge pull request #202 from bento-platform/chore/network-tweaks
davidlougheed Oct 15, 2024
a3157cf
refact: factor out common types for discovery results + new types
davidlougheed Oct 16, 2024
8c4af73
refact(beacon-network): destructure beacon results
davidlougheed Oct 16, 2024
c63629a
fix: show zero individuals as dashes in network node counts
davidlougheed Oct 17, 2024
e458bf5
chore: note mode for SearchResultsCountsProps
davidlougheed Oct 17, 2024
f321932
fix: proper beacon network counts behaviour
davidlougheed Oct 17, 2024
0a0fbdc
Merge pull request #204 from bento-platform/refact/beacon-network-redux
davidlougheed Oct 18, 2024
d9c1080
refact: combine beacon and network double-slices
davidlougheed Oct 21, 2024
ca43785
refact: beacon network dispatch conditions + URL
davidlougheed Oct 21, 2024
172d18e
refact(beacon): move apiErrorMessage to BeaconQueryUi param
davidlougheed Oct 21, 2024
9118bb7
lint
davidlougheed Oct 21, 2024
3937c23
Merge remote-tracking branch 'origin/main' into refact/beacon-slices
davidlougheed Oct 22, 2024
3b318c2
lint(beacon): query UI input order
davidlougheed Oct 22, 2024
b8e8f18
Merge pull request #206 from bento-platform/refact/beacon-slices
davidlougheed Oct 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 307 additions & 0 deletions src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Card, Col, Form, Row } from 'antd';
import { useIsAuthenticated } from 'bento-auth-js';
import { useAppDispatch, useTranslationDefault, useQueryWithAuthIfAllowed } from '@/hooks';
import VariantsForm from './VariantsForm';
import Filters from './Filters';
import SearchToolTip from './ToolTips/SearchToolTip';
import VariantsInstructions from './ToolTips/VariantsInstructions';
import { MetadataInstructions } from './ToolTips/MetadataInstructions';
import BeaconErrorMessage from './BeaconErrorMessage';
import type {
BeaconAssemblyIds,
BeaconQueryPayload,
BeaconQueryAction,
FormFilter,
FormValues,
PayloadFilter,
PayloadVariantsQuery,
} from '@/types/beacon';
import type { Section } from '@/types/search';
import { BOX_SHADOW } from '@/constants/overviewConstants';
import {
FORM_ROW_GUTTERS,
CARD_STYLE,
BUTTON_AREA_STYLE,
BUTTON_STYLE,
CARD_STYLES,
} from '@/constants/beaconConstants';

const STARTER_FILTER = { index: 1, active: true };
const VARIANTS_FORM_ERROR_MESSAGE =
'Variants form should include either an end position or both reference and alternate bases';

// TODOs
// example searches, either hardcoded or configurable
// more intuitive variants ui

const BeaconQueryFormUi = ({
isFetchingQueryResponse, //used in local beacon only
isNetworkQuery,
beaconAssemblyIds,
querySections,
launchQuery,
apiErrorMessage,
}: BeaconQueryFormUiProps) => {
const td = useTranslationDefault();
const [form] = Form.useForm();
const [filters, setFilters] = useState<FormFilter[]>([STARTER_FILTER]);
const [hasVariants, setHasVariants] = useState<boolean>(false);
const [hasFormError, setHasFormError] = useState<boolean>(false);
const [formErrorMessage, setFormErrorMessage] = useState<string>('');

// remember if user closed alert, so we can force re-render of a new one later
const [errorAlertClosed, setErrorAlertClosed] = useState<boolean>(false);

const dispatch = useAppDispatch();
const isAuthenticated = useIsAuthenticated();

const formInitialValues = useMemo(
() => ({
'Assembly ID': beaconAssemblyIds.length === 1 && beaconAssemblyIds[0],
}),
[beaconAssemblyIds]
);
const uiInstructions = hasVariants ? 'Search by genomic variants, clinical metadata or both.' : '';

const hasError = hasFormError || !!apiErrorMessage;
const showError = hasError && !errorAlertClosed;

const requestPayload = useCallback(
(query: PayloadVariantsQuery, payloadFilters: PayloadFilter[]): BeaconQueryPayload => ({
meta: { apiVersion: '2.0.0' },
query: { requestParameters: { g_variant: query }, filters: payloadFilters },
bento: { showSummaryStatistics: true },
}),
[]
);

const launchEmptyQuery = useCallback(
() => dispatch(launchQuery(requestPayload({}, []))),
[dispatch, launchQuery, requestPayload]
);

// should not be possible to have both errors simultaneously
// and api error is more important
const errorMessage = apiErrorMessage || formErrorMessage;

const clearFormError = () => {
setHasFormError(false);
setFormErrorMessage('');
};

useEffect(() => {
launchEmptyQuery();
setHasVariants(beaconAssemblyIds.length > 0 || isNetworkQuery);

// set assembly id options matching what's in gohan (for local beacon) or in network
form.setFieldsValue(formInitialValues);
}, [beaconAssemblyIds.length, form, formInitialValues, isAuthenticated, isNetworkQuery, launchEmptyQuery]);

// Disables max query param if user is authenticated and authorized
useQueryWithAuthIfAllowed();

// following GA4GH recommendations, UI is one-based, but API is zero-based, "half-open"
// so to convert to zero-based, we only modify the start value
// see e.g., https://genome-blog.soe.ucsc.edu/blog/2016/12/12/the-ucsc-genome-browser-coordinate-counting-systems/
const convertToZeroBased = (start: string) => Number(start) - 1;

const packageFilters = useCallback(
(values: FormValues): PayloadFilter[] => {
// ignore optional first filter when left blank
if (filters.length === 1 && !values.filterId1) {
return [];
}

return filters
.filter((f) => f.active)
.map((f) => ({
id: values[`filterId${f.index}`],
operator: '=',
value: values[`filterValue${f.index}`],
}));
},
[filters]
);

const packageBeaconJSON = useCallback(
(values: FormValues) => {
let query = {} as PayloadVariantsQuery;
const payloadFilters = packageFilters(values);
const hasVariantsQuery = values?.['Chromosome'] || values?.['Variant start'] || values?.['Reference base(s)'];
if (hasVariantsQuery) {
query = {
referenceName: values['Chromosome'],
start: [convertToZeroBased(values['Variant start'])],
assemblyId: values['Assembly ID'],
};
if (values['Variant end']) {
query.end = [Number(values['Variant end'])];
}
if (values['Reference base(s)']) {
query.referenceBases = values['Reference base(s)'];
}
if (values['Alternate base(s)']) {
query.alternateBases = values['Alternate base(s)'];
}
}

return requestPayload(query, payloadFilters);
},
[packageFilters, requestPayload]
);

const handleFinish = useCallback(
(formValues: FormValues) => {
// if bad form, block submit and show user error
if (!variantsFormValid(formValues)) {
setHasFormError(true);
setErrorAlertClosed(false);
setFormErrorMessage(td(VARIANTS_FORM_ERROR_MESSAGE));
return;
}

clearFormError();
setErrorAlertClosed(false);
const jsonPayload = packageBeaconJSON(formValues);

dispatch(launchQuery(jsonPayload));
},
[dispatch, td, launchQuery, packageBeaconJSON]
);

const handleClearForm = () => {
setFilters([STARTER_FILTER]);
form.resetFields();
form.setFieldsValue(formInitialValues);
clearFormError();
setErrorAlertClosed(false);
launchEmptyQuery();
};

const handleValuesChange = (_: Partial<FormValues>, allValues: FormValues) => {
form.validateFields(['Chromosome', 'Variant start', 'Variant end', 'Reference base(s)', 'Alternate base(s)']);

// clear any existing errors if form now valid
if (variantsFormValid(allValues)) {
clearFormError();
}
// can also check filter values here (to e.g. avoid offering duplicate options)
};

const variantsFormValid = (formValues: FormValues) => {
// valid variant form states:
// empty except possibly autofilled assemblyID (no variant query)
// chrom, start, assemblyID, end (range query)
// chrom, start, assemblyID, alt, ref (sequence query)
// https://docs.genomebeacons.org/variant-queries/

// as an alternative, we could require "end" always, then form logic would be less convoluted
// just set start=end to search for SNPs

const empty = !(
formValues['Chromosome'] ||
formValues['Variant start'] ||
formValues['Variant end'] ||
formValues['Reference base(s)'] ||
formValues['Alternate base(s)']
);
const rangeQuery =
formValues['Chromosome'] && formValues['Variant start'] && formValues['Variant end'] && formValues['Assembly ID'];

const sequenceQuery =
formValues['Chromosome'] &&
formValues['Variant start'] &&
formValues['Assembly ID'] &&
formValues['Reference base(s)'] &&
formValues['Alternate base(s)'];
return empty || rangeQuery || sequenceQuery;
};

const searchButtonText = isNetworkQuery ? 'Search Network' : 'Search Beacon';

return (
<div style={{ paddingBottom: 8, display: 'flex', justifyContent: 'center', width: '100%' }}>
<Card
title={td('Search')}
style={{ borderRadius: '10px', maxWidth: '1200px', width: '100%', ...BOX_SHADOW }}
styles={CARD_STYLES}
>
<p style={{ margin: '-8px 0 8px 0', padding: '0', color: 'grey' }}>{td(uiInstructions)}</p>
<Form form={form} onFinish={handleFinish} layout="vertical" onValuesChange={handleValuesChange}>
<Row gutter={FORM_ROW_GUTTERS}>
{hasVariants && (
<Col xs={24} lg={12}>
<Card
title={td('entities.Variants')}
style={CARD_STYLE}
styles={CARD_STYLES}
extra={
<SearchToolTip>
<VariantsInstructions />
</SearchToolTip>
}
>
<VariantsForm beaconAssemblyIds={beaconAssemblyIds} />
</Card>
</Col>
)}
<Col xs={24} lg={hasVariants ? 12 : 24}>
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<>{td('Metadata')}</>
</div>
}
style={CARD_STYLE}
styles={CARD_STYLES}
extra={
<SearchToolTip>
<MetadataInstructions />
</SearchToolTip>
}
>
<Filters
filters={filters}
setFilters={setFilters}
form={form}
querySections={querySections}
isNetworkQuery={isNetworkQuery}
/>
</Card>
</Col>
</Row>
<Row>
<Col span={24}>
{showError && (
<BeaconErrorMessage
message={`${td('Beacon error')}: ${errorMessage}`}
onClose={() => setErrorAlertClosed(true)}
/>
)}
<div style={BUTTON_AREA_STYLE}>
<Button type="primary" htmlType="submit" loading={isFetchingQueryResponse} style={BUTTON_STYLE}>
{td(searchButtonText)}
</Button>
<Button onClick={handleClearForm} style={BUTTON_STYLE}>
{td('Clear Form')}
</Button>
</div>
</Col>
</Row>
</Form>
</Card>
</div>
);
};

export interface BeaconQueryFormUiProps {
isFetchingQueryResponse: boolean;
isNetworkQuery: boolean;
beaconAssemblyIds: BeaconAssemblyIds;
querySections: Section[];
launchQuery: BeaconQueryAction;
apiErrorMessage?: string;
}

export default BeaconQueryFormUi;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import type { Section, Field } from '@/types/search';

// TODOs:
// rewrite to use beacon specification filters instead of bento public "querySections"
// any search key (eg "sex") selected in one filter should not available in other
// for clarity they should probably appear, but be greyed out
// this requires rendering select options as <Option> components
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
import type { Dispatch, SetStateAction } from 'react';
import { useAppSelector } from '@/hooks';
import { useTranslation } from 'react-i18next';
import { Button, Form, Space, Tooltip } from 'antd';
import { Button, Form, Space, Switch, Tooltip } from 'antd';
import type { FormInstance } from 'antd/es/form';

import { DEFAULT_TRANSLATION } from '@/constants/configConstants';
import { useBeaconNetwork } from '@/features/beacon/hooks';
import { toggleQuerySectionsUnionOrIntersection } from '@/features/beacon/network.store';
import { useAppSelector, useAppDispatch } from '@/hooks';
import type { FormFilter } from '@/types/beacon';
import type { SearchFieldResponse } from '@/types/search';
import { DEFAULT_TRANSLATION } from '@/constants/configConstants';

import Filter from './Filter';

const NetworkFilterToggle = () => {
const dispatch = useAppDispatch();
const { isQuerySectionsUnion } = useBeaconNetwork();

return (
<Tooltip title="Choose all search filters across the network, or only those common to all beacons.">
<div style={{ display: 'flex', alignItems: 'center' }}>
<Switch
onChange={() => dispatch(toggleQuerySectionsUnionOrIntersection())}
checked={isQuerySectionsUnion}
style={{ margin: '5px' }}
/>
<p style={{ margin: '5px' }}>{isQuerySectionsUnion ? 'show all filters' : 'common filters only'}</p>
</div>
</Tooltip>
);
};

// ideally:
// - should not permit you to make multiple queries on the same key (Redmine #1688)

const BUTTON_STYLE = { margin: '10px 0' };

const Filters = ({ filters, setFilters, form, querySections }: FiltersProps) => {
const Filters = ({ filters, setFilters, form, querySections, isNetworkQuery }: FiltersProps) => {
const { t: td } = useTranslation(DEFAULT_TRANSLATION);

const maxFilters = useAppSelector((state) => state.config.maxQueryParameters);
const maxQueryParametersRequired = useAppSelector((state) => state.config.maxQueryParametersRequired);
const activeFilters = filters.filter((f) => f.active);
const hasMaxFilters = maxQueryParametersRequired && activeFilters.length >= maxFilters;

// don't need to pull filters from state
// we only need to know *which* state we are in, so it can be shown in the switch

// UI starts with an optional filter, which can be left blank
const isRequired = filters.length > 1;

Expand All @@ -45,6 +70,7 @@ const Filters = ({ filters, setFilters, form, querySections }: FiltersProps) =>
{td('Add Filter')}
</Button>
</Tooltip>
{isNetworkQuery && <NetworkFilterToggle />}
</Space>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{activeFilters.map((f) => (
Expand All @@ -67,6 +93,7 @@ export interface FiltersProps {
setFilters: Dispatch<SetStateAction<FormFilter[]>>;
form: FormInstance;
querySections: SearchFieldResponse['sections'];
isNetworkQuery: boolean;
}

export default Filters;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ToolTipText } from './ToolTipText';
import { useTranslationDefault } from '@/hooks';

const METADATA_INSTRUCTIONS = 'Search over clinical or phenotypic properties.';

export const MetadataInstructions = () => {
const td = useTranslationDefault();
return <ToolTipText>{td(METADATA_INSTRUCTIONS)}</ToolTipText>;
};
Loading