jbrowse-plugin-msaview 3.1.0 → 3.2.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 (43) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -1
  3. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
  4. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -1
  5. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +1 -1
  7. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
  8. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
  9. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
  10. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
  11. package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
  12. package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
  13. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
  14. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
  15. package/dist/index.js +4 -1
  16. package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
  17. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  18. package/dist/utils/launchMsaView.d.ts +19 -0
  19. package/dist/utils/launchMsaView.js +13 -0
  20. package/dist/utils/workspaces.d.ts +34 -0
  21. package/dist/utils/workspaces.js +100 -0
  22. package/dist/utils/workspaces.test.d.ts +1 -0
  23. package/dist/utils/workspaces.test.js +100 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +4 -1
  27. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
  28. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
  29. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
  30. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
  31. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
  32. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +1 -1
  33. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
  34. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
  35. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
  36. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
  37. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
  38. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
  39. package/src/index.ts +4 -1
  40. package/src/utils/launchMsaView.ts +30 -0
  41. package/src/utils/workspaces.test.ts +132 -0
  42. package/src/utils/workspaces.ts +146 -0
  43. package/src/version.ts +1 -1
@@ -1,4 +1,7 @@
1
+ import { launchMsaView } from '../utils/launchMsaView'
2
+
1
3
  import type { OrthologParams } from '../MsaViewPanel/model'
4
+ import type { MsaViewPlacement } from '../utils/workspaces'
2
5
  import type PluginManager from '@jbrowse/core/PluginManager'
3
6
  import type { AbstractSessionModel } from '@jbrowse/core/util'
4
7
 
@@ -39,6 +42,23 @@ interface LaunchMsaViewArgs {
39
42
  * snapshot property passes through the same way.
40
43
  */
41
44
  allowedGappyness?: number
45
+ /**
46
+ * Where the view lands: `stack` (default), `splitRight` or `newTab`. The
47
+ * declarative half of the launch — a link states the arrangement it wants
48
+ * instead of the reader dragging the view into place.
49
+ *
50
+ * The default is `stack` and has to stay `stack`: it is what every link
51
+ * written before this key existed already does, and the only thing an
52
+ * embedded session can do. The launch DIALOG defaults to `splitRight`
53
+ * instead (see `DEFAULT_LAUNCH_PLACEMENT`), because a launch from a gene
54
+ * feature is a connected pair and reads as a split.
55
+ *
56
+ * A spec that arranges more than these two views should use the host's own
57
+ * `layout` key instead, which states the whole tree and is applied after
58
+ * every view in the spec has launched. Both may be given; `layout` wins,
59
+ * being the later and more specific statement.
60
+ */
61
+ placement?: MsaViewPlacement
42
62
  }
43
63
 
44
64
  export default function LaunchMsaViewExtensionPointF(
@@ -76,8 +96,7 @@ export default function LaunchMsaViewExtensionPointF(
76
96
  // directly, and so is orthologParams (the model's own autorun picks it up).
77
97
  // Only sources needing launch-time resolution go through `init`: msaUrl
78
98
  // (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
79
- session.addView('MsaView', {
80
- type: 'MsaView',
99
+ launchMsaView(session, {
81
100
  ...rest,
82
101
  data,
83
102
  ...(treeFileLocation
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import BgzipFastaMsaAdapterF from './BgzipFastaMsaAdapter'
9
9
  import LaunchMsaViewF from './LaunchMsaView'
10
10
  import LaunchMsaViewExtensionPointF from './LaunchMsaViewExtensionPoint'
11
11
  import MsaViewF from './MsaViewPanel'
12
+ import { launchMsaView } from './utils/launchMsaView'
12
13
  import { version } from './version'
13
14
 
14
15
  import type PluginManager from '@jbrowse/core/PluginManager'
@@ -32,7 +33,9 @@ export default class MsaViewPlugin extends Plugin {
32
33
  label: 'Multiple sequence alignment view',
33
34
  icon: GridOn,
34
35
  onClick: (session: AbstractSessionModel) => {
35
- session.addView('MsaView', {})
36
+ // stacked, by default: nothing was launched from, so there is no
37
+ // connected view for it to sit beside
38
+ launchMsaView(session, {})
36
39
  },
37
40
  })
38
41
  }
@@ -0,0 +1,30 @@
1
+ import { placeMsaView } from './workspaces'
2
+
3
+ import type { MsaViewPlacement } from './workspaces'
4
+ import type { AbstractSessionModel } from '@jbrowse/core/util'
5
+
6
+ /**
7
+ * A launch, stated: what the view is, and where it goes. Everything but
8
+ * `placement` is a react-msaview or plugin-model snapshot property, passed
9
+ * through untouched so this never becomes a list that has to grow.
10
+ */
11
+ export interface MsaViewLaunchSpec extends Record<string, unknown> {
12
+ /** default `stack`, the only thing an embedded session can do */
13
+ placement?: MsaViewPlacement
14
+ }
15
+
16
+ /**
17
+ * The one place a launch adds an MSA view -- the dialog's four tabs, the Add
18
+ * menu, and the `LaunchView-MsaView` extension point a session spec arrives on
19
+ * all come through here. Each of them used to run its own `addView` and none
20
+ * placed the result, which is how a launch from a gene feature landed stacked
21
+ * under the very genome view it was connected to.
22
+ */
23
+ export function launchMsaView(
24
+ session: AbstractSessionModel,
25
+ { placement = 'stack', ...snapshot }: MsaViewLaunchSpec,
26
+ ) {
27
+ const view = session.addView('MsaView', { type: 'MsaView', ...snapshot })
28
+ placeMsaView(session, view.id, placement)
29
+ return view
30
+ }
@@ -0,0 +1,132 @@
1
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest'
2
+
3
+ import { launchMsaView } from './launchMsaView'
4
+ import {
5
+ DEFAULT_LAUNCH_PLACEMENT,
6
+ LAUNCH_PLACEMENT_KEY,
7
+ placeMsaView,
8
+ readLaunchPlacement,
9
+ resetWorkspacesWarning,
10
+ sessionSupportsPlacement,
11
+ writeLaunchPlacement,
12
+ } from './workspaces'
13
+
14
+ import type { AbstractSessionModel } from '@jbrowse/core/util'
15
+
16
+ interface Recorded {
17
+ moves: unknown[]
18
+ workspaces: boolean[]
19
+ added: { type: string; snapshot: Record<string, unknown> }[]
20
+ }
21
+
22
+ function makeSession({
23
+ canPlace = true,
24
+ canEnable = true,
25
+ }: { canPlace?: boolean; canEnable?: boolean } = {}) {
26
+ const recorded: Recorded = { moves: [], workspaces: [], added: [] }
27
+ const session: Record<string, unknown> = {
28
+ addView(type: string, snapshot: Record<string, unknown>) {
29
+ recorded.added.push({ type, snapshot })
30
+ return { id: `view-${recorded.added.length}` }
31
+ },
32
+ }
33
+ if (canPlace) {
34
+ session.setPendingMove = (move: unknown) => recorded.moves.push(move)
35
+ }
36
+ if (canEnable) {
37
+ session.setUseWorkspaces = (on: boolean) => recorded.workspaces.push(on)
38
+ }
39
+ return { session: session as unknown as AbstractSessionModel, recorded }
40
+ }
41
+
42
+ // node has no localStorage, and the plugin runs in a browser -- a Map-backed
43
+ // stub keeps read/write round-tripping without pulling in jsdom
44
+ function stubStorage() {
45
+ const store = new Map<string, string>()
46
+ vi.stubGlobal('localStorage', {
47
+ getItem: (key: string) => store.get(key) ?? null,
48
+ setItem: (key: string, value: string) => store.set(key, value),
49
+ })
50
+ }
51
+
52
+ beforeEach(() => {
53
+ resetWorkspacesWarning()
54
+ stubStorage()
55
+ })
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks()
59
+ vi.unstubAllGlobals()
60
+ })
61
+
62
+ test('stack places nothing, on a host that could tile', () => {
63
+ const { session, recorded } = makeSession()
64
+ placeMsaView(session, 'view-1', 'stack')
65
+ expect(recorded.moves).toEqual([])
66
+ expect(recorded.workspaces).toEqual([])
67
+ })
68
+
69
+ test('splitRight asks for the move, then turns workspaces on', () => {
70
+ const { session, recorded } = makeSession()
71
+ placeMsaView(session, 'view-1', 'splitRight')
72
+ expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }])
73
+ expect(recorded.workspaces).toEqual([true])
74
+ })
75
+
76
+ test('newTab is the same path with the other move type', () => {
77
+ const { session, recorded } = makeSession()
78
+ placeMsaView(session, 'view-1', 'newTab')
79
+ expect(recorded.moves).toEqual([{ type: 'newTab', viewId: 'view-1' }])
80
+ })
81
+
82
+ // an embedded session has no workspaces at all, so there is nothing to report
83
+ test('a session with neither action is a silent no-op', () => {
84
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
85
+ const { session, recorded } = makeSession({
86
+ canPlace: false,
87
+ canEnable: false,
88
+ })
89
+ placeMsaView(session, 'view-1', 'splitRight')
90
+ expect(recorded.moves).toEqual([])
91
+ expect(warn).not.toHaveBeenCalled()
92
+ expect(sessionSupportsPlacement(session)).toBe(false)
93
+ })
94
+
95
+ // the shape that broke jbrowse-plugin-protein3d silently: workspaces are there,
96
+ // the action this plugin reaches for is not
97
+ test('a half-supported host warns once and stacks', () => {
98
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
99
+ const { session, recorded } = makeSession({ canPlace: false })
100
+ placeMsaView(session, 'view-1', 'splitRight')
101
+ placeMsaView(session, 'view-2', 'splitRight')
102
+ expect(recorded.moves).toEqual([])
103
+ expect(warn).toHaveBeenCalledTimes(1)
104
+ expect(warn.mock.calls[0]?.[0]).toContain('setPendingMove')
105
+ })
106
+
107
+ test('the dialog default is side-by-side, and a junk value falls back to it', () => {
108
+ expect(DEFAULT_LAUNCH_PLACEMENT).toBe('splitRight')
109
+ expect(readLaunchPlacement()).toBe('splitRight')
110
+ localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'sideways')
111
+ expect(readLaunchPlacement()).toBe('splitRight')
112
+ writeLaunchPlacement('stack')
113
+ expect(readLaunchPlacement()).toBe('stack')
114
+ })
115
+
116
+ test('launchMsaView defaults to stack, so a spec written before placement existed is unchanged', () => {
117
+ const { session, recorded } = makeSession()
118
+ launchMsaView(session, { data: { msa: '>a\nAC' } })
119
+ expect(recorded.added).toEqual([
120
+ { type: 'MsaView', snapshot: { type: 'MsaView', data: { msa: '>a\nAC' } } },
121
+ ])
122
+ expect(recorded.moves).toEqual([])
123
+ })
124
+
125
+ // placement is a launch instruction, not view state: MST would drop it from the
126
+ // snapshot without a word, and the view would land stacked with nothing said
127
+ test('launchMsaView keeps placement out of the view snapshot', () => {
128
+ const { session, recorded } = makeSession()
129
+ launchMsaView(session, { placement: 'splitRight', colWidth: 10 })
130
+ expect(recorded.added[0]?.snapshot).toEqual({ type: 'MsaView', colWidth: 10 })
131
+ expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }])
132
+ })
@@ -0,0 +1,146 @@
1
+ import type { AbstractSessionModel } from '@jbrowse/core/util'
2
+
3
+ /**
4
+ * Where a launched MSA view lands. Every launch names one of these and stops
5
+ * there, so a host that arranges views differently is one function to teach.
6
+ *
7
+ * stack append below whatever is on screen
8
+ * splitRight its own cell to the right, beside the view it is connected to
9
+ * newTab its own tab in the current cell
10
+ */
11
+ export type MsaViewPlacement = 'stack' | 'splitRight' | 'newTab'
12
+
13
+ const PLACEMENTS: MsaViewPlacement[] = ['stack', 'splitRight', 'newTab']
14
+
15
+ /**
16
+ * What the dialog does unasked. Side-by-side, because a launch from a gene
17
+ * feature sets `connectedViewId`: the pair shares a hover and a highlight, and
18
+ * reads as a split. A session spec defaults to `stack` instead.
19
+ */
20
+ export const DEFAULT_LAUNCH_PLACEMENT: MsaViewPlacement = 'splitRight'
21
+
22
+ export const LAUNCH_PLACEMENT_KEY = 'msaView-launchPlacement'
23
+
24
+ /**
25
+ * The session actions that place a view in a tiled workspace. Only jbrowse-web
26
+ * and desktop have them, so feature-detect rather than import.
27
+ */
28
+ interface SessionWithWorkspaces {
29
+ setUseWorkspaces: (useWorkspaces: boolean) => void
30
+ setPendingMove: (move: {
31
+ type: 'newTab' | 'splitRight'
32
+ viewId: string
33
+ }) => void
34
+ }
35
+
36
+ function hasAction(session: AbstractSessionModel, name: string) {
37
+ return (
38
+ name in session &&
39
+ typeof (session as unknown as Record<string, unknown>)[name] === 'function'
40
+ )
41
+ }
42
+
43
+ // Warned at most once. This is a property of the host, so the answer is the
44
+ // same on every launch and a dialog the user reopens should not stack up noise.
45
+ let warnedPartial = false
46
+
47
+ export function resetWorkspacesWarning() {
48
+ warnedPartial = false
49
+ }
50
+
51
+ /**
52
+ * Whether this host can honor anything other than `stack`. Silent: the dialog
53
+ * asks on every render, and only a launch is worth warning about.
54
+ */
55
+ export function sessionSupportsPlacement(session: AbstractSessionModel) {
56
+ return (
57
+ hasAction(session, 'setUseWorkspaces') &&
58
+ hasAction(session, 'setPendingMove')
59
+ )
60
+ }
61
+
62
+ function isSessionWithWorkspaces(
63
+ session: AbstractSessionModel,
64
+ ): session is AbstractSessionModel & SessionWithWorkspaces {
65
+ const canEnable = hasAction(session, 'setUseWorkspaces')
66
+ const canPlace = hasAction(session, 'setPendingMove')
67
+
68
+ // Missing BOTH is an embedded session: it has no workspaces, there is nothing
69
+ // to ask for, and silence is the right answer.
70
+ //
71
+ // Missing ONE is a host that has workspaces but places views some other way,
72
+ // and silence there is how the same feature broke in jbrowse-plugin-protein3d
73
+ // — jbrowse-web folded `setPendingMove` into its layout `init`, the guard went
74
+ // false, and the plugin simply stopped asking: no error, no missing feature,
75
+ // two views quietly stacking, nobody noticed for weeks. Feature detection
76
+ // cannot ask a host to announce a change, but it can tell "not supported here"
77
+ // from "supported, and gone".
78
+ //
79
+ // Two very different hosts produce this one shape and nothing on the session
80
+ // tells them apart, so the message carries both rather than a guess:
81
+ //
82
+ // - releases through v4.3.0, where placement is `setPendingMoveToSplitRight`,
83
+ // a module function in @jbrowse/app-core rather than a session action.
84
+ // Nothing is wrong and nothing needs fixing
85
+ // - a newer host that moved the action out from under us, which is the
86
+ // regression this warning exists to catch
87
+ //
88
+ // Do not quiet the first case by sniffing the version. The alarm is only worth
89
+ // having if it fires on a shape it cannot explain, and these two are identical.
90
+ if (canEnable !== canPlace && !warnedPartial) {
91
+ warnedPartial = true
92
+ console.warn(
93
+ `jbrowse-plugin-msaview: this session supports workspaces but not ` +
94
+ `${canPlace ? 'setUseWorkspaces' : 'setPendingMove'}, so the MSA view ` +
95
+ `was stacked instead of tiled. Expected on releases through v4.3.0, ` +
96
+ `which place views through @jbrowse/app-core instead; on a newer host ` +
97
+ `it means the session API moved and the plugin needs updating to match.`,
98
+ )
99
+ }
100
+ return canEnable && canPlace
101
+ }
102
+
103
+ /**
104
+ * Put a freshly added view where the launch said to. `stack` is a placement
105
+ * rather than the absence of one, so no caller has to ask what host it is on.
106
+ */
107
+ export function placeMsaView(
108
+ session: AbstractSessionModel,
109
+ viewId: string,
110
+ placement: MsaViewPlacement,
111
+ ) {
112
+ if (placement === 'stack' || !isSessionWithWorkspaces(session)) {
113
+ return
114
+ }
115
+ session.setPendingMove({ type: placement, viewId })
116
+ // Session-scoped: turning workspaces on for this session leaves the user's
117
+ // own default alone, which is what `setUseWorkspaces` (as against
118
+ // `setUseWorkspacesPreference`) is for.
119
+ session.setUseWorkspaces(true)
120
+ }
121
+
122
+ function isPlacement(value: unknown): value is MsaViewPlacement {
123
+ return PLACEMENTS.includes(value as MsaViewPlacement)
124
+ }
125
+
126
+ /**
127
+ * The dialog's own remembered choice — not the host's preferences system, which
128
+ * records whether the user likes workspaces and does not exist everywhere.
129
+ */
130
+ export function readLaunchPlacement(): MsaViewPlacement {
131
+ try {
132
+ const stored = globalThis.localStorage.getItem(LAUNCH_PLACEMENT_KEY)
133
+ return isPlacement(stored) ? stored : DEFAULT_LAUNCH_PLACEMENT
134
+ } catch (error) {
135
+ console.error(error)
136
+ return DEFAULT_LAUNCH_PLACEMENT
137
+ }
138
+ }
139
+
140
+ export function writeLaunchPlacement(placement: MsaViewPlacement) {
141
+ try {
142
+ globalThis.localStorage.setItem(LAUNCH_PLACEMENT_KEY, placement)
143
+ } catch (error) {
144
+ console.error(error)
145
+ }
146
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '3.1.0'
1
+ export const version = '3.2.0'