@morscherlab/mint-sdk 1.1.0 → 1.1.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 (94) hide show
  1. package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
  2. package/dist/EmptyState-LMPS3keb.js.map +1 -0
  3. package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
  4. package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
  5. package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-CDNsNudn.js} +9 -11
  6. package/dist/ExperimentSelectorModal-CDNsNudn.js.map +1 -0
  7. package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-BGBimSIA.js} +5 -5
  8. package/dist/{SettingsModal-Dpg-X1OM.js.map → SettingsModal-BGBimSIA.js.map} +1 -1
  9. package/dist/__stories__/experiment-helpers.d.ts +3 -0
  10. package/dist/__tests__/stores/experiment.test.d.ts +1 -0
  11. package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
  12. package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
  13. package/dist/components/ExperimentPopover.vue.d.ts +5 -0
  14. package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
  15. package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
  16. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/index.js +7 -7
  19. package/dist/{components-Ci_BTx00.js → components-BbOyjE2T.js} +24 -29
  20. package/dist/components-BbOyjE2T.js.map +1 -0
  21. package/dist/composables/experiment-utils.d.ts +10 -0
  22. package/dist/composables/index.d.ts +2 -2
  23. package/dist/composables/index.js +11 -10
  24. package/dist/composables/useAppExperiment.d.ts +29 -17
  25. package/dist/composables/usePlatformContext.d.ts +3 -3
  26. package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
  27. package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
  28. package/dist/experiment-Y9Iz7KYi.js +95 -0
  29. package/dist/experiment-Y9Iz7KYi.js.map +1 -0
  30. package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
  31. package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
  32. package/dist/index.js +16 -15
  33. package/dist/index.js.map +1 -1
  34. package/dist/install.js +7 -7
  35. package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
  36. package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
  37. package/dist/stores/auth.d.ts +6 -6
  38. package/dist/stores/experiment.d.ts +166 -0
  39. package/dist/stores/index.d.ts +1 -0
  40. package/dist/stores/index.js +3 -2
  41. package/dist/styles.css +188 -0
  42. package/dist/templates/index.js +4 -4
  43. package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
  44. package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
  45. package/dist/types/auth.d.ts +1 -1
  46. package/dist/types/index.js +1 -1
  47. package/dist/types/platform.d.ts +1 -1
  48. package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
  49. package/dist/useApi-CDBq6z23.js.map +1 -0
  50. package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
  51. package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
  52. package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
  53. package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
  54. package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-Dld4841S.js} +4 -3
  55. package/dist/{useExperimentSelector-BuQZRgMt.js.map → useExperimentSelector-Dld4841S.js.map} +1 -1
  56. package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
  57. package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
  58. package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
  59. package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/__stories__/experiment-helpers.ts +12 -9
  62. package/src/__tests__/components/AppTopBar.test.ts +10 -6
  63. package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
  64. package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
  65. package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
  66. package/src/__tests__/components/SettingsModal.test.ts +1 -1
  67. package/src/__tests__/composables/experiment-utils.test.ts +33 -0
  68. package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
  69. package/src/__tests__/composables/useAuth.test.ts +2 -2
  70. package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
  71. package/src/__tests__/stores/experiment.test.ts +300 -0
  72. package/src/components/AppTopBar.vue +0 -2
  73. package/src/components/ExperimentPopover.story.vue +59 -0
  74. package/src/components/ExperimentPopover.vue +96 -6
  75. package/src/components/ExperimentSelectorModal.story.vue +6 -16
  76. package/src/components/ExperimentSelectorModal.vue +8 -4
  77. package/src/components/PluginWorkspaceView.props.ts +1 -4
  78. package/src/components/PluginWorkspaceView.story.vue +12 -5
  79. package/src/components/PluginWorkspaceView.vue +0 -2
  80. package/src/composables/experiment-utils.ts +22 -0
  81. package/src/composables/index.ts +1 -2
  82. package/src/composables/useAppExperiment.ts +57 -67
  83. package/src/composables/useAuth.ts +1 -1
  84. package/src/stores/experiment.ts +120 -0
  85. package/src/stores/index.ts +1 -0
  86. package/src/styles/components/experiment-popover.css +105 -0
  87. package/src/types/auth.ts +1 -1
  88. package/src/types/platform.ts +1 -1
  89. package/dist/EmptyState-DgBRoYAy.js.map +0 -1
  90. package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
  91. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
  92. package/dist/auth-B87xM--s.js.map +0 -1
  93. package/dist/components-Ci_BTx00.js.map +0 -1
  94. package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
@@ -0,0 +1,300 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import axios, { type AxiosRequestConfig } from 'axios'
4
+
5
+ import { useExperimentStore } from '../../stores/experiment'
6
+ import type { ExperimentSummary } from '../../types'
7
+
8
+ let fakeResponse: ExperimentSummary
9
+ let fakeError: (Error & { response?: { status: number } }) | null = null
10
+ let recordedUrls: string[] = []
11
+
12
+ // Lets a test hold individual requests open so responses can be landed out of
13
+ // the order they were issued.
14
+ type Deferred = { settle: (experiment: ExperimentSummary) => void }
15
+ let deferreds: Map<number, Deferred>
16
+ let deferResponses = false
17
+
18
+ function idFromUrl(url: string): number {
19
+ return Number(/\/experiments\/(\d+)/.exec(url)?.[1])
20
+ }
21
+
22
+ function landResponse(id: number, experiment: ExperimentSummary): void {
23
+ const deferred = deferreds.get(id)
24
+ if (!deferred) throw new Error(`No request in flight for experiment ${id}`)
25
+ deferred.settle(experiment)
26
+ }
27
+
28
+ function experiment(id: number, overrides: Partial<ExperimentSummary> = {}): ExperimentSummary {
29
+ return {
30
+ id,
31
+ name: 'Hexokinase knockout run',
32
+ experiment_code: `EXP-${String(id).padStart(3, '0')}`,
33
+ status: 'ongoing',
34
+ experiment_type: 'screen',
35
+ created_at: '2026-07-01T08:00:00Z',
36
+ updated_at: '2026-07-01T08:00:00Z',
37
+ has_design_data: false,
38
+ ...overrides,
39
+ }
40
+ }
41
+
42
+ function installLocalStorage() {
43
+ const storage: Record<string, string> = {}
44
+ vi.stubGlobal('localStorage', {
45
+ getItem: vi.fn((key: string) => storage[key] ?? null),
46
+ setItem: vi.fn((key: string, value: string) => {
47
+ storage[key] = value
48
+ }),
49
+ removeItem: vi.fn((key: string) => {
50
+ delete storage[key]
51
+ }),
52
+ clear: vi.fn(() => {
53
+ for (const key of Object.keys(storage)) delete storage[key]
54
+ }),
55
+ } as unknown as Storage)
56
+ }
57
+
58
+ describe('useExperimentStore', () => {
59
+ beforeEach(() => {
60
+ setActivePinia(createPinia())
61
+ installLocalStorage()
62
+ fakeResponse = experiment(42)
63
+ fakeError = null
64
+ recordedUrls = []
65
+ deferreds = new Map()
66
+ deferResponses = false
67
+ vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
68
+ this: unknown,
69
+ url: string,
70
+ _config?: AxiosRequestConfig,
71
+ ) {
72
+ recordedUrls.push(url)
73
+ if (fakeError) throw fakeError
74
+ if (deferResponses) {
75
+ return await new Promise(resolve => {
76
+ deferreds.set(idFromUrl(url), {
77
+ settle: experiment => resolve({ data: experiment }),
78
+ })
79
+ })
80
+ }
81
+ return { data: fakeResponse }
82
+ })
83
+ })
84
+
85
+ afterEach(() => {
86
+ vi.restoreAllMocks()
87
+ vi.unstubAllGlobals()
88
+ })
89
+
90
+ describe('selectById', () => {
91
+ it('should fetch the record from the platform when given only an id', async () => {
92
+ const store = useExperimentStore()
93
+
94
+ await store.selectById(42)
95
+
96
+ expect(recordedUrls.some(url => url.includes('/experiments/42'))).toBe(true)
97
+ })
98
+
99
+ it('should expose the server-provided name when resolution succeeds', async () => {
100
+ const store = useExperimentStore()
101
+
102
+ await store.selectById(42)
103
+
104
+ expect(store.current?.name).toBe('Hexokinase knockout run')
105
+ })
106
+
107
+ it('should expose the server-provided experiment code when resolution succeeds', async () => {
108
+ const store = useExperimentStore()
109
+
110
+ await store.selectById(42)
111
+
112
+ expect(store.current?.experiment_code).toBe('EXP-042')
113
+ })
114
+
115
+ it('should leave the current experiment null when resolution fails', async () => {
116
+ fakeError = Object.assign(new Error('Not found'), { response: { status: 404 } })
117
+ const store = useExperimentStore()
118
+
119
+ await store.selectById(42)
120
+
121
+ expect(store.current).toBeNull()
122
+ })
123
+
124
+ it('should never synthesise a name from the id when resolution fails', async () => {
125
+ fakeError = Object.assign(new Error('Forbidden'), { response: { status: 403 } })
126
+ const store = useExperimentStore()
127
+
128
+ await store.selectById(42)
129
+
130
+ expect(store.current?.name).not.toBe('Experiment 42')
131
+ })
132
+
133
+ it('should record an error message when resolution fails', async () => {
134
+ fakeError = Object.assign(new Error('Not found'), { response: { status: 404 } })
135
+ const store = useExperimentStore()
136
+
137
+ await store.selectById(42)
138
+
139
+ expect(store.error).not.toBeNull()
140
+ })
141
+
142
+ it('should report the bound id even while the record is still resolving', async () => {
143
+ const store = useExperimentStore()
144
+
145
+ const pending = store.selectById(42)
146
+
147
+ expect(store.currentId).toBe(42)
148
+ await pending
149
+ })
150
+
151
+ it('should clear the previous record while a different id is resolving', async () => {
152
+ const store = useExperimentStore()
153
+ store.select(experiment(7))
154
+
155
+ const pending = store.selectById(42)
156
+
157
+ expect(store.current).toBeNull()
158
+ await pending
159
+ })
160
+
161
+ it('should issue a single request when the same id is resolved concurrently', async () => {
162
+ const store = useExperimentStore()
163
+
164
+ await Promise.all([store.selectById(42), store.selectById(42), store.selectById(42)])
165
+
166
+ expect(recordedUrls.filter(url => url.includes('/experiments/42'))).toHaveLength(1)
167
+ })
168
+
169
+ it('should return the resolved record to every caller resolving the same id', async () => {
170
+ const store = useExperimentStore()
171
+
172
+ const results = await Promise.all([
173
+ store.selectById(42),
174
+ store.selectById(42),
175
+ store.selectById(42),
176
+ ])
177
+
178
+ expect(results.map(result => result?.id)).toEqual([42, 42, 42])
179
+ })
180
+
181
+ it('should retry after a failure rather than caching the rejection', async () => {
182
+ const store = useExperimentStore()
183
+ fakeError = Object.assign(new Error('Boom'), { response: { status: 503 } })
184
+ await store.selectById(42)
185
+ fakeError = null
186
+
187
+ await store.selectById(42)
188
+
189
+ expect(store.current?.name).toBe('Hexokinase knockout run')
190
+ })
191
+ })
192
+
193
+ // A late response overwriting a newer selection shows the user a name that
194
+ // belongs to a different experiment — the same visible failure this store
195
+ // exists to prevent, reached by a different route.
196
+ describe('out-of-order resolution', () => {
197
+ beforeEach(() => {
198
+ deferResponses = true
199
+ })
200
+
201
+ it('should keep the modal selection when an earlier deep-link resolve lands late', async () => {
202
+ const store = useExperimentStore()
203
+ const pending = store.selectById(1)
204
+ store.select(experiment(2, { name: 'Chosen from the modal' }))
205
+
206
+ landResponse(1, experiment(1, { name: 'Stale deep-link record' }))
207
+ await pending
208
+
209
+ expect(store.current?.name).toBe('Chosen from the modal')
210
+ })
211
+
212
+ it('should keep the modal id when an earlier resolve lands late', async () => {
213
+ const store = useExperimentStore()
214
+ const pending = store.selectById(1)
215
+ store.select(experiment(2, { name: 'Chosen from the modal' }))
216
+
217
+ landResponse(1, experiment(1, { name: 'Stale deep-link record' }))
218
+ await pending
219
+
220
+ expect(store.currentId).toBe(2)
221
+ })
222
+
223
+ it('should show the newest experiment when two resolves settle in reverse order', async () => {
224
+ const store = useExperimentStore()
225
+ const first = store.selectById(1)
226
+ const second = store.selectById(2)
227
+
228
+ landResponse(2, experiment(2, { name: 'Second' }))
229
+ await second
230
+ landResponse(1, experiment(1, { name: 'First' }))
231
+ await first
232
+
233
+ expect(store.current?.name).toBe('Second')
234
+ })
235
+
236
+ it('should still be resolving when a superseded request settles first', async () => {
237
+ const store = useExperimentStore()
238
+ const first = store.selectById(1)
239
+ store.selectById(2)
240
+
241
+ landResponse(1, experiment(1, { name: 'First' }))
242
+ await first
243
+
244
+ expect(store.isResolving).toBe(true)
245
+ })
246
+ })
247
+
248
+ describe('select', () => {
249
+ it('should adopt a record the platform already returned without refetching', () => {
250
+ const store = useExperimentStore()
251
+
252
+ store.select(experiment(7, { name: 'Glutamine tracing' }))
253
+
254
+ expect(recordedUrls).toHaveLength(0)
255
+ })
256
+
257
+ it('should expose the adopted record as the current experiment', () => {
258
+ const store = useExperimentStore()
259
+
260
+ store.select(experiment(7, { name: 'Glutamine tracing' }))
261
+
262
+ expect(store.current?.name).toBe('Glutamine tracing')
263
+ })
264
+ })
265
+
266
+ describe('shared state', () => {
267
+ it('should share the current experiment across separate store instances', () => {
268
+ useExperimentStore().select(experiment(7, { name: 'Glutamine tracing' }))
269
+
270
+ expect(useExperimentStore().current?.name).toBe('Glutamine tracing')
271
+ })
272
+ })
273
+
274
+ describe('browser storage isolation', () => {
275
+ it('should not persist a modal selection', () => {
276
+ const store = useExperimentStore()
277
+
278
+ store.select(experiment(7, { name: 'Glutamine tracing' }))
279
+
280
+ expect(localStorage.setItem).not.toHaveBeenCalled()
281
+ })
282
+
283
+ it('should not persist an explicitly resolved deep link', async () => {
284
+ const store = useExperimentStore()
285
+
286
+ await store.selectById(42)
287
+
288
+ expect(localStorage.setItem).not.toHaveBeenCalled()
289
+ })
290
+
291
+ it('should not touch browser storage when detached', () => {
292
+ const store = useExperimentStore()
293
+ store.select(experiment(7))
294
+
295
+ store.clear()
296
+
297
+ expect(localStorage.removeItem).not.toHaveBeenCalled()
298
+ })
299
+ })
300
+ })
@@ -439,8 +439,6 @@ const effectiveCurrentPageSelectorId = computed(() =>
439
439
  v-if="appExperiment && !isStandalone && appExperiment.selectorModal.value.modelValue"
440
440
  v-bind="appExperiment.selectorModal.value"
441
441
  @update:model-value="$event ? appExperiment.openModal() : appExperiment.closeModal()"
442
- @select="appExperiment.handleSelect($event)"
443
- @deselect="appExperiment.handleDetach()"
444
442
  />
445
443
  </template>
446
444
 
@@ -30,6 +30,8 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
30
30
  :experiment-name="state.experimentName"
31
31
  :experiment-code="state.experimentCode"
32
32
  :experiment-status="state.experimentStatus"
33
+ :is-resolving="state.isResolving"
34
+ :error="state.error"
33
35
  :show-save="state.showSave"
34
36
  :show-detach="state.showDetach"
35
37
  :save-disabled="state.saveDisabled"
@@ -55,6 +57,8 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
55
57
  title="Status"
56
58
  :options="STATUSES.map(s => ({ value: s, label: s }))"
57
59
  />
60
+ <HstCheckbox v-model="state.isResolving" title="Resolving (id bound, name pending)" />
61
+ <HstText v-model="state.error" title="Resolution error message" />
58
62
  <HstCheckbox v-model="state.showSave" title="Show save (split mode)" />
59
63
  <HstCheckbox v-model="state.showDetach" title="Show detach action" />
60
64
  <HstCheckbox v-model="state.saveDisabled" title="Save disabled" />
@@ -79,6 +83,36 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
79
83
  </div>
80
84
  </Variant>
81
85
 
86
+ <!-- Bound by id, record still in flight -->
87
+ <Variant
88
+ title="Resolving (deep link)"
89
+ :init-state="() => ({})"
90
+ >
91
+ <div style="padding: 3rem; display: flex; align-items: center; justify-content: center;">
92
+ <ExperimentPopover is-resolving show-detach />
93
+ </div>
94
+ <div style="text-align: center; font-size: 0.75rem; color: var(--text-muted); padding: 0 2rem 1rem;">
95
+ A deep link bound an experiment id and the platform has not returned the record yet. The chip shows a
96
+ placeholder bar — never "No experiment", and never the numeric id, because a name may only come from the
97
+ platform. Open the panel to see the matching skeleton card.
98
+ </div>
99
+ </Variant>
100
+
101
+ <!-- Bound by id, resolution failed -->
102
+ <Variant
103
+ title="Resolution failed"
104
+ :init-state="() => ({})"
105
+ >
106
+ <div style="padding: 3rem; display: flex; align-items: center; justify-content: center;">
107
+ <ExperimentPopover error="Experiment not found (404)" show-detach />
108
+ </div>
109
+ <div style="text-align: center; font-size: 0.75rem; color: var(--text-muted); padding: 0 2rem 1rem;">
110
+ The bound experiment could not be loaded — deleted, or the user lost access. The chip reads
111
+ "Experiment unavailable" in the error palette; the panel carries the platform's message and keeps
112
+ <strong>Change</strong> and <strong>Detach</strong> reachable so the user is never stranded.
113
+ </div>
114
+ </Variant>
115
+
82
116
  <!-- Selected, no save action -->
83
117
  <Variant
84
118
  title="Selected (no save)"
@@ -297,6 +331,14 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
297
331
  <ExperimentPopover />
298
332
  <span style="font-size: 0.6875rem; color: var(--text-muted); font-family: 'Fira Code', monospace;">empty</span>
299
333
  </div>
334
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 0.625rem;">
335
+ <ExperimentPopover is-resolving show-detach />
336
+ <span style="font-size: 0.6875rem; color: var(--text-muted); font-family: 'Fira Code', monospace;">resolving</span>
337
+ </div>
338
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 0.625rem;">
339
+ <ExperimentPopover error="Experiment not found (404)" show-detach />
340
+ <span style="font-size: 0.6875rem; color: var(--text-muted); font-family: 'Fira Code', monospace;">resolve failed</span>
341
+ </div>
300
342
  <div style="display: flex; flex-direction: column; align-items: center; gap: 0.625rem;">
301
343
  <ExperimentPopover
302
344
  :experiment-name="EXPERIMENT_NAMES[0]"
@@ -373,6 +415,23 @@ optionally pairs with an inline Save button as a split unit.
373
415
  - **Split cohesion** — when save is visible, the `[chip | save]` pair lifts
374
416
  together on either-half hover while keeping per-half color feedback.
375
417
 
418
+ ### Binding states
419
+
420
+ The chip renders four mutually exclusive states, in this order:
421
+
422
+ | State | Props | Trigger reads |
423
+ |---|---|---|
424
+ | Resolving | `isResolving` | placeholder bar (`aria-busy`) |
425
+ | Failed | `error` | "Experiment unavailable" |
426
+ | Linked | `experimentName` / `experimentCode` | the platform's name, code as fallback |
427
+ | Empty | none of the above | "No experiment" |
428
+
429
+ Resolving and failed exist because an experiment can be *bound by id* before its
430
+ record arrives — a deep link, or a restored session. In those states the chip must
431
+ not read "No experiment", and it must never substitute the numeric id for a name:
432
+ **a displayed name only ever comes from the platform.** Detach stays reachable in
433
+ the failed state so a user is never stranded on an experiment that cannot load.
434
+
376
435
  ### Events
377
436
 
378
437
  | Event | When |
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /** Floating popover showing the active experiment with save, detach, and select actions. */
3
- import { ref, watch, onUnmounted } from 'vue'
3
+ import { ref, computed, watch, onUnmounted } from 'vue'
4
4
  import { useDropdownState } from '../composables/useDropdownState'
5
5
  import { formatExperimentStatus } from '../composables/experiment-utils'
6
6
  import ConfirmDialog from './ConfirmDialog.vue'
@@ -9,6 +9,10 @@ interface Props {
9
9
  experimentName?: string
10
10
  experimentCode?: string
11
11
  experimentStatus?: string
12
+ /** An experiment is bound but the platform has not returned its record yet. */
13
+ isResolving?: boolean
14
+ /** Message explaining why the bound experiment could not be resolved. */
15
+ error?: string
12
16
  showSave?: boolean
13
17
  showDetach?: boolean
14
18
  saveDisabled?: boolean
@@ -21,6 +25,7 @@ interface Props {
21
25
  }
22
26
 
23
27
  const props = withDefaults(defineProps<Props>(), {
28
+ isResolving: false,
24
29
  showSave: false,
25
30
  showDetach: false,
26
31
  saveDisabled: false,
@@ -28,6 +33,24 @@ const props = withDefaults(defineProps<Props>(), {
28
33
  confirmSave: true,
29
34
  })
30
35
 
36
+ // An experiment is bound but has no name to show — either still resolving or
37
+ // resolution failed. The id is never substituted: a name may only come from the
38
+ // platform, so these states say what is happening instead of inventing a label.
39
+ const isUnresolved = computed(() => props.isResolving || !!props.error)
40
+ const hasExperiment = computed(() => !!props.experimentName || !!props.experimentCode)
41
+
42
+ const panelSubtitle = computed(() => {
43
+ if (props.isResolving) return 'Loading experiment…'
44
+ if (props.error) return 'Could not load this experiment'
45
+ return hasExperiment.value ? 'Linked experiment context' : 'Link to an MINT experiment'
46
+ })
47
+
48
+ const triggerLabel = computed(() => {
49
+ if (props.isResolving) return 'Loading experiment'
50
+ if (props.error) return 'Experiment unavailable'
51
+ return props.experimentName || props.experimentCode || 'No experiment'
52
+ })
53
+
31
54
  const emit = defineEmits<{
32
55
  select: []
33
56
  save: []
@@ -99,9 +122,13 @@ onUnmounted(() => {
99
122
  :class="[
100
123
  'mint-experiment-popover__trigger',
101
124
  { 'mint-experiment-popover__trigger--active': isOpen },
102
- { 'mint-experiment-popover__trigger--empty': !experimentCode && !experimentName },
125
+ { 'mint-experiment-popover__trigger--empty': !isUnresolved && !hasExperiment },
126
+ { 'mint-experiment-popover__trigger--loading': isResolving },
127
+ { 'mint-experiment-popover__trigger--error': !isResolving && !!error },
103
128
  ]"
104
- :title="experimentName || experimentCode || undefined"
129
+ :title="isUnresolved ? triggerLabel : experimentName || experimentCode || undefined"
130
+ :aria-label="isUnresolved ? triggerLabel : undefined"
131
+ :aria-busy="isResolving || undefined"
105
132
  :aria-expanded="isOpen"
106
133
  @click.stop="toggle"
107
134
  >
@@ -114,8 +141,12 @@ onUnmounted(() => {
114
141
  d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
115
142
  />
116
143
  </svg>
144
+ <!-- Resolving and error come first: with an experiment bound but no name
145
+ yet, "No experiment" would be a lie. -->
146
+ <span v-if="isResolving" class="mint-experiment-popover__trigger-skeleton" aria-hidden="true" />
147
+ <span v-else-if="error" class="mint-experiment-popover__trigger-text">Experiment unavailable</span>
117
148
  <!-- Name preferred, code as fallback, "No experiment" as empty state -->
118
- <span v-if="experimentName" class="mint-experiment-popover__trigger-text">{{ experimentName }}</span>
149
+ <span v-else-if="experimentName" class="mint-experiment-popover__trigger-text">{{ experimentName }}</span>
119
150
  <span v-else-if="experimentCode" class="mint-experiment-popover__trigger-code">{{ experimentCode }}</span>
120
151
  <span v-else class="mint-experiment-popover__trigger-text">No experiment</span>
121
152
  <!-- Chevron -->
@@ -158,12 +189,71 @@ onUnmounted(() => {
158
189
  <div class="mint-experiment-popover__header">
159
190
  <div class="mint-experiment-popover__title">Experiment</div>
160
191
  <div class="mint-experiment-popover__subtitle">
161
- {{ experimentName ? 'Linked experiment context' : 'Link to an MINT experiment' }}
192
+ {{ panelSubtitle }}
193
+ </div>
194
+ </div>
195
+
196
+ <!-- Bound, still resolving — placeholders stand in for the record the
197
+ platform has not returned yet. -->
198
+ <div v-if="isResolving" class="mint-experiment-popover__body">
199
+ <div class="mint-experiment-popover__card" aria-busy="true">
200
+ <div class="mint-experiment-popover__card-icon">
201
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
202
+ <path
203
+ stroke-linecap="round"
204
+ stroke-linejoin="round"
205
+ stroke-width="1.75"
206
+ d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
207
+ />
208
+ </svg>
209
+ </div>
210
+ <div class="mint-experiment-popover__card-info">
211
+ <span class="mint-experiment-popover__skeleton-line mint-experiment-popover__skeleton-line--code" />
212
+ <span class="mint-experiment-popover__skeleton-line mint-experiment-popover__skeleton-line--name" />
213
+ <span class="mint-experiment-popover__skeleton-line mint-experiment-popover__skeleton-line--status" />
214
+ </div>
215
+ </div>
216
+ <div class="mint-experiment-popover__card-actions">
217
+ <button type="button" class="mint-experiment-popover__change-btn" @click="handleSelect">
218
+ Change
219
+ </button>
220
+ <button v-if="showDetach" type="button" class="mint-experiment-popover__detach-btn" @click="handleDetach">
221
+ Detach
222
+ </button>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Bound, resolution failed — Detach stays reachable so the user is not
227
+ stuck on an experiment the platform cannot describe. -->
228
+ <div v-else-if="error" class="mint-experiment-popover__body">
229
+ <div class="mint-experiment-popover__card mint-experiment-popover__card--error" role="alert">
230
+ <div class="mint-experiment-popover__card-icon mint-experiment-popover__card-icon--error">
231
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
+ <path
233
+ stroke-linecap="round"
234
+ stroke-linejoin="round"
235
+ stroke-width="1.75"
236
+ d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
237
+ />
238
+ </svg>
239
+ </div>
240
+ <div class="mint-experiment-popover__card-info">
241
+ <div class="mint-experiment-popover__card-name">Experiment unavailable</div>
242
+ <div class="mint-experiment-popover__card-message">{{ error }}</div>
243
+ </div>
244
+ </div>
245
+ <div class="mint-experiment-popover__card-actions">
246
+ <button type="button" class="mint-experiment-popover__change-btn" @click="handleSelect">
247
+ Change
248
+ </button>
249
+ <button v-if="showDetach" type="button" class="mint-experiment-popover__detach-btn" @click="handleDetach">
250
+ Detach
251
+ </button>
162
252
  </div>
163
253
  </div>
164
254
 
165
255
  <!-- No experiment selected -->
166
- <div v-if="!experimentName" class="mint-experiment-popover__body">
256
+ <div v-else-if="!experimentName" class="mint-experiment-popover__body">
167
257
  <button type="button" class="mint-experiment-popover__select-btn" @click="handleSelect">
168
258
  <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
169
259
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { ref, nextTick, onMounted } from 'vue'
2
+ import { ref, computed, nextTick, onMounted } from 'vue'
3
3
  import ExperimentSelectorModal from './ExperimentSelectorModal.vue'
4
4
  import ExperimentCodeBadge from './ExperimentCodeBadge.vue'
5
+ import { useExperimentStore } from '../stores/experiment'
5
6
  import type { ExperimentSummary, ExperimentStatus, ExperimentSortField } from '../types'
6
7
 
7
8
  // -- Mock data: realistic cell biology experiments --
@@ -247,15 +248,11 @@ const isOpenEmpty = ref(false)
247
248
  const isOpenGrouped = ref(false)
248
249
  const isOpenWithSelection = ref(false)
249
250
  const isOpenAdvanced = ref(false)
250
- const selectedExperiment = ref<ExperimentSummary | null>(null)
251
251
 
252
- function handleSelect(experiment: ExperimentSummary) {
253
- selectedExperiment.value = experiment
254
- }
255
-
256
- function handleDeselect() {
257
- selectedExperiment.value = null
258
- }
252
+ // The modal commits to the shared store rather than emitting; stories read the
253
+ // selection back from the same place a plugin would.
254
+ const experimentStore = useExperimentStore()
255
+ const selectedExperiment = computed(() => experimentStore.current)
259
256
  </script>
260
257
 
261
258
  <template>
@@ -290,8 +287,6 @@ function handleDeselect() {
290
287
  :experiment-type="state.experimentType || undefined"
291
288
  :title="state.title"
292
289
  :current-experiment-id="selectedExperiment?.id ?? null"
293
- @select="handleSelect"
294
- @deselect="handleDeselect"
295
290
  />
296
291
  </div>
297
292
  </template>
@@ -319,7 +314,6 @@ function handleDeselect() {
319
314
  v-model="isOpenAdvanced"
320
315
  title="Select Experiment"
321
316
  :show-filters="true"
322
- @select="handleSelect"
323
317
  />
324
318
  </div>
325
319
  </Variant>
@@ -340,7 +334,6 @@ function handleDeselect() {
340
334
  v-model="isOpenGrouped"
341
335
  title="Select Experiment"
342
336
  :group-by-project="true"
343
- @select="handleSelect"
344
337
  />
345
338
  </div>
346
339
  </Variant>
@@ -361,8 +354,6 @@ function handleDeselect() {
361
354
  v-model="isOpenWithSelection"
362
355
  title="Select Experiment"
363
356
  :current-experiment-id="1"
364
- @select="handleSelect"
365
- @deselect="handleDeselect"
366
357
  />
367
358
  </div>
368
359
  </Variant>
@@ -383,7 +374,6 @@ function handleDeselect() {
383
374
  v-model="isOpenEmpty"
384
375
  experiment-type="nonexistent_type"
385
376
  title="Select Experiment"
386
- @select="handleSelect"
387
377
  />
388
378
  </div>
389
379
  </Variant>
@@ -3,6 +3,7 @@
3
3
  import { ref, reactive, computed, watch, nextTick } from 'vue'
4
4
  import type { ModalSize, ExperimentSummary, ExperimentFilters } from '../types'
5
5
  import { useExperimentSelector } from '../composables/useExperimentSelector'
6
+ import { useExperimentStore } from '../stores/experiment'
6
7
  import {
7
8
  formatExperimentDate,
8
9
  formatExperimentStatus,
@@ -38,12 +39,15 @@ const props = withDefaults(defineProps<Props>(), {
38
39
  showFilters: false,
39
40
  })
40
41
 
42
+ // Selection is committed to the shared experiment store rather than emitted.
43
+ // A single owner of "the current experiment" is what stops callers holding —
44
+ // or inventing — a record of their own.
41
45
  const emit = defineEmits<{
42
46
  'update:modelValue': [value: boolean]
43
- select: [experiment: ExperimentSummary]
44
- deselect: []
45
47
  }>()
46
48
 
49
+ const experimentStore = useExperimentStore()
50
+
47
51
  const {
48
52
  experiments,
49
53
  filters,
@@ -113,12 +117,12 @@ function handleSortChange(value: string | number) {
113
117
  }
114
118
 
115
119
  function handleSelect(experiment: ExperimentSummary) {
116
- emit('select', experiment)
120
+ experimentStore.select(experiment)
117
121
  emit('update:modelValue', false)
118
122
  }
119
123
 
120
124
  function handleDeselect() {
121
- emit('deselect')
125
+ experimentStore.clear()
122
126
  emit('update:modelValue', false)
123
127
  }
124
128