@jbrowse/plugin-linear-genome-view 1.3.4 → 1.5.0

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 (34) hide show
  1. package/dist/BaseLinearDisplay/components/BaseLinearDisplay.d.ts +3 -2
  2. package/dist/BaseLinearDisplay/models/BaseLinearDisplayModel.d.ts +20 -10
  3. package/dist/BaseLinearDisplay/models/serverSideRenderedBlock.d.ts +2 -2
  4. package/dist/LinearBareDisplay/model.d.ts +10 -9
  5. package/dist/LinearBasicDisplay/model.d.ts +13 -9
  6. package/dist/LinearGenomeView/components/Header.d.ts +2 -4
  7. package/dist/LinearGenomeView/components/RefNameAutocomplete.d.ts +1 -11
  8. package/dist/LinearGenomeView/components/ScaleBar.d.ts +46 -14
  9. package/dist/LinearGenomeView/components/util.d.ts +2 -0
  10. package/dist/LinearGenomeView/index.d.ts +13 -2
  11. package/dist/index.d.ts +47 -56
  12. package/dist/plugin-linear-genome-view.cjs.development.js +642 -423
  13. package/dist/plugin-linear-genome-view.cjs.development.js.map +1 -1
  14. package/dist/plugin-linear-genome-view.cjs.production.min.js +1 -1
  15. package/dist/plugin-linear-genome-view.cjs.production.min.js.map +1 -1
  16. package/dist/plugin-linear-genome-view.esm.js +652 -433
  17. package/dist/plugin-linear-genome-view.esm.js.map +1 -1
  18. package/package.json +4 -2
  19. package/src/BaseLinearDisplay/components/BaseLinearDisplay.tsx +100 -21
  20. package/src/BaseLinearDisplay/models/BaseLinearDisplayModel.tsx +10 -10
  21. package/src/BaseLinearDisplay/models/serverSideRenderedBlock.ts +15 -13
  22. package/src/LinearBasicDisplay/model.ts +25 -3
  23. package/src/LinearGenomeView/components/ExportSvgDialog.tsx +17 -8
  24. package/src/LinearGenomeView/components/Header.tsx +101 -104
  25. package/src/LinearGenomeView/components/ImportForm.tsx +146 -113
  26. package/src/LinearGenomeView/components/LinearGenomeView.test.js +6 -6
  27. package/src/LinearGenomeView/components/OverviewScaleBar.tsx +4 -1
  28. package/src/LinearGenomeView/components/RefNameAutocomplete.tsx +196 -169
  29. package/src/LinearGenomeView/components/SearchResultsDialog.tsx +1 -16
  30. package/src/LinearGenomeView/components/SequenceDialog.tsx +59 -58
  31. package/src/LinearGenomeView/components/__snapshots__/LinearGenomeView.test.js.snap +5 -177
  32. package/src/LinearGenomeView/components/util.ts +8 -0
  33. package/src/LinearGenomeView/index.tsx +39 -28
  34. package/src/index.ts +3 -1
@@ -1,23 +1,27 @@
1
+ import React from 'react'
2
+ import { observer } from 'mobx-react'
1
3
  import { getSession } from '@jbrowse/core/util'
4
+ import {
5
+ Button,
6
+ FormGroup,
7
+ Typography,
8
+ makeStyles,
9
+ useTheme,
10
+ alpha,
11
+ } from '@material-ui/core'
2
12
  import BaseResult from '@jbrowse/core/TextSearch/BaseResults'
3
- import Button from '@material-ui/core/Button'
4
- import { makeStyles, useTheme } from '@material-ui/core/styles'
5
- import { alpha } from '@material-ui/core/styles'
6
- import FormGroup from '@material-ui/core/FormGroup'
7
- import Typography from '@material-ui/core/Typography'
8
- import { observer } from 'mobx-react'
9
- import { Instance, getEnv } from 'mobx-state-tree'
10
- import React from 'react'
11
13
 
14
+ // icons
12
15
  import { TrackSelector as TrackSelectorIcon } from '@jbrowse/core/ui/Icons'
13
16
  import ArrowForwardIcon from '@material-ui/icons/ArrowForward'
14
17
  import ArrowBackIcon from '@material-ui/icons/ArrowBack'
15
- import { LinearGenomeViewStateModel, HEADER_BAR_HEIGHT } from '..'
18
+
19
+ // locals
20
+ import { LinearGenomeViewModel, HEADER_BAR_HEIGHT } from '..'
16
21
  import RefNameAutocomplete from './RefNameAutocomplete'
17
22
  import OverviewScaleBar from './OverviewScaleBar'
18
23
  import ZoomControls from './ZoomControls'
19
-
20
- type LGV = Instance<LinearGenomeViewStateModel>
24
+ import { dedupe } from './util'
21
25
 
22
26
  const WIDGET_HEIGHT = 32
23
27
  const SPACING = 7
@@ -58,7 +62,7 @@ const useStyles = makeStyles(theme => ({
58
62
  },
59
63
  }))
60
64
 
61
- const Controls = observer(({ model }: { model: LGV }) => {
65
+ const Controls = observer(({ model }: { model: LinearGenomeViewModel }) => {
62
66
  const classes = useStyles()
63
67
  return (
64
68
  <Button
@@ -73,7 +77,7 @@ const Controls = observer(({ model }: { model: LGV }) => {
73
77
  )
74
78
  })
75
79
 
76
- function PanControls({ model }: { model: LGV }) {
80
+ function PanControls({ model }: { model: LinearGenomeViewModel }) {
77
81
  const classes = useStyles()
78
82
  return (
79
83
  <>
@@ -95,7 +99,7 @@ function PanControls({ model }: { model: LGV }) {
95
99
  )
96
100
  }
97
101
 
98
- const RegionWidth = observer(({ model }: { model: LGV }) => {
102
+ const RegionWidth = observer(({ model }: { model: LinearGenomeViewModel }) => {
99
103
  const classes = useStyles()
100
104
  const { coarseTotalBp } = model
101
105
  return (
@@ -105,104 +109,97 @@ const RegionWidth = observer(({ model }: { model: LGV }) => {
105
109
  )
106
110
  })
107
111
 
108
- const LinearGenomeViewHeader = observer(({ model }: { model: LGV }) => {
109
- const classes = useStyles()
110
- const theme = useTheme()
111
- const session = getSession(model)
112
- const { assemblyManager } = session
113
- const { pluginManager } = getEnv(session)
114
- const { textSearchManager } = pluginManager.rootModel
115
- const {
116
- coarseDynamicBlocks: contentBlocks,
117
- displayedRegions,
118
- rankSearchResults,
119
- } = model
120
- const { assemblyName, refName } = contentBlocks[0] || { refName: '' }
121
- const assembly = assemblyName && assemblyManager.get(assemblyName)
122
- const regions = (assembly && assembly.regions) || []
123
- const searchScope = model.searchScope(assemblyName)
124
- async function setDisplayedRegion(result: BaseResult) {
125
- if (result) {
126
- const newRegionValue = result.getLocation()
127
- // need to fix finding region
128
- const newRegion = regions.find(
129
- region => newRegionValue === region.refName,
112
+ const LinearGenomeViewHeader = observer(
113
+ ({ model }: { model: LinearGenomeViewModel }) => {
114
+ const classes = useStyles()
115
+ const theme = useTheme()
116
+ const session = getSession(model)
117
+
118
+ const { textSearchManager, assemblyManager } = session
119
+ const { assemblyNames, rankSearchResults } = model
120
+ const assemblyName = assemblyNames[0]
121
+ const assembly = assemblyManager.get(assemblyName)
122
+ const searchScope = model.searchScope(assemblyName)
123
+
124
+ async function fetchResults(queryString: string) {
125
+ if (!textSearchManager) {
126
+ console.warn('No text search manager')
127
+ }
128
+ const results = await textSearchManager?.search(
129
+ {
130
+ queryString: queryString.toLowerCase(),
131
+ searchType: 'exact',
132
+ },
133
+ searchScope,
134
+ rankSearchResults,
130
135
  )
131
- if (newRegion) {
132
- model.setDisplayedRegions([newRegion])
133
- // we use showAllRegions after setDisplayedRegions to make the entire
134
- // region visible, xref #1703
135
- model.showAllRegions()
136
- } else {
137
- const results =
138
- (await textSearchManager?.search(
139
- {
140
- queryString: newRegionValue.toLocaleLowerCase(),
141
- searchType: 'exact',
142
- },
143
- searchScope,
144
- rankSearchResults,
145
- )) || []
146
- // distinguishes between locstrings and search strings
147
- if (results.length > 0) {
148
- model.setSearchResults(results, newRegionValue.toLocaleLowerCase())
136
+ return dedupe(results)
137
+ }
138
+
139
+ async function handleSelectedRegion(option: BaseResult) {
140
+ let trackId = option.getTrackId()
141
+ let location = option.getLocation()
142
+ const label = option.getLabel()
143
+ try {
144
+ if (assembly?.refNames?.includes(location)) {
145
+ model.navToLocString(location)
149
146
  } else {
150
- try {
151
- newRegionValue !== '' && model.navToLocString(newRegionValue)
152
- } catch (e) {
153
- if (
154
- `${e}` === `Error: Unknown reference sequence "${newRegionValue}"`
155
- ) {
156
- model.setSearchResults(
157
- results,
158
- newRegionValue.toLocaleLowerCase(),
159
- )
160
- } else {
161
- console.warn(e)
162
- session.notify(`${e}`, 'warning')
163
- }
147
+ const results = await fetchResults(label)
148
+ if (results && results.length > 1) {
149
+ model.setSearchResults(results, label.toLowerCase())
150
+ return
151
+ } else if (results?.length === 1) {
152
+ location = results[0].getLocation()
153
+ trackId = results[0].getTrackId()
154
+ }
155
+
156
+ model.navToLocString(location, assemblyName)
157
+ if (trackId) {
158
+ model.showTrack(trackId)
164
159
  }
165
160
  }
161
+ } catch (e) {
162
+ console.error(e)
163
+ session.notify(`${e}`, 'warning')
166
164
  }
167
165
  }
168
- }
169
-
170
- const controls = (
171
- <div className={classes.headerBar}>
172
- <Controls model={model} />
173
- <div className={classes.spacer} />
174
- <FormGroup row className={classes.headerForm}>
175
- <PanControls model={model} />
176
- <RefNameAutocomplete
177
- onSelect={setDisplayedRegion}
178
- assemblyName={assemblyName}
179
- value={displayedRegions.length > 1 ? '' : refName}
180
- model={model}
181
- TextFieldProps={{
182
- variant: 'outlined',
183
- className: classes.headerRefName,
184
- style: { margin: SPACING, minWidth: '175px' },
185
- InputProps: {
186
- style: {
187
- padding: 0,
188
- height: WIDGET_HEIGHT,
189
- background: alpha(theme.palette.background.paper, 0.8),
166
+
167
+ const controls = (
168
+ <div className={classes.headerBar}>
169
+ <Controls model={model} />
170
+ <div className={classes.spacer} />
171
+ <FormGroup row className={classes.headerForm}>
172
+ <PanControls model={model} />
173
+ <RefNameAutocomplete
174
+ onSelect={handleSelectedRegion}
175
+ assemblyName={assemblyName}
176
+ model={model}
177
+ TextFieldProps={{
178
+ variant: 'outlined',
179
+ className: classes.headerRefName,
180
+ style: { margin: SPACING, minWidth: '175px' },
181
+ InputProps: {
182
+ style: {
183
+ padding: 0,
184
+ height: WIDGET_HEIGHT,
185
+ background: alpha(theme.palette.background.paper, 0.8),
186
+ },
190
187
  },
191
- },
192
- }}
193
- />
194
- </FormGroup>
195
- <RegionWidth model={model} />
196
- <ZoomControls model={model} />
197
- <div className={classes.spacer} />
198
- </div>
199
- )
188
+ }}
189
+ />
190
+ </FormGroup>
191
+ <RegionWidth model={model} />
192
+ <ZoomControls model={model} />
193
+ <div className={classes.spacer} />
194
+ </div>
195
+ )
200
196
 
201
- if (model.hideHeaderOverview) {
202
- return controls
203
- }
197
+ if (model.hideHeaderOverview) {
198
+ return controls
199
+ }
204
200
 
205
- return <OverviewScaleBar model={model}>{controls}</OverviewScaleBar>
206
- })
201
+ return <OverviewScaleBar model={model}>{controls}</OverviewScaleBar>
202
+ },
203
+ )
207
204
 
208
205
  export default LinearGenomeViewHeader
@@ -1,7 +1,9 @@
1
1
  import React, { useState } from 'react'
2
2
  import { observer } from 'mobx-react'
3
- import { getEnv } from 'mobx-state-tree'
4
3
  import { getSession } from '@jbrowse/core/util'
4
+ import BaseResult, {
5
+ RefSequenceResult,
6
+ } from '@jbrowse/core/TextSearch/BaseResults'
5
7
  import AssemblySelector from '@jbrowse/core/ui/AssemblySelector'
6
8
  import {
7
9
  Button,
@@ -15,14 +17,12 @@ import {
15
17
  import RefNameAutocomplete from './RefNameAutocomplete'
16
18
  import SearchResultsDialog from './SearchResultsDialog'
17
19
  import { LinearGenomeViewModel } from '..'
20
+ import { dedupe } from './util'
18
21
 
19
22
  const useStyles = makeStyles(theme => ({
20
23
  importFormContainer: {
21
24
  padding: theme.spacing(2),
22
25
  },
23
- importFormEntry: {
24
- minWidth: 180,
25
- },
26
26
  button: {
27
27
  margin: theme.spacing(2),
28
28
  },
@@ -41,16 +41,14 @@ const ErrorDisplay = observer(({ error }: { error?: Error | string }) => {
41
41
  const ImportForm = observer(({ model }: { model: LGV }) => {
42
42
  const classes = useStyles()
43
43
  const session = getSession(model)
44
- const { assemblyNames, assemblyManager } = session
45
- const { pluginManager } = getEnv(session)
46
- const { textSearchManager } = pluginManager.rootModel
44
+ const { assemblyNames, assemblyManager, textSearchManager } = session
47
45
  const {
48
46
  rankSearchResults,
49
47
  isSearchDialogDisplayed,
50
48
  error: modelError,
51
49
  } = model
52
- const [selectedAsm, setSelectedAsm] = useState<string>(assemblyNames[0])
53
- const [error, setError] = useState<Error | string | undefined>(modelError)
50
+ const [selectedAsm, setSelectedAsm] = useState(assemblyNames[0])
51
+ const [error, setError] = useState<typeof modelError | undefined>(modelError)
54
52
  const message = !assemblyNames.length ? 'No configured assemblies' : ''
55
53
  const searchScope = model.searchScope(selectedAsm)
56
54
 
@@ -60,123 +58,158 @@ const ImportForm = observer(({ model }: { model: LGV }) => {
60
58
  : 'No configured assemblies'
61
59
  const regions = assembly?.regions || []
62
60
  const err = assemblyError || error
63
- const [mySelectedRegion, setSelectedRegion] = useState<string>()
64
- const selectedRegion = mySelectedRegion || regions[0]?.refName
65
61
 
62
+ const [myOption, setOption] = useState<BaseResult | undefined>()
63
+
64
+ // use this instead of useState initializer because the useState initializer
65
+ // won't update in response to an observable
66
+ const option =
67
+ myOption ||
68
+ new RefSequenceResult({
69
+ refName: regions[0]?.refName,
70
+ label: regions[0]?.refName,
71
+ })
72
+
73
+ const selectedRegion = option?.getLocation()
74
+
75
+ async function fetchResults(queryString: string) {
76
+ if (!textSearchManager) {
77
+ console.warn('No text search manager')
78
+ }
79
+ const results = await textSearchManager?.search(
80
+ {
81
+ queryString: queryString.toLowerCase(),
82
+ searchType: 'exact',
83
+ },
84
+ searchScope,
85
+ rankSearchResults,
86
+ )
87
+
88
+ return dedupe(results)
89
+ }
90
+
91
+ /**
92
+ * gets a string as input, or use stored option results from previous query,
93
+ * then re-query and
94
+ * 1) if it has multiple results: pop a dialog
95
+ * 2) if it's a single result navigate to it
96
+ * 3) else assume it's a locstring and navigate to it
97
+ */
66
98
  async function handleSelectedRegion(input: string) {
67
- const newRegion = regions.find(r => selectedRegion === r.refName)
68
- if (newRegion) {
69
- model.setDisplayedRegions([newRegion])
70
- // we use showAllRegions after setDisplayedRegions to make the entire
71
- // region visible, xref #1703
72
- model.showAllRegions()
73
- } else {
74
- const results = await textSearchManager?.search(
75
- {
76
- queryString: input.toLocaleLowerCase(),
77
- searchType: 'exact',
78
- },
79
- searchScope,
80
- rankSearchResults,
81
- )
82
- if (results?.length > 0) {
83
- model.setSearchResults(results, input.toLocaleLowerCase())
99
+ if (!option) {
100
+ return
101
+ }
102
+ let trackId = option.getTrackId()
103
+ let location = input || option.getLocation() || ''
104
+ try {
105
+ if (assembly?.refNames?.includes(location)) {
106
+ model.navToLocString(location, selectedAsm)
84
107
  } else {
85
- try {
86
- input && model.navToLocString(input, selectedAsm)
87
- } catch (e) {
88
- if (`${e}` === `Error: Unknown reference sequence "${input}"`) {
89
- model.setSearchResults(results, input.toLocaleLowerCase())
90
- } else {
91
- console.warn(e)
92
- session.notify(`${e}`, 'warning')
93
- }
108
+ const results = await fetchResults(input)
109
+ if (results && results.length > 1) {
110
+ model.setSearchResults(results, input.toLowerCase())
111
+ return
112
+ } else if (results?.length === 1) {
113
+ location = results[0].getLocation()
114
+ trackId = results[0].getTrackId()
115
+ }
116
+
117
+ model.navToLocString(location, selectedAsm)
118
+ if (trackId) {
119
+ model.showTrack(trackId)
94
120
  }
95
121
  }
122
+ } catch (e) {
123
+ console.error(e)
124
+ session.notify(`${e}`, 'warning')
96
125
  }
97
126
  }
98
127
 
128
+ // implementation notes:
129
+ // having this wrapped in a form allows intuitive use of enter key to submit
99
130
  return (
100
131
  <div>
101
132
  {err ? <ErrorDisplay error={err} /> : null}
102
-
103
133
  <Container className={classes.importFormContainer}>
104
- <Grid container spacing={1} justifyContent="center" alignItems="center">
105
- <Grid item>
106
- <AssemblySelector
107
- onChange={val => {
108
- setError(undefined)
109
- setSelectedAsm(val)
110
- }}
111
- session={session}
112
- selected={selectedAsm}
113
- />
114
- </Grid>
115
- <Grid item>
116
- {selectedAsm ? (
117
- err ? (
118
- <Typography color="error">X</Typography>
119
- ) : selectedRegion && model.volatileWidth ? (
120
- <RefNameAutocomplete
121
- model={model}
122
- assemblyName={message ? undefined : selectedAsm}
123
- value={selectedRegion}
124
- onSelect={option => setSelectedRegion(option.getLocation())}
125
- TextFieldProps={{
126
- margin: 'normal',
127
- variant: 'outlined',
128
- helperText: 'Enter a sequence or location',
129
- className: classes.importFormEntry,
130
- onBlur: event => {
131
- if (event.target.value !== '') {
132
- setSelectedRegion(event.target.value)
133
- }
134
- },
135
- onKeyPress: event => {
136
- const elt = event.target as HTMLInputElement
137
- // maybe check regular expression here to see if it's a
138
- // locstring try defaulting exact matches to first exact
139
- // match
140
- if (event.key === 'Enter') {
141
- handleSelectedRegion(elt.value)
142
- }
143
- },
144
- }}
145
- />
146
- ) : (
147
- <CircularProgress role="progressbar" size={20} disableShrink />
148
- )
149
- ) : null}
150
- </Grid>
151
- <Grid item>
152
- <Button
153
- disabled={!selectedRegion}
154
- className={classes.button}
155
- onClick={() => {
156
- model.setError(undefined)
157
- if (selectedRegion) {
158
- handleSelectedRegion(selectedRegion)
159
- }
160
- }}
161
- variant="contained"
162
- color="primary"
163
- >
164
- Open
165
- </Button>
166
- <Button
167
- disabled={!selectedRegion}
168
- className={classes.button}
169
- onClick={() => {
170
- model.setError(undefined)
171
- model.showAllRegionsInAssembly(selectedAsm)
172
- }}
173
- variant="contained"
174
- color="secondary"
175
- >
176
- Show all regions in assembly
177
- </Button>
134
+ <form
135
+ onSubmit={event => {
136
+ event.preventDefault()
137
+ }}
138
+ >
139
+ <Grid
140
+ container
141
+ spacing={1}
142
+ justifyContent="center"
143
+ alignItems="center"
144
+ >
145
+ <Grid item>
146
+ <AssemblySelector
147
+ onChange={val => {
148
+ setError(undefined)
149
+ setSelectedAsm(val)
150
+ }}
151
+ session={session}
152
+ selected={selectedAsm}
153
+ />
154
+ </Grid>
155
+ <Grid item>
156
+ {selectedAsm ? (
157
+ err ? (
158
+ <Typography color="error">X</Typography>
159
+ ) : selectedRegion && model.volatileWidth ? (
160
+ <RefNameAutocomplete
161
+ model={model}
162
+ assemblyName={message ? undefined : selectedAsm}
163
+ value={selectedRegion}
164
+ onSelect={option => {
165
+ setOption(option)
166
+ }}
167
+ TextFieldProps={{
168
+ margin: 'normal',
169
+ variant: 'outlined',
170
+ helperText: 'Enter a sequence or location',
171
+ }}
172
+ />
173
+ ) : (
174
+ <CircularProgress
175
+ role="progressbar"
176
+ size={20}
177
+ disableShrink
178
+ />
179
+ )
180
+ ) : null}
181
+ </Grid>
182
+ <Grid item>
183
+ <Button
184
+ type="submit"
185
+ disabled={!selectedRegion}
186
+ className={classes.button}
187
+ onClick={() => {
188
+ model.setError(undefined)
189
+ if (selectedRegion) {
190
+ handleSelectedRegion(selectedRegion)
191
+ }
192
+ }}
193
+ variant="contained"
194
+ color="primary"
195
+ >
196
+ Open
197
+ </Button>
198
+ <Button
199
+ disabled={!selectedRegion}
200
+ className={classes.button}
201
+ onClick={() => {
202
+ model.setError(undefined)
203
+ model.showAllRegionsInAssembly(selectedAsm)
204
+ }}
205
+ variant="contained"
206
+ color="secondary"
207
+ >
208
+ Show all regions in assembly
209
+ </Button>
210
+ </Grid>
178
211
  </Grid>
179
- </Grid>
212
+ </form>
180
213
  </Container>
181
214
  {isSearchDialogDisplayed ? (
182
215
  <SearchResultsDialog
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render } from '@testing-library/react'
2
+ import { fireEvent, render, waitFor } from '@testing-library/react'
3
3
  import { createTestSession } from '@jbrowse/web/src/rootModel'
4
4
  import sizeMe from 'react-sizeme'
5
5
  import 'requestidlecallback-polyfill'
@@ -34,12 +34,12 @@ describe('<LinearGenomeView />', () => {
34
34
  session.addView('LinearGenomeView', { id: 'lgv' })
35
35
  const model = session.views[0]
36
36
  model.setWidth(800)
37
- const { container, findByText } = render(<LinearGenomeView model={model} />)
38
- const openButton = await findByText('Open')
39
- expect(container.firstChild).toMatchSnapshot()
37
+ const { findByText } = render(<LinearGenomeView model={model} />)
40
38
  expect(model.displayedRegions.length).toEqual(0)
41
- openButton.click()
42
- expect(model.displayedRegions.length).toEqual(1)
39
+ fireEvent.click(await findByText('Open'))
40
+ await waitFor(() => {
41
+ expect(model.displayedRegions.length).toEqual(1)
42
+ })
43
43
  })
44
44
  it('renders one track, one region', async () => {
45
45
  const session = createTestSession()
@@ -263,7 +263,10 @@ const ScaleBar = observer(
263
263
  >
264
264
  {/* name of sequence */}
265
265
  <Typography
266
- style={{ color: refNameColor }}
266
+ style={{
267
+ color: refNameColor,
268
+ zIndex: 100,
269
+ }}
267
270
  className={classes.scaleBarRefName}
268
271
  >
269
272
  {seq.refName}