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.
Files changed (69) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.d.ts +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.js +14 -17
  3. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -34
  4. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +16 -0
  5. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +65 -0
  6. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +4 -5
  7. package/dist/LaunchMsaView/components/useFeatureSequence.d.ts +6 -4
  8. package/dist/LaunchMsaView/components/useFeatureSequence.js +20 -11
  9. package/dist/LaunchMsaView/components/useTranscriptSelection.d.ts +1 -1
  10. package/dist/LaunchMsaView/extendStateModel.test.d.ts +1 -0
  11. package/dist/LaunchMsaView/extendStateModel.test.js +69 -0
  12. package/dist/LaunchMsaView/index.d.ts +2 -0
  13. package/dist/LaunchMsaView/index.js +45 -37
  14. package/dist/LaunchMsaView/launchTarget.d.ts +25 -0
  15. package/dist/LaunchMsaView/launchTarget.js +42 -0
  16. package/dist/LaunchMsaView/launchTarget.test.d.ts +1 -0
  17. package/dist/LaunchMsaView/launchTarget.test.js +54 -0
  18. package/dist/MsaViewPanel/doLaunchOrthologs.js +45 -12
  19. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +93 -19
  20. package/dist/MsaViewPanel/model.d.ts +23 -10
  21. package/dist/jbrowse-plugin-msaview.umd.production.min.js +31 -44
  22. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  23. package/dist/utils/ebiJobDispatcher.js +26 -1
  24. package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
  25. package/dist/utils/ebiJobDispatcher.test.js +55 -0
  26. package/dist/utils/eutils.d.ts +9 -0
  27. package/dist/utils/eutils.js +18 -0
  28. package/dist/utils/ncbiOrthologs.d.ts +17 -75
  29. package/dist/utils/ncbiOrthologs.js +67 -63
  30. package/dist/utils/ncbiOrthologs.test.js +82 -2
  31. package/dist/utils/ncbiTaxonomy.d.ts +30 -0
  32. package/dist/utils/ncbiTaxonomy.js +66 -0
  33. package/dist/utils/ncbiTaxonomy.test.d.ts +1 -0
  34. package/dist/utils/ncbiTaxonomy.test.js +69 -0
  35. package/dist/utils/useFetch.d.ts +22 -0
  36. package/dist/utils/useFetch.js +0 -0
  37. package/dist/utils/useFetch.test.d.ts +1 -0
  38. package/dist/utils/useFetch.test.js +23 -0
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +4 -5
  42. package/src/LaunchMsaView/components/BlastQuery/useCachedBlastResults.ts +16 -29
  43. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +34 -71
  44. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +106 -0
  45. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +3 -6
  46. package/src/LaunchMsaView/components/useFeatureSequence.ts +35 -15
  47. package/src/LaunchMsaView/extendStateModel.test.ts +76 -0
  48. package/src/LaunchMsaView/index.ts +47 -62
  49. package/src/LaunchMsaView/launchTarget.test.ts +77 -0
  50. package/src/LaunchMsaView/launchTarget.ts +65 -0
  51. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +109 -21
  52. package/src/MsaViewPanel/doLaunchOrthologs.ts +58 -13
  53. package/src/MsaViewPanel/model.ts +13 -4
  54. package/src/utils/ebiJobDispatcher.test.ts +63 -0
  55. package/src/utils/ebiJobDispatcher.ts +32 -3
  56. package/src/utils/eutils.ts +19 -0
  57. package/src/utils/ncbiOrthologs.test.ts +96 -2
  58. package/src/utils/ncbiOrthologs.ts +83 -71
  59. package/src/utils/ncbiTaxonomy.test.ts +84 -0
  60. package/src/utils/ncbiTaxonomy.ts +80 -0
  61. package/src/utils/useFetch.test.ts +30 -0
  62. package/src/utils/useFetch.ts +0 -0
  63. package/src/version.ts +1 -1
  64. package/dist/LaunchMsaView/components/useSWRFeatureSequence.d.ts +0 -12
  65. package/dist/LaunchMsaView/components/useSWRFeatureSequence.js +0 -25
  66. package/dist/utils/swrConfig.d.ts +0 -8
  67. package/dist/utils/swrConfig.js +0 -8
  68. package/src/LaunchMsaView/components/useSWRFeatureSequence.ts +0 -54
  69. 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: any;
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 { staticSwrConfig } from '../../../utils/swrConfig';
2
+ import { useFetch } from '../../../utils/useFetch';
4
3
  export function useCachedBlastResults(geneIds) {
5
- const { data: results, error, isLoading, mutate, } = useSWR(`cached-blast-${geneIds.join(',')}`, async () => {
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
- }, staticSwrConfig);
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
- handleClearAll,
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 { Checkbox, FormControlLabel, MenuItem, Typography } from '@mui/material';
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 { COMMON_SPECIES } from '../../../utils/ncbiOrthologs';
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 [excluded, setExcluded] = useState([]);
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 taxa = COMMON_SPECIES.map(s => s.taxId).filter(t => !excluded.includes(t));
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, aligned at EBI in seconds rather than the 10+ minutes BLAST takes."),
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(TextField2, { variant: "outlined", label: "Query species", className: classes.selectField, select: true, value: taxId, onChange: event => {
53
- setTaxId(Number(event.target.value));
54
- }, helperText: "the species this gene is from" }, COMMON_SPECIES.map(s => (React.createElement(MenuItem, { value: s.taxId, key: s.taxId }, s.label)))),
55
- React.createElement(MsaAlgorithmSelect, { className: classes.selectField, value: msaAlgorithm, onChange: setMsaAlgorithm })),
56
- React.createElement(Typography, { variant: "subtitle2", style: { marginTop: 8 } }, "Species to include (those without an ortholog are skipped)"),
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 || taxa.length < 2, onSubmit: () => {
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
- taxa,
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 { staticSwrConfig } from '../../../utils/swrConfig';
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, } = useSWR(selectedDataset ? `${selectedDataset.datasetId}-msa-list` : null, () => fetchMSAList({ config: selectedDataset.adapter, pluginManager }), staticSwrConfig);
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, } = useSWR(selectedId && selectedDataset && msaList
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
- }), staticSwrConfig);
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: any;
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 { useSWRFeatureSequence } from './useSWRFeatureSequence';
3
+ import { fetchSeq } from './fetchSeq';
4
+ import { useFetch } from '../../utils/useFetch';
3
5
  export function useFeatureSequence({ view, feature, }) {
4
- const { sequence, error } = useSWRFeatureSequence({
5
- view,
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: any;
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
- const GENE_LIKE_TYPES = new Set(['gene', 'mRNA', 'transcript']);
8
- function extendStateModel(stateModel) {
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 track = getContainingTrack(self);
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
- ...superContextMenuItems(),
47
- ...(onClick
48
- ? [{ label: 'Launch MSA view', icon: AddIcon, onClick }]
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
+ });