jbrowse-plugin-msaview 2.9.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.d.ts +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.js +14 -17
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -34
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +16 -0
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +65 -0
- package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +4 -5
- package/dist/LaunchMsaView/components/useFeatureSequence.d.ts +6 -4
- package/dist/LaunchMsaView/components/useFeatureSequence.js +20 -11
- package/dist/LaunchMsaView/components/useTranscriptSelection.d.ts +1 -1
- package/dist/LaunchMsaView/extendStateModel.test.d.ts +1 -0
- package/dist/LaunchMsaView/extendStateModel.test.js +69 -0
- package/dist/LaunchMsaView/index.d.ts +2 -0
- package/dist/LaunchMsaView/index.js +45 -37
- package/dist/LaunchMsaView/launchTarget.d.ts +25 -0
- package/dist/LaunchMsaView/launchTarget.js +42 -0
- package/dist/LaunchMsaView/launchTarget.test.d.ts +1 -0
- package/dist/LaunchMsaView/launchTarget.test.js +54 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.js +45 -12
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +93 -19
- package/dist/MsaViewPanel/model.d.ts +23 -10
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +31 -44
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/ebiJobDispatcher.js +26 -1
- package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
- package/dist/utils/ebiJobDispatcher.test.js +55 -0
- package/dist/utils/eutils.d.ts +9 -0
- package/dist/utils/eutils.js +18 -0
- package/dist/utils/ncbiOrthologs.d.ts +17 -75
- package/dist/utils/ncbiOrthologs.js +67 -63
- package/dist/utils/ncbiOrthologs.test.js +82 -2
- package/dist/utils/ncbiTaxonomy.d.ts +30 -0
- package/dist/utils/ncbiTaxonomy.js +66 -0
- package/dist/utils/ncbiTaxonomy.test.d.ts +1 -0
- package/dist/utils/ncbiTaxonomy.test.js +69 -0
- package/dist/utils/useFetch.d.ts +22 -0
- package/dist/utils/useFetch.js +0 -0
- package/dist/utils/useFetch.test.d.ts +1 -0
- package/dist/utils/useFetch.test.js +23 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -5
- package/src/LaunchMsaView/components/BlastQuery/useCachedBlastResults.ts +16 -29
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +34 -71
- package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +106 -0
- package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +3 -6
- package/src/LaunchMsaView/components/useFeatureSequence.ts +35 -15
- package/src/LaunchMsaView/extendStateModel.test.ts +76 -0
- package/src/LaunchMsaView/index.ts +47 -62
- package/src/LaunchMsaView/launchTarget.test.ts +77 -0
- package/src/LaunchMsaView/launchTarget.ts +65 -0
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +109 -21
- package/src/MsaViewPanel/doLaunchOrthologs.ts +58 -13
- package/src/MsaViewPanel/model.ts +13 -4
- package/src/utils/ebiJobDispatcher.test.ts +63 -0
- package/src/utils/ebiJobDispatcher.ts +32 -3
- package/src/utils/eutils.ts +19 -0
- package/src/utils/ncbiOrthologs.test.ts +96 -2
- package/src/utils/ncbiOrthologs.ts +83 -71
- package/src/utils/ncbiTaxonomy.test.ts +84 -0
- package/src/utils/ncbiTaxonomy.ts +80 -0
- package/src/utils/useFetch.test.ts +30 -0
- package/src/utils/useFetch.ts +0 -0
- package/src/version.ts +1 -1
- package/dist/LaunchMsaView/components/useSWRFeatureSequence.d.ts +0 -12
- package/dist/LaunchMsaView/components/useSWRFeatureSequence.js +0 -25
- package/dist/utils/swrConfig.d.ts +0 -8
- package/dist/utils/swrConfig.js +0 -8
- package/src/LaunchMsaView/components/useSWRFeatureSequence.ts +0 -54
- package/src/utils/swrConfig.ts +0 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare function useCachedBlastResults(geneIds: string[]): {
|
|
2
2
|
results: import("../../../utils/blastCache").CachedBlastResult[];
|
|
3
|
-
error:
|
|
3
|
+
error: unknown;
|
|
4
4
|
isLoading: boolean;
|
|
5
5
|
handleDelete: (id: string) => Promise<void>;
|
|
6
6
|
handleClearAll: () => Promise<void>;
|
|
@@ -1,27 +1,24 @@
|
|
|
1
|
-
import useSWR from 'swr';
|
|
2
1
|
import { deleteCachedResult, getAllCachedResults, } from '../../../utils/blastCache';
|
|
3
|
-
import {
|
|
2
|
+
import { useFetch } from '../../../utils/useFetch';
|
|
4
3
|
export function useCachedBlastResults(geneIds) {
|
|
5
|
-
const { data: results, error, isLoading, mutate, } =
|
|
4
|
+
const { data: results, error, isLoading, mutate, } = useFetch(`cached-blast-${geneIds.join(',')}`, async () => {
|
|
6
5
|
const cached = await getAllCachedResults();
|
|
7
6
|
return cached.filter(r => r.geneId && geneIds.includes(r.geneId));
|
|
8
|
-
}
|
|
9
|
-
const handleDelete = async (id) => {
|
|
10
|
-
await deleteCachedResult(id);
|
|
11
|
-
await mutate(results => results?.filter(result => result.id !== id) ?? [], false);
|
|
12
|
-
};
|
|
13
|
-
// deletes only what this hook listed, i.e. the results for these gene ids.
|
|
14
|
-
// The list the user is looking at is gene-scoped, so a store-wide clear here
|
|
15
|
-
// would silently throw away every other gene's cached alignments too
|
|
16
|
-
const handleClearAll = async () => {
|
|
17
|
-
await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)));
|
|
18
|
-
await mutate([], false);
|
|
19
|
-
};
|
|
7
|
+
});
|
|
20
8
|
return {
|
|
21
9
|
results: results ?? [],
|
|
22
10
|
error,
|
|
23
11
|
isLoading,
|
|
24
|
-
handleDelete
|
|
25
|
-
|
|
12
|
+
handleDelete: async (id) => {
|
|
13
|
+
await deleteCachedResult(id);
|
|
14
|
+
mutate();
|
|
15
|
+
},
|
|
16
|
+
// deletes only what this hook listed, i.e. the results for these gene ids.
|
|
17
|
+
// The list the user is looking at is gene-scoped, so a store-wide clear here
|
|
18
|
+
// would silently throw away every other gene's cached alignments too
|
|
19
|
+
handleClearAll: async () => {
|
|
20
|
+
await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)));
|
|
21
|
+
mutate();
|
|
22
|
+
},
|
|
26
23
|
};
|
|
27
24
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React, { useMemo, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Typography } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react';
|
|
4
4
|
import { makeStyles } from 'tss-react/mui';
|
|
5
|
+
import QuerySpeciesSelect from './QuerySpeciesSelect';
|
|
5
6
|
import { orthologLaunchView } from './orthologLaunchView';
|
|
6
7
|
import TextField2 from '../../../components/TextField2';
|
|
7
|
-
import {
|
|
8
|
+
import { defaultMaxSpecies } from '../../../utils/ncbiOrthologs';
|
|
8
9
|
import { getGeneDisplayName, getGeneIdentifiers, getLinearGenomeView, getTranscriptDisplayName, } from '../../util';
|
|
9
10
|
import MsaAlgorithmSelect from '../BlastQuery/MsaAlgorithmSelect';
|
|
10
11
|
import LaunchPanelContent from '../LaunchPanelContent';
|
|
@@ -15,23 +16,6 @@ const useStyles = makeStyles()({
|
|
|
15
16
|
selectField: {
|
|
16
17
|
width: 180,
|
|
17
18
|
},
|
|
18
|
-
// A GRID, not a wrapping flex row of fixed-width items. The old form was three
|
|
19
|
-
// 160px columns inside a 560px box, which is five rows for thirteen species and
|
|
20
|
-
// eight for twenty-three -- and the checkbox list is the tallest thing in the
|
|
21
|
-
// dialog, so those rows are the dialog's height. Five auto-fitted columns is
|
|
22
|
-
// five rows for twenty-three, i.e. more species in less space, and it reflows
|
|
23
|
-
// rather than being pinned to a width the dialog may not have.
|
|
24
|
-
speciesBox: {
|
|
25
|
-
display: 'grid',
|
|
26
|
-
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
|
|
27
|
-
maxWidth: 700,
|
|
28
|
-
marginTop: 4,
|
|
29
|
-
},
|
|
30
|
-
// The label carries the row height; the default control padding is what makes
|
|
31
|
-
// 23 rows of it tall.
|
|
32
|
-
species: {
|
|
33
|
-
marginRight: 0,
|
|
34
|
-
},
|
|
35
19
|
});
|
|
36
20
|
const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
|
|
37
21
|
const { classes } = useStyles();
|
|
@@ -39,28 +23,24 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
|
|
|
39
23
|
const [launchViewError, setLaunchViewError] = useState();
|
|
40
24
|
const [taxId, setTaxId] = useState(9606);
|
|
41
25
|
const [msaAlgorithm, setMsaAlgorithm] = useState('clustalo');
|
|
42
|
-
const [
|
|
26
|
+
const [maxSpecies, setMaxSpecies] = useState(String(defaultMaxSpecies));
|
|
43
27
|
const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature]);
|
|
44
28
|
const transcriptSelection = useTranscriptSelection({ feature, view });
|
|
45
29
|
const { selectedTranscript, proteinSequence } = transcriptSelection;
|
|
46
30
|
const e = transcriptSelection.error ?? launchViewError;
|
|
47
|
-
const
|
|
31
|
+
const rowCount = Number(maxSpecies);
|
|
32
|
+
const rowCountValid = Number.isInteger(rowCount) && rowCount >= 2;
|
|
48
33
|
return (React.createElement(React.Fragment, null,
|
|
49
34
|
React.createElement(LaunchPanelContent, { error: e },
|
|
50
|
-
React.createElement(Typography, { variant: "body2" }, "NCBI's precomputed orthologs, one gene per species,
|
|
35
|
+
React.createElement(Typography, { variant: "body2" }, "NCBI's precomputed orthologs, one gene per species, looked up rather than searched for. No BLAST job to queue."),
|
|
51
36
|
React.createElement("div", null,
|
|
52
|
-
React.createElement(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
React.createElement("div", { className: classes.speciesBox }, COMMON_SPECIES.map(s => (React.createElement(FormControlLabel, { className: classes.species, key: s.taxId, control: React.createElement(Checkbox, { checked: !excluded.includes(s.taxId), onChange: event => {
|
|
58
|
-
setExcluded(event.target.checked
|
|
59
|
-
? excluded.filter(t => t !== s.taxId)
|
|
60
|
-
: [...excluded, s.taxId]);
|
|
61
|
-
} }), label: s.label })))),
|
|
37
|
+
React.createElement(QuerySpeciesSelect, { className: classes.selectField, value: taxId, assemblyName: view.assemblyNames[0], onChange: setTaxId }),
|
|
38
|
+
React.createElement(MsaAlgorithmSelect, { className: classes.selectField, value: msaAlgorithm, onChange: setMsaAlgorithm }),
|
|
39
|
+
React.createElement(TextField2, { variant: "outlined", label: "Rows to align", className: classes.selectField, type: "number", value: maxSpecies, onChange: event => {
|
|
40
|
+
setMaxSpecies(event.target.value);
|
|
41
|
+
}, error: !rowCountValid, helperText: "the closest N species NCBI has" })),
|
|
62
42
|
React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection })),
|
|
63
|
-
React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence ||
|
|
43
|
+
React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence || !rowCountValid, onSubmit: () => {
|
|
64
44
|
try {
|
|
65
45
|
if (selectedTranscript) {
|
|
66
46
|
setLaunchViewError(undefined);
|
|
@@ -70,7 +50,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
|
|
|
70
50
|
newViewTitle: `Orthologs - ${getGeneDisplayName(feature)} - ${getTranscriptDisplayName(selectedTranscript)}`,
|
|
71
51
|
orthologParams: {
|
|
72
52
|
taxId,
|
|
73
|
-
|
|
53
|
+
maxSpecies: rowCount,
|
|
74
54
|
geneCandidates,
|
|
75
55
|
msaAlgorithm,
|
|
76
56
|
selectedTranscript,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The species the query gene came from, as free text resolved against NCBI's
|
|
4
|
+
* taxonomy rather than picked from a fixed list.
|
|
5
|
+
*
|
|
6
|
+
* The resolved taxon is shown back as helper text, which is the whole point of
|
|
7
|
+
* resolving on a debounce instead of on submit: a typo resolves to some other
|
|
8
|
+
* organism rather than to nothing, and the only place that surfaces is the gene
|
|
9
|
+
* lookup, as "could not resolve NLRP1 in taxon 9986".
|
|
10
|
+
*/
|
|
11
|
+
export default function QuerySpeciesSelect({ value, assemblyName, onChange, className, }: {
|
|
12
|
+
value: number;
|
|
13
|
+
assemblyName?: string;
|
|
14
|
+
onChange: (taxId: number) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import TextField2 from '../../../components/TextField2';
|
|
3
|
+
import { resolveAssemblySpecies, resolveTaxId, } from '../../../utils/ncbiTaxonomy';
|
|
4
|
+
import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames';
|
|
5
|
+
import { useDebounced, useFetch } from '../../../utils/useFetch';
|
|
6
|
+
async function describeTaxon(query) {
|
|
7
|
+
const taxId = await resolveTaxId(query);
|
|
8
|
+
if (!taxId) {
|
|
9
|
+
throw new Error(`No NCBI taxon matches "${query}"`);
|
|
10
|
+
}
|
|
11
|
+
const info = (await fetchTaxonomyInfo([taxId])).get(taxId);
|
|
12
|
+
const label = [info?.sciname, info?.commonName && `(${info.commonName})`]
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join(' ');
|
|
15
|
+
return { taxId, label: label || `taxon ${taxId}` };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The species the query gene came from, as free text resolved against NCBI's
|
|
19
|
+
* taxonomy rather than picked from a fixed list.
|
|
20
|
+
*
|
|
21
|
+
* The resolved taxon is shown back as helper text, which is the whole point of
|
|
22
|
+
* resolving on a debounce instead of on submit: a typo resolves to some other
|
|
23
|
+
* organism rather than to nothing, and the only place that surfaces is the gene
|
|
24
|
+
* lookup, as "could not resolve NLRP1 in taxon 9986".
|
|
25
|
+
*/
|
|
26
|
+
export default function QuerySpeciesSelect({ value, assemblyName, onChange, className, }) {
|
|
27
|
+
// undefined until the user types, which is what makes the two lookups
|
|
28
|
+
// exclusive rather than both firing on open
|
|
29
|
+
const [typed, setTyped] = useState();
|
|
30
|
+
const debounced = useDebounced(typed, 400);
|
|
31
|
+
// Opening on `human` for everyone is the same silent wrong answer the fixed
|
|
32
|
+
// species list gave: on a mouse assembly the gene symbol resolves to the HUMAN
|
|
33
|
+
// gene, and the excluded taxon is human too, so mouse appears twice. The
|
|
34
|
+
// assembly being browsed is the one thing here that already knows the answer.
|
|
35
|
+
//
|
|
36
|
+
// db=assembly already returns the taxon id, so this is the whole lookup — the
|
|
37
|
+
// taxonomy chain below would be two more requests for an answer we hold. That
|
|
38
|
+
// is not just waste: eutils allows 3 requests a second and throttles by
|
|
39
|
+
// answering without CORS headers, so all four fired on open and the browser
|
|
40
|
+
// reported the throttle as "blocked by CORS policy" in the helper text.
|
|
41
|
+
const { data: fromAssembly } = useFetch(assemblyName && typed === undefined
|
|
42
|
+
? [assemblyName, 'assembly-species']
|
|
43
|
+
: null, () => resolveAssemblySpecies(assemblyName), {
|
|
44
|
+
onSuccess: found => {
|
|
45
|
+
if (found) {
|
|
46
|
+
onChange(found.taxId);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const { data: fromText, error } = useFetch(debounced?.trim() ? [debounced.trim(), 'taxon'] : null, () => describeTaxon(debounced), {
|
|
51
|
+
onSuccess: ({ taxId }) => {
|
|
52
|
+
onChange(taxId);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
// derived, not seeded through an effect: whatever the user typed wins, and
|
|
56
|
+
// until they type anything the assembly's species does, so a lookup that
|
|
57
|
+
// lands while they are mid-word cannot overwrite the field
|
|
58
|
+
const text = typed ?? fromAssembly?.speciesName ?? 'human';
|
|
59
|
+
const resolved = typed === undefined ? fromAssembly?.speciesName : fromText?.label;
|
|
60
|
+
return (React.createElement(TextField2, { variant: "outlined", label: "Query species", className: className, value: text, onChange: event => {
|
|
61
|
+
setTyped(event.target.value);
|
|
62
|
+
}, error: !!error, helperText: error
|
|
63
|
+
? `${error}`
|
|
64
|
+
: (resolved ?? `the species this gene is from (taxon ${value})`) }));
|
|
65
|
+
}
|
|
@@ -3,10 +3,9 @@ import { LoadingEllipses, SanitizedHTML } from '@jbrowse/core/ui';
|
|
|
3
3
|
import { getEnv, getSession } from '@jbrowse/core/util';
|
|
4
4
|
import { MenuItem } from '@mui/material';
|
|
5
5
|
import { observer } from 'mobx-react';
|
|
6
|
-
import useSWR from 'swr';
|
|
7
6
|
import { makeStyles } from 'tss-react/mui';
|
|
8
7
|
import TextField2 from '../../../components/TextField2';
|
|
9
|
-
import {
|
|
8
|
+
import { useFetch } from '../../../utils/useFetch';
|
|
10
9
|
import { getGeneDisplayName, getLinearGenomeView } from '../../util';
|
|
11
10
|
import LaunchPanelContent from '../LaunchPanelContent';
|
|
12
11
|
import SubmitCancelActions from '../SubmitCancelActions';
|
|
@@ -30,20 +29,20 @@ const PreLoadedMSA = observer(function ({ model, feature, handleClose, }) {
|
|
|
30
29
|
const datasets = readMsaDatasets(session.jbrowse);
|
|
31
30
|
const [selectedDatasetId, setSelectedDatasetId] = useState(datasets?.[0]?.datasetId);
|
|
32
31
|
const selectedDataset = datasets?.find(d => d.datasetId === selectedDatasetId);
|
|
33
|
-
const { data: msaList, isLoading: msaListLoading, error: msaListFetchError, } =
|
|
32
|
+
const { data: msaList, isLoading: msaListLoading, error: msaListFetchError, } = useFetch(selectedDataset ? `${selectedDataset.datasetId}-msa-list` : null, () => fetchMSAList({ config: selectedDataset.adapter, pluginManager }));
|
|
34
33
|
const transcriptSelection = useTranscriptSelection({
|
|
35
34
|
feature,
|
|
36
35
|
view,
|
|
37
36
|
validIds: msaList,
|
|
38
37
|
});
|
|
39
38
|
const { selectedId, selectedTranscript } = transcriptSelection;
|
|
40
|
-
const { data: msaData, isLoading: msaDataLoading, error: msaDataFetchError, } =
|
|
39
|
+
const { data: msaData, isLoading: msaDataLoading, error: msaDataFetchError, } = useFetch(selectedId && selectedDataset && msaList
|
|
41
40
|
? `${selectedDataset.datasetId}-${selectedId}-msa`
|
|
42
41
|
: null, () => fetchMSA({
|
|
43
42
|
msaId: selectedId,
|
|
44
43
|
config: selectedDataset.adapter,
|
|
45
44
|
pluginManager,
|
|
46
|
-
})
|
|
45
|
+
}));
|
|
47
46
|
const e = msaListFetchError ??
|
|
48
47
|
msaDataFetchError ??
|
|
49
48
|
transcriptSelection.error ??
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { Feature } from '@jbrowse/core/util';
|
|
2
|
+
interface ViewLike {
|
|
3
|
+
assemblyNames?: string[];
|
|
4
|
+
}
|
|
2
5
|
export declare function useFeatureSequence({ view, feature, }: {
|
|
3
|
-
view:
|
|
4
|
-
assemblyNames?: string[];
|
|
5
|
-
} | undefined;
|
|
6
|
+
view: ViewLike | undefined;
|
|
6
7
|
feature?: Feature;
|
|
7
8
|
}): {
|
|
8
9
|
proteinSequence: string;
|
|
9
10
|
sequence: {
|
|
10
11
|
seq: string;
|
|
11
12
|
} | undefined;
|
|
12
|
-
error:
|
|
13
|
+
error: unknown;
|
|
13
14
|
};
|
|
15
|
+
export {};
|
|
@@ -1,18 +1,27 @@
|
|
|
1
|
+
import { getSession } from '@jbrowse/core/util';
|
|
1
2
|
import { getProteinSequenceFromFeature } from './calculateProteinSequence';
|
|
2
|
-
import {
|
|
3
|
+
import { fetchSeq } from './fetchSeq';
|
|
4
|
+
import { useFetch } from '../../utils/useFetch';
|
|
3
5
|
export function useFeatureSequence({ view, feature, }) {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
feature,
|
|
6
|
+
const assemblyName = view?.assemblyNames?.[0];
|
|
7
|
+
const { data: sequence, error } = useFetch(feature && assemblyName
|
|
8
|
+
? [feature.id(), assemblyName, 'feature-sequence']
|
|
9
|
+
: null, async () => {
|
|
10
|
+
const { start, end, refName } = feature.toJSON();
|
|
11
|
+
return {
|
|
12
|
+
seq: await fetchSeq({
|
|
13
|
+
start,
|
|
14
|
+
end,
|
|
15
|
+
refName,
|
|
16
|
+
assemblyName: assemblyName,
|
|
17
|
+
session: getSession(view),
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
7
20
|
});
|
|
8
|
-
const proteinSequence = sequence && feature
|
|
9
|
-
? getProteinSequenceFromFeature({
|
|
10
|
-
seq: sequence.seq,
|
|
11
|
-
feature,
|
|
12
|
-
})
|
|
13
|
-
: '';
|
|
14
21
|
return {
|
|
15
|
-
proteinSequence
|
|
22
|
+
proteinSequence: sequence && feature
|
|
23
|
+
? getProteinSequenceFromFeature({ seq: sequence.seq, feature })
|
|
24
|
+
: '',
|
|
16
25
|
sequence,
|
|
17
26
|
error,
|
|
18
27
|
};
|
|
@@ -11,6 +11,6 @@ export declare function useTranscriptSelection({ feature, view, validIds, }: {
|
|
|
11
11
|
setSelectedId: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
12
12
|
selectedTranscript: Feature | undefined;
|
|
13
13
|
proteinSequence: string;
|
|
14
|
-
error:
|
|
14
|
+
error: unknown;
|
|
15
15
|
validIds: string[] | undefined;
|
|
16
16
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { types } from '@jbrowse/mobx-state-tree';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { extendStateModel } from './index';
|
|
4
|
+
// A host display whose own contextMenuItems reaches the rest of itself through
|
|
5
|
+
// `this` -- which is what jbrowse-components shipped between b439251a21 and
|
|
6
|
+
// 104bbfc581, and what any host is free to do again. This plugin captures the
|
|
7
|
+
// base method and calls it detached, so a bare call leaves `this` undefined, the
|
|
8
|
+
// read throws inside the ErrorBoundary the menu builds in, and a right-click
|
|
9
|
+
// produces no menu at all: the host's own rows go with it.
|
|
10
|
+
function hostReadingThis(clickedType) {
|
|
11
|
+
return types
|
|
12
|
+
.model('MockDisplay', { id: types.optional(types.string, 'display1') })
|
|
13
|
+
.views(self => ({
|
|
14
|
+
get isGeneLike() {
|
|
15
|
+
return self.id === 'display1';
|
|
16
|
+
},
|
|
17
|
+
}))
|
|
18
|
+
.views(() => ({
|
|
19
|
+
contextMenuItems() {
|
|
20
|
+
return [{ label: `host item ${this.isGeneLike}` }];
|
|
21
|
+
},
|
|
22
|
+
get contextMenuInfo() {
|
|
23
|
+
return {
|
|
24
|
+
item: { featureId: 'f1', type: clickedType },
|
|
25
|
+
displayedRegionIndex: 0,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
fetchFullFeature() {
|
|
29
|
+
return Promise.resolve(undefined);
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
const labels = (stateModel) => stateModel
|
|
34
|
+
.create()
|
|
35
|
+
.contextMenuItems()
|
|
36
|
+
.map((i) => i.label);
|
|
37
|
+
describe('extendStateModel', () => {
|
|
38
|
+
test('calls the host contextMenuItems with a receiver', () => {
|
|
39
|
+
expect(labels(extendStateModel(hostReadingThis('mRNA')))).toEqual([
|
|
40
|
+
'host item true',
|
|
41
|
+
'Launch MSA view',
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
test('leaves the host menu alone when the click is not on a gene', () => {
|
|
45
|
+
expect(labels(extendStateModel(hostReadingThis('CDS')))).toEqual([
|
|
46
|
+
'host item true',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
// two plugins extending the same display each capture the previous
|
|
50
|
+
// contextMenuItems, so the receiver has to survive the whole chain
|
|
51
|
+
test('survives another plugin extending the display underneath it', () => {
|
|
52
|
+
const withOther = extendStateModel(hostReadingThis('mRNA')).views(self => {
|
|
53
|
+
const superContextMenuItems = self.contextMenuItems;
|
|
54
|
+
return {
|
|
55
|
+
contextMenuItems() {
|
|
56
|
+
return [
|
|
57
|
+
...superContextMenuItems.call(self),
|
|
58
|
+
{ label: 'other plugin' },
|
|
59
|
+
];
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
expect(labels(withOther)).toEqual([
|
|
64
|
+
'host item true',
|
|
65
|
+
'Launch MSA view',
|
|
66
|
+
'other plugin',
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type PluginManager from '@jbrowse/core/PluginManager';
|
|
2
|
+
import type { IAnyModelType } from '@jbrowse/mobx-state-tree';
|
|
3
|
+
export declare function extendStateModel(stateModel: IAnyModelType): import("@jbrowse/mobx-state-tree").IModelType<any, any, any, any>;
|
|
2
4
|
export default function LaunchMsaViewF(pluginManager: PluginManager): void;
|
|
@@ -1,51 +1,59 @@
|
|
|
1
1
|
import { getContainingTrack, getSession } from '@jbrowse/core/util';
|
|
2
2
|
import AddIcon from '@mui/icons-material/Add';
|
|
3
3
|
import LaunchMsaViewDialog from './components/LaunchMsaViewDialog';
|
|
4
|
+
import { launchTarget } from './launchTarget';
|
|
4
5
|
function isDisplay(elt) {
|
|
5
6
|
return elt.name === 'LinearBasicDisplay';
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
// Walking to the track and the session at click time, not while the menu is
|
|
9
|
+
// built: contextMenuItems runs on every right-click and, on a host whose base
|
|
10
|
+
// method reads `this`, is the one place a plugin can take the whole menu down.
|
|
11
|
+
// Keeping it to a pure read of the display is also what lets a test call it.
|
|
12
|
+
function openDialog(self, feature) {
|
|
13
|
+
const track = getContainingTrack(self);
|
|
14
|
+
const session = getSession(track);
|
|
15
|
+
feature()
|
|
16
|
+
.then(f => {
|
|
17
|
+
if (f) {
|
|
18
|
+
session.queueDialog(handleClose => [
|
|
19
|
+
LaunchMsaViewDialog,
|
|
20
|
+
{ model: track, handleClose, feature: f },
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
session.notify('Could not load feature for MSA view', 'warning');
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.catch((e) => {
|
|
28
|
+
session.notifyError(`${e}`, e);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function extendStateModel(stateModel) {
|
|
9
32
|
return stateModel.views((self) => {
|
|
10
33
|
const superContextMenuItems = self.contextMenuItems;
|
|
11
34
|
return {
|
|
12
35
|
contextMenuItems() {
|
|
13
|
-
const
|
|
14
|
-
const session = getSession(track);
|
|
15
|
-
const launch = (feature) => {
|
|
16
|
-
session.queueDialog(handleClose => [
|
|
17
|
-
LaunchMsaViewDialog,
|
|
18
|
-
{ model: track, handleClose, feature },
|
|
19
|
-
]);
|
|
20
|
-
};
|
|
21
|
-
const info = self.contextMenuInfo;
|
|
22
|
-
const fetchFullFeature = self.fetchFullFeature;
|
|
23
|
-
const legacyFeature = self.contextMenuFeature;
|
|
24
|
-
const onClick = info && fetchFullFeature && self.isGeneLike
|
|
25
|
-
? () => {
|
|
26
|
-
fetchFullFeature(info.item.featureId, info.displayedRegionIndex)
|
|
27
|
-
.then(feature => {
|
|
28
|
-
if (feature) {
|
|
29
|
-
launch(feature);
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
session.notify('Could not load feature for MSA view', 'warning');
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
.catch((e) => {
|
|
36
|
-
session.notifyError(`${e}`, e);
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
: legacyFeature &&
|
|
40
|
-
GENE_LIKE_TYPES.has(String(legacyFeature.get('type')))
|
|
41
|
-
? () => {
|
|
42
|
-
launch(legacyFeature);
|
|
43
|
-
}
|
|
44
|
-
: undefined;
|
|
36
|
+
const target = launchTarget(self);
|
|
45
37
|
return [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
// .call(self), not a bare call: a host's own contextMenuItems may
|
|
39
|
+
// reach its sibling views through `this`, which is undefined when the
|
|
40
|
+
// captured super is invoked detached. It throws, the ErrorBoundary the
|
|
41
|
+
// menu builds inside swallows it, and the user right-clicks a feature
|
|
42
|
+
// and gets no menu at all -- the host's own rows gone too, which is
|
|
43
|
+
// worse than this plugin contributing nothing. jbrowse-components hit
|
|
44
|
+
// exactly this with `this.isGeneLike` and fixed its side in
|
|
45
|
+
// 104bbfc581, but a plugin cannot choose which host build it runs on.
|
|
46
|
+
...superContextMenuItems.call(self),
|
|
47
|
+
...(target
|
|
48
|
+
? [
|
|
49
|
+
{
|
|
50
|
+
label: 'Launch MSA view',
|
|
51
|
+
icon: AddIcon,
|
|
52
|
+
onClick: () => {
|
|
53
|
+
openDialog(self, target);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]
|
|
49
57
|
: []),
|
|
50
58
|
];
|
|
51
59
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MenuItem } from '@jbrowse/core/ui';
|
|
2
|
+
import type { Feature } from '@jbrowse/core/util';
|
|
3
|
+
export interface ContextMenuInfo {
|
|
4
|
+
item: {
|
|
5
|
+
featureId: string;
|
|
6
|
+
type?: string;
|
|
7
|
+
};
|
|
8
|
+
displayedRegionIndex: number;
|
|
9
|
+
}
|
|
10
|
+
export interface DisplayModel {
|
|
11
|
+
contextMenuItems: () => MenuItem[];
|
|
12
|
+
contextMenuInfo?: ContextMenuInfo;
|
|
13
|
+
fetchFullFeature?: (featureId: string, displayedRegionIndex: number) => Promise<Feature | undefined>;
|
|
14
|
+
contextMenuFeature?: Feature;
|
|
15
|
+
}
|
|
16
|
+
export declare function isGeneLikeType(type: unknown): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* How to get the right-clicked feature, or nothing when there is no gene to
|
|
19
|
+
* launch on. Both host shapes reduce to a thunk, so the menu item is built and
|
|
20
|
+
* the dialog is opened by one code path — and the same gene test decides both.
|
|
21
|
+
* The strict three-name set the legacy branch used to carry disagreed with the
|
|
22
|
+
* loose one above, so a `lnc_RNA` offered the menu item on a 4.3 host and not
|
|
23
|
+
* on a 3.7 one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function launchTarget(self: DisplayModel): (() => Promise<Feature | undefined>) | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Read off the clicked item rather than off the display.
|
|
2
|
+
//
|
|
3
|
+
// LinearBasicDisplay used to publish an `isGeneLike` getter and this gated on
|
|
4
|
+
// it. jbrowse-components 684142b3 (2026-08-16) inlined that getter into its own
|
|
5
|
+
// `contextMenuItems`, and every host built after it returns `undefined` here --
|
|
6
|
+
// so the gate was never satisfied, `onClick` stayed undefined, and the item
|
|
7
|
+
// silently left the right-click menu on every gene track. Nothing failed loudly:
|
|
8
|
+
// the display still had contextMenuInfo and fetchFullFeature, and the menu still
|
|
9
|
+
// opened with its own items in it.
|
|
10
|
+
//
|
|
11
|
+
// A predicate over the type we were already given cannot go the same way, and it
|
|
12
|
+
// costs one comparison. Deliberately the same loose case-insensitive test the
|
|
13
|
+
// host applies (`isGeneLikeType` in collapseIntronsMenu.ts): real GFFs carry
|
|
14
|
+
// 'mRNA', 'lnc_RNA', 'protein_coding_gene', 'transcript'.
|
|
15
|
+
export function isGeneLikeType(type) {
|
|
16
|
+
const t = String(type ?? '').toLowerCase();
|
|
17
|
+
return t.includes('gene') || t.includes('rna') || t.includes('transcript');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How to get the right-clicked feature, or nothing when there is no gene to
|
|
21
|
+
* launch on. Both host shapes reduce to a thunk, so the menu item is built and
|
|
22
|
+
* the dialog is opened by one code path — and the same gene test decides both.
|
|
23
|
+
* The strict three-name set the legacy branch used to carry disagreed with the
|
|
24
|
+
* loose one above, so a `lnc_RNA` offered the menu item on a 4.3 host and not
|
|
25
|
+
* on a 3.7 one.
|
|
26
|
+
*/
|
|
27
|
+
export function launchTarget(self) {
|
|
28
|
+
const info = self.contextMenuInfo;
|
|
29
|
+
const fetchFullFeature = self.fetchFullFeature;
|
|
30
|
+
// exclusive, not a fallthrough: a display publishing contextMenuInfo has
|
|
31
|
+
// already said what was clicked, and reading contextMenuFeature after it
|
|
32
|
+
// rejects the click can only answer with some other feature
|
|
33
|
+
if (info && fetchFullFeature) {
|
|
34
|
+
return isGeneLikeType(info.item.type)
|
|
35
|
+
? () => fetchFullFeature(info.item.featureId, info.displayedRegionIndex)
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
const legacy = self.contextMenuFeature;
|
|
39
|
+
return legacy && isGeneLikeType(legacy.get('type'))
|
|
40
|
+
? () => Promise.resolve(legacy)
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { isGeneLikeType, launchTarget } from './launchTarget';
|
|
3
|
+
function feature(type) {
|
|
4
|
+
return {
|
|
5
|
+
get: (key) => (key === 'type' ? type : undefined),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
const modernHost = (type) => ({
|
|
9
|
+
contextMenuItems: () => [],
|
|
10
|
+
contextMenuInfo: { item: { featureId: 'f1', type }, displayedRegionIndex: 0 },
|
|
11
|
+
fetchFullFeature: (featureId) => Promise.resolve(feature(`fetched:${featureId}`)),
|
|
12
|
+
});
|
|
13
|
+
const legacyHost = (type) => ({
|
|
14
|
+
contextMenuItems: () => [],
|
|
15
|
+
contextMenuFeature: feature(type),
|
|
16
|
+
});
|
|
17
|
+
describe('isGeneLikeType', () => {
|
|
18
|
+
test.each(['gene', 'mRNA', 'transcript', 'lnc_RNA', 'protein_coding_gene'])('accepts %s', type => {
|
|
19
|
+
expect(isGeneLikeType(type)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
test.each(['CDS', 'exon', 'match', 'SNV', undefined, null, 42])('rejects %s', type => {
|
|
22
|
+
expect(isGeneLikeType(type)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('launchTarget', () => {
|
|
26
|
+
test('resolves the clicked feature through fetchFullFeature', async () => {
|
|
27
|
+
const target = launchTarget(modernHost('mRNA'));
|
|
28
|
+
expect(target).toBeDefined();
|
|
29
|
+
expect((await target())?.get('type')).toBe('fetched:f1');
|
|
30
|
+
});
|
|
31
|
+
test('offers nothing for a non-gene click', () => {
|
|
32
|
+
expect(launchTarget(modernHost('CDS'))).toBeUndefined();
|
|
33
|
+
expect(launchTarget(modernHost(undefined))).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
test('offers nothing when nothing was clicked', () => {
|
|
36
|
+
expect(launchTarget({ contextMenuItems: () => [] })).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
// v3.7.0 hosts have contextMenuFeature and nothing else; dropping this
|
|
39
|
+
// fallback once took "Launch MSA view" off every host in the wild.
|
|
40
|
+
test('falls back to a synchronous contextMenuFeature', async () => {
|
|
41
|
+
const target = launchTarget(legacyHost('mRNA'));
|
|
42
|
+
expect(target).toBeDefined();
|
|
43
|
+
expect((await target())?.get('type')).toBe('mRNA');
|
|
44
|
+
});
|
|
45
|
+
test('applies the same gene test on both host shapes', () => {
|
|
46
|
+
expect(launchTarget(legacyHost('lnc_RNA'))).toBeDefined();
|
|
47
|
+
expect(launchTarget(legacyHost('exon'))).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
// a host that has both shapes must not fall through to the legacy branch and
|
|
50
|
+
// launch on a stale feature when the click was not on a gene
|
|
51
|
+
test('a non-gene click on a host carrying both shapes offers nothing', () => {
|
|
52
|
+
expect(launchTarget({ ...modernHost('CDS'), ...legacyHost('mRNA') })).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|