Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into chart-pointer
Browse files Browse the repository at this point in the history
  • Loading branch information
davidlougheed committed Nov 22, 2024
2 parents 3ae584d + e2e9b70 commit da5ab74
Show file tree
Hide file tree
Showing 15 changed files with 311 additions and 73 deletions.
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

0 comments on commit da5ab74

Please sign in to comment.