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

Beacon variant UI chromosome selector #226

Merged
merged 9 commits into from
Nov 22, 2024
10 changes: 2 additions & 8 deletions src/js/components/Beacon/BeaconCommon/AssemblyIdSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,11 @@ import type { FormField, BeaconAssemblyIds } from '@/types/beacon';

const AssemblyIdSelect = ({ field, beaconAssemblyIds, disabled }: AssemblyIdSelectProps) => {
const t = useTranslationFn();
const assemblyIdOptions = beaconAssemblyIds.map((assembly) => (
<Select.Option key={assembly} value={assembly}>
{assembly}
</Select.Option>
));
const assemblyIdOptions = beaconAssemblyIds.map((assembly) => ({ value: assembly, label: assembly }));

return (
<Form.Item name={field.name} label={t(field.name)} rules={field.rules}>
<Select style={{ width: '100%' }} disabled={disabled}>
{assemblyIdOptions}
</Select>
<Select style={{ width: '100%' }} disabled={disabled} options={assemblyIdOptions} />
</Form.Item>
);
};
Expand Down
6 changes: 2 additions & 4 deletions src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ import {
import { T_PLURAL_COUNT } from '@/constants/i18n';

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
Expand Down Expand Up @@ -158,7 +156,7 @@ const BeaconQueryFormUi = ({
if (!variantsFormValid(formValues)) {
setHasFormError(true);
setErrorAlertClosed(false);
setFormErrorMessage(t(VARIANTS_FORM_ERROR_MESSAGE));
setFormErrorMessage(t('beacon.variants_form_error'));
return;
}

Expand Down Expand Up @@ -243,7 +241,7 @@ const BeaconQueryFormUi = ({
</SearchToolTip>
}
>
<VariantsForm beaconAssemblyIds={beaconAssemblyIds} />
<VariantsForm isNetworkQuery={isNetworkQuery} beaconAssemblyIds={beaconAssemblyIds} />
</Card>
</Col>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
import { ToolTipText } from './ToolTipText';
import { useTranslationFn } from '@/hooks';
import { Space, Typography } from 'antd';
import { useTranslationFn } from '@/hooks';
import { range } from '@/utils/arrays';

const { Title } = Typography;

// complexity of instructions suggests the form isn't intuitive enough
const VARIANTS_INSTRUCTIONS_TITLE = 'Variant search';
const VARIANTS_INSTRUCTIONS_LINE1a =
'To search for all variants inside a range: fill both "Variant start" and "Variant end",';
const VARIANTS_INSTRUCTIONS_LINE1b =
'all variants inside the range will be returned. You can optionally filter by reference or alternate bases.';
import { ToolTipText } from './ToolTipText';

const VARIANTS_INSTRUCTIONS_LINE2a =
'To search for a variant at a particular position, either set "Variant end" to the same value in "Variant start",';
const VARIANTS_INSTRUCTIONS_LINE2b = 'or fill in values for both reference and alternate bases.';
const VARIANTS_INSTRUCTIONS_LINE3 = '"Chromosome", "Variant start" and "Assembly ID" are always required.';
const VARIANTS_INSTRUCTIONS_LINE4a = 'Coordinates are one-based.';
const VARIANTS_INSTRUCTIONS_LINE4b = 'Leave this form blank to search by metadata only.';
// complexity of instructions suggests the form isn't intuitive enough
const HELP_LINES = 4;

const VariantsInstructions = () => {
const t = useTranslationFn();
return (
<Space direction="vertical" style={{ minWidth: '510px' }}>
<Title level={4} style={{ color: 'white', marginTop: '10px' }}>
{VARIANTS_INSTRUCTIONS_TITLE}
{t('beacon.variants_help_title')}
</Title>
<ToolTipText>{t(VARIANTS_INSTRUCTIONS_LINE1a) + ' ' + t(VARIANTS_INSTRUCTIONS_LINE1b)}</ToolTipText>
<ToolTipText>{t(VARIANTS_INSTRUCTIONS_LINE2a) + ' ' + t(VARIANTS_INSTRUCTIONS_LINE2b)}</ToolTipText>
<ToolTipText>{t(VARIANTS_INSTRUCTIONS_LINE3)}</ToolTipText>
<ToolTipText>{t(VARIANTS_INSTRUCTIONS_LINE4a) + ' ' + t(VARIANTS_INSTRUCTIONS_LINE4b)}</ToolTipText>
{range(HELP_LINES).map((x) => (
<ToolTipText key={x}>{t(`beacon.variants_help_${x + 1}`, { joinArrays: ' ' })}</ToolTipText>
))}
</Space>
);
};
Expand Down
24 changes: 19 additions & 5 deletions src/js/components/Beacon/BeaconCommon/VariantInput.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import { Form, Input } from 'antd';
import { Form, Input, Select } from 'antd';
import type { DefaultOptionType } from 'antd/es/select/index';
import { useTranslationFn } from '@/hooks';
import type { FormField } from '@/types/beacon';

const VariantInput = ({ field, disabled }: VariantInputProps) => {
type InputMode = { type: 'input' } | { type: 'select'; options?: DefaultOptionType[] };

const VariantInput = ({ field, disabled, mode }: VariantInputProps) => {
const t = useTranslationFn();
return (
<div>
<>
<Form.Item name={field.name} label={t(field.name)} rules={field.rules}>
<Input placeholder={field.placeholder} disabled={disabled} />
{!mode || mode.type === 'input' ? (
<Input placeholder={field.placeholder} disabled={disabled} />
) : (
<Select
placeholder={field.placeholder}
disabled={disabled}
options={mode.options}
showSearch={true}
optionFilterProp="value"
/>
)}
</Form.Item>
</div>
</>
);
};

export interface VariantInputProps {
field: FormField;
disabled: boolean;
mode?: InputMode;
}

export default VariantInput;
94 changes: 80 additions & 14 deletions src/js/components/Beacon/BeaconCommon/VariantsForm.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,60 @@
import type { CSSProperties } from 'react';
import { Col, Row } from 'antd';
import VariantInput from './VariantInput';
import AssemblyIdSelect from './AssemblyIdSelect';
import { type CSSProperties, useEffect, useMemo } from 'react';

import { Col, Form, Row } from 'antd';
import type { DefaultOptionType } from 'antd/es/select/index';

import { useTranslationFn } from '@/hooks';
import { useReference } from '@/features/reference/hooks';
import type { Contig } from '@/features/reference/types';
import type { BeaconAssemblyIds } from '@/types/beacon';

import VariantInput from './VariantInput';
import AssemblyIdSelect from './AssemblyIdSelect';

type ContigOptionType = DefaultOptionType & { value: string };

// form state has to be one of these:
// empty (except for autofilled assemblyID)
// chrom, start, assemblyID, end
// chrom, start, assemblyID, ref, alt

// forgiving chromosome regex
// accepts X, Y, etc. and any one- or two-digit non-zero number
// note that, eg, polar bears have 37 pairs of chromosomes...
const CHROMOSOME_REGEX = /^([1-9][0-9]?|X|x|Y|y|M|m|MT|mt)$/;

const NUCLEOTIDES_REGEX = /^([acgtnACGTN])*$/;
const DIGITS_REGEX = /^[0-9]+$/;
const DIGITS_REGEX = /^\d+$/;

const HUMAN_LIKE_CONTIG_REGEX = /^(?:chr)?(\d+|X|Y|M)$/;
const HUMAN_LIKE_EXCLUDE_CONTIG_REGEX = /^(?:chr)?(\d+|X|Y|M|Un)_.+$/;

const contigToOption = (c: Contig): ContigOptionType => ({ value: c.name });

const contigOptionSort = (a: ContigOptionType, b: ContigOptionType) => {
const aMatch = a.value.match(HUMAN_LIKE_CONTIG_REGEX);
const bMatch = b.value.match(HUMAN_LIKE_CONTIG_REGEX);
if (aMatch) {
if (bMatch) {
const aNoPrefix = aMatch[1];
const bNoPrefix = bMatch[1];
const aNumeric = !!aNoPrefix.match(DIGITS_REGEX);
const bNumeric = !!bNoPrefix.match(DIGITS_REGEX);
if (aNumeric) {
if (bNumeric) {
return parseInt(aNoPrefix, 10) - parseInt(bNoPrefix, 10);
} else {
return -1;
}
} else if (bNumeric) {
return 1;
} else {
return aNoPrefix.localeCompare(bNoPrefix);
}
} else {
// chr## type contigs put before other types
return -1;
}
}
return a.value.localeCompare(b.value);
};

const filterOutHumanLikeExtraContigs = (opt: ContigOptionType) => !opt.value.match(HUMAN_LIKE_EXCLUDE_CONTIG_REGEX);

const FORM_STYLE: CSSProperties = {
display: 'flex',
Expand All @@ -25,13 +63,36 @@ const FORM_STYLE: CSSProperties = {

const FORM_ROW_GUTTER: [number, number] = [12, 0];

const VariantsForm = ({ beaconAssemblyIds }: VariantsFormProps) => {
const VariantsForm = ({ isNetworkQuery, beaconAssemblyIds }: VariantsFormProps) => {
const { genomesByID } = useReference();

// Pick up form context from outside
const form = Form.useFormInstance();
const currentAssemblyID = Form.useWatch('Assembly ID', form);

// Right now, we cannot figure out the contig options for the network, so we fall back to a normal input box.
const availableContigs = useMemo<ContigOptionType[]>(
() =>
!isNetworkQuery && currentAssemblyID && genomesByID[currentAssemblyID]
? genomesByID[currentAssemblyID].contigs
.map(contigToOption)
.sort(contigOptionSort)
.filter(filterOutHumanLikeExtraContigs)
: [],
[isNetworkQuery, currentAssemblyID, genomesByID]
);
const assemblySelect = !!availableContigs.length;

useEffect(() => {
// Clear contig value when list of available contigs changes:
form.setFieldValue('Chromosome', '');
}, [form, availableContigs]);

const t = useTranslationFn();
const formFields = {
referenceName: {
name: 'Chromosome',
rules: [{ pattern: CHROMOSOME_REGEX, message: t('Enter a chromosome name, e.g.: "17" or "X"') }],
placeholder: '1-22, X, Y, M',
placeholder: !currentAssemblyID ? t('beacon.select_asm') : '',
initialValue: '',
},
start: {
Expand Down Expand Up @@ -67,7 +128,11 @@ const VariantsForm = ({ beaconAssemblyIds }: VariantsFormProps) => {
<div style={FORM_STYLE}>
<Row gutter={FORM_ROW_GUTTER}>
<Col span={8}>
<VariantInput field={formFields.referenceName} disabled={variantsError} />
<VariantInput
field={formFields.referenceName}
disabled={variantsError || !currentAssemblyID}
mode={assemblySelect ? { type: 'select', options: availableContigs } : { type: 'input' }}
/>
</Col>
<Col span={8}>
<VariantInput field={formFields.start} disabled={variantsError} />
Expand All @@ -94,6 +159,7 @@ const VariantsForm = ({ beaconAssemblyIds }: VariantsFormProps) => {
};

export interface VariantsFormProps {
isNetworkQuery?: boolean;
beaconAssemblyIds: BeaconAssemblyIds;
}

Expand Down
2 changes: 2 additions & 0 deletions src/js/components/BentoAppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fetchGohanData, fetchKatsuData } from '@/features/ingestion/lastIngesti
import { makeGetDataTypes } from '@/features/dataTypes/dataTypes.store';
import { useMetadata } from '@/features/metadata/hooks';
import { getProjects, markScopeSet, selectScope } from '@/features/metadata/metadata.store';
import { getGenomes } from '@/features/reference/reference.store';

import Loader from '@/components/Loader';
import DefaultLayout from '@/components/Util/DefaultLayout';
Expand Down Expand Up @@ -107,6 +108,7 @@ const BentoAppRouter = () => {
dispatch(fetchGohanData());
dispatch(makeGetServiceInfoRequest());
dispatch(makeGetDataTypes());
dispatch(getGenomes());
}, [dispatch]);

if (isAutoAuthenticating || projectsStatus === RequestStatus.Pending) {
Expand Down
4 changes: 3 additions & 1 deletion src/js/constants/configConstants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PORTAL_URL } from '@/config';
import { PUBLIC_URL_NO_TRAILING_SLASH, PORTAL_URL } from '@/config';

export const MAX_CHARTS = 3;

Expand All @@ -10,6 +10,8 @@ export const projectsUrl = `${PORTAL_URL}/api/metadata/api/projects`;
export const katsuLastIngestionsUrl = `${PORTAL_URL}/api/metadata/data-types`;
export const gohanLastIngestionsUrl = `${PORTAL_URL}/api/gohan/data-types`;

export const referenceGenomesUrl = `${PUBLIC_URL_NO_TRAILING_SLASH}/api/reference/genomes`;

export const DEFAULT_TRANSLATION = 'default_translation';
export const CUSTOMIZABLE_TRANSLATION = 'translation';

Expand Down
50 changes: 50 additions & 0 deletions src/js/features/reference/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { useAuthorizationHeader } from 'bento-auth-js';
import { referenceGenomesUrl } from '@/constants/configConstants';
import { useAppSelector } from '@/hooks';
import { RequestStatus } from '@/types/requests';
import type { GenomeFeature } from './types';

export const useReference = () => {
return useAppSelector((state) => state.reference);
};

export const useGeneNameSearch = (referenceGenomeID: string | undefined, nameQuery: string | null | undefined) => {
const authHeader = useAuthorizationHeader();

const [status, setStatus] = useState<RequestStatus>(RequestStatus.Idle);
const [data, setData] = useState<GenomeFeature[]>([]);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!referenceGenomeID || !nameQuery) return;

const params = new URLSearchParams({ name: nameQuery, name_fzy: 'true', limit: '10' });
const searchUrl = `${referenceGenomesUrl}/${referenceGenomeID}/features?${params.toString()}`;

setError(null);

(async () => {
setStatus(RequestStatus.Pending);

try {
const res = await fetch(searchUrl, { headers: { Accept: 'application/json', ...authHeader } });
const resData = await res.json();
if (res.ok) {
console.debug('Genome feature search - got results:', resData.results);
setData(resData.results);
setStatus(RequestStatus.Fulfilled);
} else {
setError(`Genome feature search failed with message: ${resData.message}`);
setStatus(RequestStatus.Rejected);
}
} catch (e) {
console.error(e);
setError(`Genome feature search failed: ${(e as Error).toString()}`);
setStatus(RequestStatus.Rejected);
}
})();
}, [referenceGenomeID, nameQuery, authHeader]);

return { status, data, error };
};
Loading