Skip to content

Commit

Permalink
Merge pull request #466 from bento-platform/refact/locus-search
Browse files Browse the repository at this point in the history
refact: VariantSearchHeader types + locus search fixes
  • Loading branch information
davidlougheed authored Nov 15, 2024
2 parents ef6cafe + 5e9a9dc commit cd8ff0e
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 51 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.1",
"@types/json-schema": "^7.0.15",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.2.25",
Expand Down
67 changes: 48 additions & 19 deletions src/components/discovery/LocusSearch.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AutoComplete, Tag } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { AutoComplete, Input, Tag } from "antd";
import PropTypes from "prop-types";
import { useGeneNameSearch, useReferenceGenomes } from "@/modules/reference/hooks";
import { useGeneNameSearch } from "@/modules/reference/hooks";

const NULL_LOCUS = { chrom: null, start: null, end: null };

// Position notation pattern
// - strip chr prefix, but allow any other types of chromosome - eventually this should instead autocomplete from the
// reference service.
const POS_NOTATION_PATTERN = /(?:CHR|chr)?([\w.-]+):(\d+)-(\d+)/;

const parsePosition = (value) => {
const parse = /(?:CHR|chr)([0-9]{1,2}|X|x|Y|y|M|m):(\d+)-(\d+)/;
const result = parse.exec(value);
const result = POS_NOTATION_PATTERN.exec(value);

if (!result) {
return { chrom: null, start: null, end: null };
return NULL_LOCUS;
}

const chrom = result[1].toUpperCase(); //for eg 'x', has no effect on numbers
Expand All @@ -19,16 +25,21 @@ const parsePosition = (value) => {

const looksLikePositionNotation = (value) => !value.includes(" ") && value.includes(":");

const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, setLocusValidity }) => {
const referenceGenomes = useReferenceGenomes();
const LocusSearch = ({
assemblyId,
geneSearchEnabled,
addVariantSearchValues,
handleLocusChange,
setLocusValidity,
}) => {
const mounted = useRef(false);

const [autoCompleteOptions, setAutoCompleteOptions] = useState([]);
const [inputValue, setInputValue] = useState("");

const showAutoCompleteOptions = useMemo(
() =>
!!referenceGenomes.itemsByID[assemblyId]?.gff3_gz && inputValue.length && !looksLikePositionNotation(inputValue),
[referenceGenomes, assemblyId, inputValue],
);
const valueLooksLikePosNot = looksLikePositionNotation(inputValue);

const showAutoCompleteOptions = geneSearchEnabled && !!inputValue.length && !valueLooksLikePosNot;

const handlePositionNotation = useCallback(
(value) => {
Expand All @@ -50,10 +61,10 @@ const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, se
}
}, [inputValue, handlePositionNotation, setLocusValidity]);

const handleChange = useCallback((value) => {
setInputValue(value);
}, []);
const handleChangeInput = useCallback((e) => setInputValue(e.target.value), []);
const handleChangeAutoComplete = useCallback((value) => setInputValue(value), []);

// Don't execute search if showAutoCompleteOptions is false (gene search disabled / input doesn't look like search)
const { data: geneSearchResults } = useGeneNameSearch(assemblyId, showAutoCompleteOptions ? inputValue : null);

const handleOnBlur = useCallback(() => {
Expand All @@ -70,8 +81,8 @@ const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, se
const isPositionNotation = inputValue.includes(":") && !isAutoCompleteOption;

if (!(isAutoCompleteOption || isPositionNotation)) {
handleLocusChange({ chrom: null, start: null, end: null });
addVariantSearchValues({ chrom: null, start: null, end: null });
handleLocusChange(NULL_LOCUS);
addVariantSearchValues(NULL_LOCUS);
return;
}

Expand Down Expand Up @@ -121,10 +132,27 @@ const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, se
);
}, [geneSearchResults]);

useEffect(() => {
// If the input mode changes, we need to clear the corresponding Redux state since it isn't directly linked
// - if we're making the state newly invalid (rather than on first run), run handleLocusChange() too
if (mounted.current) handleLocusChange(NULL_LOCUS);
addVariantSearchValues(NULL_LOCUS);
}, [addVariantSearchValues, handleLocusChange, geneSearchEnabled]);

// This effect needs to be last before rendering!
// A small hack to change the above effect's behaviour if we're making the input invalid (vs. it starting invalid)
useEffect(() => {
mounted.current = true;
}, []);

if (!geneSearchEnabled) {
return <Input onChange={handleChangeInput} onBlur={handleOnBlur} />;
}

return (
<AutoComplete
options={showAutoCompleteOptions ? autoCompleteOptions : []}
onChange={handleChange}
onChange={handleChangeAutoComplete}
onSelect={handleSelect}
onBlur={handleOnBlur}
/>
Expand All @@ -133,6 +161,7 @@ const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, se

LocusSearch.propTypes = {
assemblyId: PropTypes.string,
geneSearchEnabled: PropTypes.bool,
addVariantSearchValues: PropTypes.func,
handleLocusChange: PropTypes.func,
setLocusValidity: PropTypes.func,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import type { JSONSchema7 } from "json-schema";

import { Form, Input, Select } from "antd";

import LocusSearch from "./LocusSearch";

import { notAlleleCharactersRegex } from "@/utils/misc";
import type { InputChangeEventHandler } from "@/components/manager/access/types";
import { useGohanVariantsOverview } from "@/modules/explorer/hooks";
import type { BentoDataType } from "@/modules/services/types";
import { useReferenceGenomes } from "@/modules/reference/hooks";
import { useAppSelector } from "@/store";
import { notAlleleCharactersRegex } from "@/utils/misc";

type Locus = { chrom: string | null; start: string | null; end: string | null };

const isValidLocus = (locus) => locus.chrom !== null && locus.start !== null && locus.end !== null;
const normalizeAlleleText = (text) => text.toUpperCase().replaceAll(notAlleleCharactersRegex, "");
const containsInvalid = (text) => {
const isValidLocus = (locus: Locus) => locus.chrom !== null && locus.start !== null && locus.end !== null;
const normalizeAlleleText = (text: string) => text.toUpperCase().replaceAll(notAlleleCharactersRegex, "");
const containsInvalid = (text: string) => {
const matches = text.toUpperCase().match(notAlleleCharactersRegex);
return matches && matches.length > 0;
};

const INITIAL_FIELDS_VALIDITY = {
type FieldsValidity = {
assemblyId: boolean;
locus: boolean;
};

const INITIAL_FIELDS_VALIDITY: FieldsValidity = {
assemblyId: true,
locus: true,
};
Expand All @@ -25,7 +36,20 @@ const INITIAL_FIELDS_VALIDITY = {
const LABEL_COL = { lg: { span: 24 }, xl: { span: 4 }, xxl: { span: 3 } };
const WRAPPER_COL = { lg: { span: 24 }, xl: { span: 20 }, xxl: { span: 18 } };

const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
type VariantSearchHeaderProps = {
dataType: BentoDataType;
addVariantSearchValues: (
x:
| { assemblyId: string }
| { alt: string }
| { ref: string }
| {
genotypeType: string;
},
) => void;
};

const VariantSearchHeader = ({ dataType, addVariantSearchValues }: VariantSearchHeaderProps) => {
const { data: variantsOverviewResults, isFetching: isFetchingVariantsOverview } = useGohanVariantsOverview();
const overviewAssemblyIds = useMemo(() => {
const hasAssemblyIds =
Expand All @@ -40,26 +64,34 @@ const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {

const [refFormReceivedValidKeystroke, setRefFormReceivedValidKeystroke] = useState(true);
const [altFormReceivedValidKeystroke, setAltFormReceivedValidKeystroke] = useState(true);
const [activeRefValue, setActiveRefValue] = useState(null);
const [activeAltValue, setActiveAltValue] = useState(null);
const [assemblyId, setAssemblyId] = useState(overviewAssemblyIds.length === 1 ? overviewAssemblyIds[0] : null);
const [locus, setLocus] = useState({ chrom: null, start: null, end: null });
const [activeRefValue, setActiveRefValue] = useState<string>("");
const [activeAltValue, setActiveAltValue] = useState<string>("");

const [assemblyId, setAssemblyId] = useState<string | null>(
overviewAssemblyIds.length === 1 ? overviewAssemblyIds[0] : null,
);
const referenceGenomes = useReferenceGenomes();
const geneSearchEnabled = assemblyId !== null && !!referenceGenomes.itemsByID[assemblyId]?.gff3_gz;

const [locus, setLocus] = useState<Locus>({ chrom: null, start: null, end: null });
const { isSubmittingSearch: isSubmitting } = useAppSelector((state) => state.explorer);

// begin with required fields considered valid, so user isn't assaulted with error messages
const [fieldsValidity, setFieldsValidity] = useState(INITIAL_FIELDS_VALIDITY);
const [fieldsValidity, setFieldsValidity] = useState<FieldsValidity>(INITIAL_FIELDS_VALIDITY);

const genotypeSchema = dataType.schema?.properties?.calls?.items?.properties?.genotype_type;
const genotypeSchema = (
(dataType.schema?.properties?.calls as JSONSchema7 | undefined)?.items as JSONSchema7 | undefined
)?.properties?.genotype_type as JSONSchema7 | undefined;
const genotypeSchemaDescription = genotypeSchema?.description;
const genotypeOptions = useMemo(
() => (genotypeSchema?.enum ?? []).map((value) => ({ value, label: value })),
() => ((genotypeSchema?.enum ?? []) as string[]).map((value: string) => ({ value, label: value })),
[genotypeSchema],
);

const helpText = useMemo(() => {
const assemblySchema = dataType.schema?.properties?.assembly_id;
const assemblySchema = dataType.schema?.properties?.assembly_id as JSONSchema7 | undefined;
return {
assemblyId: assemblySchema?.description,
assemblyId: assemblySchema?.description ?? "",
genotype: genotypeSchemaDescription,
// eslint-disable-next-line quotes
locus: 'Enter gene name (eg "BRCA1") or position ("chr17:41195311-41278381")',
Expand All @@ -74,32 +106,29 @@ const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
// check assembly
if (!assemblyId) {
// change assemblyId help text & outline
setFieldsValidity({ ...fieldsValidity, assemblyId: false });
setFieldsValidity((fv) => ({ ...fv, assemblyId: false }));
}

// check locus
const { chrom, start, end } = locus;
if (!chrom || !start || !end) {
// change locus help text & outline
setFieldsValidity({ ...fieldsValidity, locus: false });
setFieldsValidity((fv) => ({ ...fv, locus: false }));
}
}, [assemblyId, locus, fieldsValidity]);
}, [assemblyId, locus]);

useEffect(() => {
if (isSubmitting) {
validateVariantSearchForm();
}
}, [isSubmitting, validateVariantSearchForm]);

const setLocusValidity = useCallback(
(isValid) => {
setFieldsValidity({ ...fieldsValidity, locus: isValid });
},
[fieldsValidity],
);
const setLocusValidity = useCallback((isValid: boolean) => {
setFieldsValidity((fv) => ({ ...fv, locus: isValid }));
}, []);

const handleLocusChange = useCallback(
(locus) => {
(locus: Locus) => {
setLocusValidity(isValidLocus(locus));

// set even if invalid, so we don't keep old values
Expand All @@ -109,21 +138,21 @@ const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
);

const handleAssemblyIdChange = useCallback(
(value) => {
(value: string) => {
addVariantSearchValues({ assemblyId: value });
setAssemblyId(value);
},
[addVariantSearchValues],
);

const handleGenotypeChange = useCallback(
(value) => {
(value: string) => {
addVariantSearchValues({ genotypeType: value });
},
[addVariantSearchValues],
);

const handleRefChange = useCallback(
const handleRefChange = useCallback<InputChangeEventHandler>(
(e) => {
const latestInputValue = e.target.value;
const normalizedRef = normalizeAlleleText(latestInputValue);
Expand All @@ -141,7 +170,7 @@ const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
[addVariantSearchValues],
);

const handleAltChange = useCallback(
const handleAltChange = useCallback<InputChangeEventHandler>(
(e) => {
const latestInputValue = e.target.value;
const normalizedAlt = normalizeAlleleText(latestInputValue);
Expand Down Expand Up @@ -187,13 +216,14 @@ const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
<Form.Item
labelCol={LABEL_COL}
wrapperCol={WRAPPER_COL}
label="Gene / position"
label={isFetchingVariantsOverview || geneSearchEnabled ? "Gene / position" : "Position"}
help={helpText["locus"]}
validateStatus={fieldsValidity.locus ? "success" : "error"}
required
>
<LocusSearch
assemblyId={assemblyId}
geneSearchEnabled={geneSearchEnabled}
addVariantSearchValues={addVariantSearchValues}
handleLocusChange={handleLocusChange}
setLocusValidity={setLocusValidity}
Expand Down
5 changes: 3 additions & 2 deletions src/modules/services/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSONSchema7 } from "json-schema";
import type { Workflow, WorkflowType } from "@/modules/wes/types";

export type GA4GHServiceInfo = {
Expand Down Expand Up @@ -51,8 +52,8 @@ export interface BentoDataType {
id: string;
label: string;
queryable: boolean;
schema: object;
metadata_schema: object;
schema: JSONSchema7;
metadata_schema: JSONSchema7;
count?: number;
}

Expand Down

0 comments on commit cd8ff0e

Please sign in to comment.