@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.
- package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
- package/dist/EmptyState-LMPS3keb.js.map +1 -0
- package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
- package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-CDNsNudn.js} +9 -11
- package/dist/ExperimentSelectorModal-CDNsNudn.js.map +1 -0
- package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-BGBimSIA.js} +5 -5
- package/dist/{SettingsModal-Dpg-X1OM.js.map → SettingsModal-BGBimSIA.js.map} +1 -1
- package/dist/__stories__/experiment-helpers.d.ts +3 -0
- package/dist/__tests__/stores/experiment.test.d.ts +1 -0
- package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
- package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
- package/dist/components/ExperimentPopover.vue.d.ts +5 -0
- package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
- package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.js +7 -7
- package/dist/{components-Ci_BTx00.js → components-BbOyjE2T.js} +24 -29
- package/dist/components-BbOyjE2T.js.map +1 -0
- package/dist/composables/experiment-utils.d.ts +10 -0
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.js +11 -10
- package/dist/composables/useAppExperiment.d.ts +29 -17
- package/dist/composables/usePlatformContext.d.ts +3 -3
- package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
- package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
- package/dist/experiment-Y9Iz7KYi.js +95 -0
- package/dist/experiment-Y9Iz7KYi.js.map +1 -0
- package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
- package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
- package/dist/index.js +16 -15
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -7
- package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
- package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
- package/dist/stores/auth.d.ts +6 -6
- package/dist/stores/experiment.d.ts +166 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +3 -2
- package/dist/styles.css +188 -0
- package/dist/templates/index.js +4 -4
- package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
- package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
- package/dist/types/auth.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/platform.d.ts +1 -1
- package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
- package/dist/useApi-CDBq6z23.js.map +1 -0
- package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
- package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
- package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
- package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
- package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-Dld4841S.js} +4 -3
- package/dist/{useExperimentSelector-BuQZRgMt.js.map → useExperimentSelector-Dld4841S.js.map} +1 -1
- package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
- package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
- package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
- package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
- package/package.json +1 -1
- package/src/__stories__/experiment-helpers.ts +12 -9
- package/src/__tests__/components/AppTopBar.test.ts +10 -6
- package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
- package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
- package/src/__tests__/components/SettingsModal.test.ts +1 -1
- package/src/__tests__/composables/experiment-utils.test.ts +33 -0
- package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
- package/src/__tests__/composables/useAuth.test.ts +2 -2
- package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
- package/src/__tests__/stores/experiment.test.ts +300 -0
- package/src/components/AppTopBar.vue +0 -2
- package/src/components/ExperimentPopover.story.vue +59 -0
- package/src/components/ExperimentPopover.vue +96 -6
- package/src/components/ExperimentSelectorModal.story.vue +6 -16
- package/src/components/ExperimentSelectorModal.vue +8 -4
- package/src/components/PluginWorkspaceView.props.ts +1 -4
- package/src/components/PluginWorkspaceView.story.vue +12 -5
- package/src/components/PluginWorkspaceView.vue +0 -2
- package/src/composables/experiment-utils.ts +22 -0
- package/src/composables/index.ts +1 -2
- package/src/composables/useAppExperiment.ts +57 -67
- package/src/composables/useAuth.ts +1 -1
- package/src/stores/experiment.ts +120 -0
- package/src/stores/index.ts +1 -0
- package/src/styles/components/experiment-popover.css +105 -0
- package/src/types/auth.ts +1 -1
- package/src/types/platform.ts +1 -1
- package/dist/EmptyState-DgBRoYAy.js.map +0 -1
- package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
- package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
- package/dist/auth-B87xM--s.js.map +0 -1
- package/dist/components-Ci_BTx00.js.map +0 -1
- 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': !
|
|
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
|
-
{{
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
120
|
+
experimentStore.select(experiment)
|
|
117
121
|
emit('update:modelValue', false)
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
function handleDeselect() {
|
|
121
|
-
|
|
125
|
+
experimentStore.clear()
|
|
122
126
|
emit('update:modelValue', false)
|
|
123
127
|
}
|
|
124
128
|
|