@morscherlab/mint-sdk 1.2.0-beta.2 → 1.2.0-beta.4
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/README.md +5 -5
- package/dist/{ExperimentSelectorModal-DyPAUCLw.js → ExperimentSelectorModal-B6P2msT4.js} +3 -3
- package/dist/{ExperimentSelectorModal-DyPAUCLw.js.map → ExperimentSelectorModal-B6P2msT4.js.map} +1 -1
- package/dist/{SettingsModal-BgKY93zh.js → SettingsModal-CkNM8qAI.js} +4 -4
- package/dist/{SettingsModal-BgKY93zh.js.map → SettingsModal-CkNM8qAI.js.map} +1 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +15 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +9 -2
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/{components-DkHddRMl.js → components-MGNL6tRn.js} +16 -10
- package/dist/components-MGNL6tRn.js.map +1 -0
- package/dist/composables/index.d.ts +3 -2
- package/dist/composables/index.js +8 -8
- package/dist/composables/useGeneratedAnalysis.d.ts +3 -1
- package/dist/composables/useJobsStatusTray.d.ts +25 -1
- package/dist/composables/usePluginClient.d.ts +2 -0
- package/dist/composables/usePluginConfig.d.ts +25 -0
- package/dist/{composables-C_i90sGS.js → composables-CKxOcvgh.js} +140 -5
- package/dist/composables-CKxOcvgh.js.map +1 -0
- package/dist/{experiment-Cq145oEW.js → experiment-Y9Iz7KYi.js} +2 -2
- package/dist/{experiment-Cq145oEW.js.map → experiment-Y9Iz7KYi.js.map} +1 -1
- package/dist/index.js +12 -12
- package/dist/install.js +4 -4
- package/dist/stores/auth.d.ts +6 -0
- package/dist/stores/index.js +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/{templates-DsP51i-S.js → templates-BTJ9C9Kb.js} +2 -2
- package/dist/{templates-DsP51i-S.js.map → templates-BTJ9C9Kb.js.map} +1 -1
- package/dist/types/auth.d.ts +1 -0
- package/dist/{useApi-J6zFkHmn.js → useApi-CDBq6z23.js} +2 -1
- package/dist/useApi-CDBq6z23.js.map +1 -0
- package/dist/{useControlSchema-BDx2Olha.js → useControlSchema-CXhxTul7.js} +10 -3
- package/dist/useControlSchema-CXhxTul7.js.map +1 -0
- package/dist/{useExperimentSelector-CV4iNfMS.js → useExperimentSelector-D0pjj9WH.js} +2 -2
- package/dist/{useExperimentSelector-CV4iNfMS.js.map → useExperimentSelector-D0pjj9WH.js.map} +1 -1
- package/dist/{useFormBuilder-BLxG3BOX.js → useFormBuilder-CuOFB3aP.js} +2 -2
- package/dist/{useFormBuilder-BLxG3BOX.js.map → useFormBuilder-CuOFB3aP.js.map} +1 -1
- package/dist/{useProtocolTemplates-CS_SQl6U.js → useProtocolTemplates-BWhQIWV5.js} +113 -22
- package/dist/useProtocolTemplates-BWhQIWV5.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/composables/useAuth.test.ts +2 -0
- package/src/__tests__/composables/usePluginConfig.test.ts +497 -0
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +17 -6
- package/src/components/JobsStatusTray.vue +16 -1
- package/src/composables/index.ts +7 -0
- package/src/composables/useAuth.ts +3 -0
- package/src/composables/useGeneratedAnalysis.ts +14 -1
- package/src/composables/useJobsStatusTray.ts +184 -17
- package/src/composables/usePluginClient.ts +5 -0
- package/src/composables/usePluginConfig.ts +250 -0
- package/src/stores/auth.ts +1 -0
- package/src/types/auth.ts +1 -0
- package/dist/components-DkHddRMl.js.map +0 -1
- package/dist/composables-C_i90sGS.js.map +0 -1
- package/dist/useApi-J6zFkHmn.js.map +0 -1
- package/dist/useControlSchema-BDx2Olha.js.map +0 -1
- package/dist/useProtocolTemplates-CS_SQl6U.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.2.0-beta.
|
|
3
|
+
"version": "1.2.0-beta.4",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -57,6 +57,7 @@ describe('useAuth', () => {
|
|
|
57
57
|
passkey_enabled: true,
|
|
58
58
|
passkey_registered: false,
|
|
59
59
|
registration_enabled: true,
|
|
60
|
+
database_mode: 'postgresql',
|
|
60
61
|
access: { experiment_visibility_mode: 'restricted' },
|
|
61
62
|
},
|
|
62
63
|
})
|
|
@@ -67,6 +68,7 @@ describe('useAuth', () => {
|
|
|
67
68
|
passkeyEnabled: true,
|
|
68
69
|
passkeyRegistered: false,
|
|
69
70
|
registrationEnabled: true,
|
|
71
|
+
databaseMode: 'postgresql',
|
|
70
72
|
experimentVisibilityMode: 'restricted',
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for usePluginConfig.
|
|
3
|
+
*
|
|
4
|
+
* Covers the plugin settings life-cycle used by every plugin's
|
|
5
|
+
* settings pane:
|
|
6
|
+
*
|
|
7
|
+
* - resolvedName prefers explicit pluginName, falls back to platform plugin
|
|
8
|
+
* - load() populates config + savedConfig from /plugins/{name}/config
|
|
9
|
+
* - save() PATCHes the endpoint, updates savedConfig, returns true
|
|
10
|
+
* - isDirty reflects user edits vs last saved state
|
|
11
|
+
* - reset() restores config to savedConfig and clears error
|
|
12
|
+
* - lastLoadedAt / lastSavedAt track successful syncs
|
|
13
|
+
* - error messages bubble up from network failures
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
17
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
18
|
+
import axios, { type AxiosRequestConfig } from 'axios'
|
|
19
|
+
import { nextTick } from 'vue'
|
|
20
|
+
|
|
21
|
+
// usePluginConfig calls onMounted(load); happy-dom doesn't run Vue's
|
|
22
|
+
// lifecycle outside a component, so we stub onMounted to call the
|
|
23
|
+
// callback synchronously if and only if the test opts in.
|
|
24
|
+
let autoLoadOnMount = false
|
|
25
|
+
vi.mock('vue', async () => {
|
|
26
|
+
const actual = await vi.importActual<typeof import('vue')>('vue')
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
onMounted: vi.fn((cb: () => void) => {
|
|
30
|
+
if (autoLoadOnMount) cb()
|
|
31
|
+
}),
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
import { usePluginConfig } from '../../composables/usePluginConfig'
|
|
36
|
+
|
|
37
|
+
type FakeResponse = Record<string, unknown>
|
|
38
|
+
|
|
39
|
+
let getResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
40
|
+
let patchResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
41
|
+
let putResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
42
|
+
let getError: Error | null = null
|
|
43
|
+
let patchError: Error | null = null
|
|
44
|
+
let recordedBaseUrls: Array<string | undefined> = []
|
|
45
|
+
let recordedCalls: {
|
|
46
|
+
method: string
|
|
47
|
+
url: string
|
|
48
|
+
data?: unknown
|
|
49
|
+
headers?: AxiosRequestConfig['headers']
|
|
50
|
+
}[] = []
|
|
51
|
+
|
|
52
|
+
function mockAxios() {
|
|
53
|
+
vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
|
|
54
|
+
this: unknown,
|
|
55
|
+
url: string,
|
|
56
|
+
config?: AxiosRequestConfig,
|
|
57
|
+
) {
|
|
58
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
59
|
+
recordedCalls.push({ method: 'GET', url })
|
|
60
|
+
if (getError) throw getError
|
|
61
|
+
return { data: getResponse }
|
|
62
|
+
})
|
|
63
|
+
vi.spyOn(axios.Axios.prototype, 'patch').mockImplementation(async function (
|
|
64
|
+
this: unknown,
|
|
65
|
+
url: string,
|
|
66
|
+
data?: unknown,
|
|
67
|
+
config?: AxiosRequestConfig,
|
|
68
|
+
) {
|
|
69
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
70
|
+
recordedCalls.push({ method: 'PATCH', url, data })
|
|
71
|
+
if (patchError) throw patchError
|
|
72
|
+
return { data: patchResponse }
|
|
73
|
+
})
|
|
74
|
+
vi.spyOn(axios.Axios.prototype, 'put').mockImplementation(async function (
|
|
75
|
+
this: unknown,
|
|
76
|
+
url: string,
|
|
77
|
+
data?: unknown,
|
|
78
|
+
config?: AxiosRequestConfig,
|
|
79
|
+
) {
|
|
80
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
81
|
+
recordedCalls.push({ method: 'PUT', url, data, headers: config?.headers })
|
|
82
|
+
return { data: putResponse }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('usePluginConfig', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
setActivePinia(createPinia())
|
|
89
|
+
autoLoadOnMount = false
|
|
90
|
+
getResponse = { plugin_name: 'drp', config: {} }
|
|
91
|
+
patchResponse = { plugin_name: 'drp', config: {} }
|
|
92
|
+
putResponse = { plugin_name: 'drp', config: {} }
|
|
93
|
+
getError = null
|
|
94
|
+
patchError = null
|
|
95
|
+
recordedCalls = []
|
|
96
|
+
recordedBaseUrls = []
|
|
97
|
+
delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
|
|
98
|
+
mockAxios()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
|
|
103
|
+
vi.restoreAllMocks()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('load fetches config from correct endpoint and populates state', async () => {
|
|
107
|
+
getResponse = {
|
|
108
|
+
plugin_name: 'drp',
|
|
109
|
+
config: { raw_files_path: '/data/rfa', threshold: 0.5 },
|
|
110
|
+
}
|
|
111
|
+
const hook = usePluginConfig('drp')
|
|
112
|
+
await hook.load()
|
|
113
|
+
|
|
114
|
+
expect(recordedCalls).toEqual([
|
|
115
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
116
|
+
])
|
|
117
|
+
expect(hook.config.value).toEqual({
|
|
118
|
+
raw_files_path: '/data/rfa',
|
|
119
|
+
threshold: 0.5,
|
|
120
|
+
})
|
|
121
|
+
expect(hook.isDirty.value).toBe(false)
|
|
122
|
+
expect(hook.error.value).toBeNull()
|
|
123
|
+
expect(hook.lastLoadedAt.value).toBeInstanceOf(Date)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('encodes special characters in plugin name', async () => {
|
|
127
|
+
const hook = usePluginConfig('my/plugin')
|
|
128
|
+
await hook.load()
|
|
129
|
+
expect(recordedCalls[0]!.url).toBe('/plugins/my%2Fplugin/config')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it.each([
|
|
133
|
+
'platform',
|
|
134
|
+
undefined,
|
|
135
|
+
] as const)(
|
|
136
|
+
'should use the platform config facade when settings_api is %s',
|
|
137
|
+
async (settingsApi) => {
|
|
138
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
139
|
+
isIntegrated: true,
|
|
140
|
+
theme: 'light',
|
|
141
|
+
platformApiUrl: '/api',
|
|
142
|
+
plugin: {
|
|
143
|
+
id: 'drp',
|
|
144
|
+
name: 'drp',
|
|
145
|
+
version: '1.0.0',
|
|
146
|
+
route_prefix: '/drp',
|
|
147
|
+
api_prefix: '/api/drp',
|
|
148
|
+
...(settingsApi ? { settings_api: settingsApi } : {}),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
getResponse = {
|
|
152
|
+
plugin_name: 'drp',
|
|
153
|
+
config: { n: 1 },
|
|
154
|
+
revision: 'revision-1',
|
|
155
|
+
}
|
|
156
|
+
const hook = usePluginConfig('drp')
|
|
157
|
+
|
|
158
|
+
await hook.load()
|
|
159
|
+
|
|
160
|
+
expect(recordedCalls).toEqual([
|
|
161
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
162
|
+
])
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
it('should use the plugin settings router for an integrated plugin capability', async () => {
|
|
167
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
168
|
+
isIntegrated: true,
|
|
169
|
+
theme: 'light',
|
|
170
|
+
platformApiUrl: '/api',
|
|
171
|
+
plugin: {
|
|
172
|
+
id: 'drp',
|
|
173
|
+
name: 'drp',
|
|
174
|
+
version: '1.0.0',
|
|
175
|
+
route_prefix: '/drp',
|
|
176
|
+
api_prefix: '/api/drp',
|
|
177
|
+
settings_api: 'plugin',
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
getResponse = {
|
|
181
|
+
settings: { n: 1 },
|
|
182
|
+
mode: 'integrated',
|
|
183
|
+
revision: 'revision-1',
|
|
184
|
+
}
|
|
185
|
+
putResponse = {
|
|
186
|
+
settings: { n: 2 },
|
|
187
|
+
mode: 'integrated',
|
|
188
|
+
revision: 'revision-2',
|
|
189
|
+
}
|
|
190
|
+
const hook = usePluginConfig('drp')
|
|
191
|
+
await hook.load()
|
|
192
|
+
hook.config.value = { n: 2 }
|
|
193
|
+
|
|
194
|
+
const saved = await hook.save()
|
|
195
|
+
|
|
196
|
+
expect({
|
|
197
|
+
saved,
|
|
198
|
+
calls: recordedCalls,
|
|
199
|
+
baseUrls: recordedBaseUrls,
|
|
200
|
+
config: hook.config.value,
|
|
201
|
+
}).toEqual({
|
|
202
|
+
saved: true,
|
|
203
|
+
baseUrls: ['/api/drp', '/api/drp'],
|
|
204
|
+
calls: [
|
|
205
|
+
{ method: 'GET', url: '/settings' },
|
|
206
|
+
{
|
|
207
|
+
method: 'PUT',
|
|
208
|
+
url: '/settings',
|
|
209
|
+
data: { n: 2 },
|
|
210
|
+
headers: { 'If-Match': '"revision-1"' },
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
config: { n: 2 },
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should use the platform facade for an explicit different plugin name', async () => {
|
|
218
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
219
|
+
isIntegrated: true,
|
|
220
|
+
theme: 'light',
|
|
221
|
+
platformApiUrl: '/api',
|
|
222
|
+
plugin: {
|
|
223
|
+
id: 'current-plugin',
|
|
224
|
+
name: 'current-plugin',
|
|
225
|
+
version: '1.0.0',
|
|
226
|
+
route_prefix: '/current-plugin',
|
|
227
|
+
api_prefix: '/api/current-plugin',
|
|
228
|
+
settings_api: 'plugin',
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
getResponse = {
|
|
232
|
+
plugin_name: 'other-plugin',
|
|
233
|
+
config: { n: 1 },
|
|
234
|
+
revision: 'revision-1',
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hook = usePluginConfig('other-plugin')
|
|
238
|
+
await hook.load()
|
|
239
|
+
|
|
240
|
+
expect({
|
|
241
|
+
calls: recordedCalls,
|
|
242
|
+
config: hook.config.value,
|
|
243
|
+
}).toEqual({
|
|
244
|
+
calls: [
|
|
245
|
+
{ method: 'GET', url: '/plugins/other-plugin/config' },
|
|
246
|
+
],
|
|
247
|
+
config: { n: 1 },
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should PATCH only dirty typed fields through a versioned platform facade', async () => {
|
|
252
|
+
getResponse = {
|
|
253
|
+
plugin_name: 'drp',
|
|
254
|
+
config: {
|
|
255
|
+
n: 1,
|
|
256
|
+
allowed_experiment_types: ['metabolomics'],
|
|
257
|
+
},
|
|
258
|
+
revision: 'revision-1',
|
|
259
|
+
}
|
|
260
|
+
patchResponse = {
|
|
261
|
+
plugin_name: 'drp',
|
|
262
|
+
config: {
|
|
263
|
+
n: 2,
|
|
264
|
+
allowed_experiment_types: ['metabolomics'],
|
|
265
|
+
},
|
|
266
|
+
revision: 'revision-2',
|
|
267
|
+
}
|
|
268
|
+
const hook = usePluginConfig('drp')
|
|
269
|
+
await hook.load()
|
|
270
|
+
hook.config.value = {
|
|
271
|
+
n: 2,
|
|
272
|
+
allowed_experiment_types: ['metabolomics'],
|
|
273
|
+
}
|
|
274
|
+
await nextTick()
|
|
275
|
+
expect(hook.isDirty.value).toBe(true)
|
|
276
|
+
|
|
277
|
+
const ok = await hook.save()
|
|
278
|
+
expect(ok).toBe(true)
|
|
279
|
+
const patchCall = recordedCalls.find((c) => c.method === 'PATCH')!
|
|
280
|
+
expect(patchCall.url).toBe('/plugins/drp/config')
|
|
281
|
+
expect(patchCall.data).toEqual({ config: { n: 2 } })
|
|
282
|
+
expect(patchCall.headers).toBeUndefined()
|
|
283
|
+
expect(hook.config.value).toEqual({
|
|
284
|
+
n: 2,
|
|
285
|
+
allowed_experiment_types: ['metabolomics'],
|
|
286
|
+
})
|
|
287
|
+
expect(hook.isDirty.value).toBe(false)
|
|
288
|
+
expect(hook.lastSavedAt.value).toBeInstanceOf(Date)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('should split changed platform-owned config from a versioned typed save', async () => {
|
|
292
|
+
getResponse = {
|
|
293
|
+
plugin_name: 'drp',
|
|
294
|
+
config: {
|
|
295
|
+
n: 1,
|
|
296
|
+
allowed_experiment_types: ['metabolomics'],
|
|
297
|
+
},
|
|
298
|
+
revision: 'revision-1',
|
|
299
|
+
}
|
|
300
|
+
patchResponse = {
|
|
301
|
+
plugin_name: 'drp',
|
|
302
|
+
config: {
|
|
303
|
+
n: 2,
|
|
304
|
+
allowed_experiment_types: ['proteomics'],
|
|
305
|
+
},
|
|
306
|
+
revision: 'untrusted-platform-revision',
|
|
307
|
+
}
|
|
308
|
+
const hook = usePluginConfig('drp')
|
|
309
|
+
await hook.load()
|
|
310
|
+
hook.config.value = {
|
|
311
|
+
n: 2,
|
|
312
|
+
allowed_experiment_types: ['proteomics'],
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const saved = await hook.save()
|
|
316
|
+
patchResponse = {
|
|
317
|
+
plugin_name: 'drp',
|
|
318
|
+
config: {
|
|
319
|
+
n: 3,
|
|
320
|
+
allowed_experiment_types: ['proteomics'],
|
|
321
|
+
},
|
|
322
|
+
revision: 'revision-3',
|
|
323
|
+
}
|
|
324
|
+
hook.config.value = {
|
|
325
|
+
n: 3,
|
|
326
|
+
allowed_experiment_types: ['proteomics'],
|
|
327
|
+
}
|
|
328
|
+
const savedAgain = await hook.save()
|
|
329
|
+
|
|
330
|
+
expect({
|
|
331
|
+
saved,
|
|
332
|
+
savedAgain,
|
|
333
|
+
calls: recordedCalls,
|
|
334
|
+
config: hook.config.value,
|
|
335
|
+
isDirty: hook.isDirty.value,
|
|
336
|
+
}).toEqual({
|
|
337
|
+
saved: true,
|
|
338
|
+
savedAgain: true,
|
|
339
|
+
calls: [
|
|
340
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
341
|
+
{
|
|
342
|
+
method: 'PATCH',
|
|
343
|
+
url: '/plugins/drp/config',
|
|
344
|
+
data: { config: { n: 2 } },
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: 'PATCH',
|
|
348
|
+
url: '/plugins/drp/config',
|
|
349
|
+
data: { config: { allowed_experiment_types: ['proteomics'] } },
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
method: 'PATCH',
|
|
353
|
+
url: '/plugins/drp/config',
|
|
354
|
+
data: { config: { n: 3 } },
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
config: {
|
|
358
|
+
n: 3,
|
|
359
|
+
allowed_experiment_types: ['proteomics'],
|
|
360
|
+
},
|
|
361
|
+
isDirty: false,
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should avoid a typed PUT when only the platform-owned config changes', async () => {
|
|
366
|
+
getResponse = {
|
|
367
|
+
plugin_name: 'drp',
|
|
368
|
+
config: {
|
|
369
|
+
n: 1,
|
|
370
|
+
allowed_experiment_types: ['metabolomics'],
|
|
371
|
+
},
|
|
372
|
+
revision: 'revision-1',
|
|
373
|
+
}
|
|
374
|
+
patchResponse = {
|
|
375
|
+
plugin_name: 'drp',
|
|
376
|
+
config: {
|
|
377
|
+
n: 1,
|
|
378
|
+
allowed_experiment_types: ['proteomics'],
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
const hook = usePluginConfig('drp')
|
|
382
|
+
await hook.load()
|
|
383
|
+
hook.config.value = {
|
|
384
|
+
n: 1,
|
|
385
|
+
allowed_experiment_types: ['proteomics'],
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const saved = await hook.save()
|
|
389
|
+
|
|
390
|
+
expect({
|
|
391
|
+
saved,
|
|
392
|
+
calls: recordedCalls,
|
|
393
|
+
config: hook.config.value,
|
|
394
|
+
}).toEqual({
|
|
395
|
+
saved: true,
|
|
396
|
+
calls: [
|
|
397
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
398
|
+
{
|
|
399
|
+
method: 'PATCH',
|
|
400
|
+
url: '/plugins/drp/config',
|
|
401
|
+
data: { config: { allowed_experiment_types: ['proteomics'] } },
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
config: {
|
|
405
|
+
n: 1,
|
|
406
|
+
allowed_experiment_types: ['proteomics'],
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('should detect nested config edits and restore the saved JSON snapshot', async () => {
|
|
412
|
+
getResponse = {
|
|
413
|
+
plugin_name: 'drp',
|
|
414
|
+
config: {
|
|
415
|
+
rules: {
|
|
416
|
+
thresholds: [1, 2],
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
const hook = usePluginConfig('drp')
|
|
421
|
+
await hook.load()
|
|
422
|
+
const rules = hook.config.value.rules as { thresholds: number[] }
|
|
423
|
+
rules.thresholds[0] = 9
|
|
424
|
+
await nextTick()
|
|
425
|
+
|
|
426
|
+
const dirtyAfterMutation = hook.isDirty.value
|
|
427
|
+
hook.reset()
|
|
428
|
+
|
|
429
|
+
expect({
|
|
430
|
+
dirtyAfterMutation,
|
|
431
|
+
config: hook.config.value,
|
|
432
|
+
isDirty: hook.isDirty.value,
|
|
433
|
+
}).toEqual({
|
|
434
|
+
dirtyAfterMutation: true,
|
|
435
|
+
config: {
|
|
436
|
+
rules: {
|
|
437
|
+
thresholds: [1, 2],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
isDirty: false,
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('isDirty reflects edits vs last saved snapshot', async () => {
|
|
445
|
+
getResponse = { plugin_name: 'drp', config: { k: 'v' } }
|
|
446
|
+
const hook = usePluginConfig('drp')
|
|
447
|
+
await hook.load()
|
|
448
|
+
expect(hook.isDirty.value).toBe(false)
|
|
449
|
+
|
|
450
|
+
hook.config.value = { k: 'changed' }
|
|
451
|
+
await nextTick()
|
|
452
|
+
expect(hook.isDirty.value).toBe(true)
|
|
453
|
+
|
|
454
|
+
hook.config.value = { k: 'v' }
|
|
455
|
+
await nextTick()
|
|
456
|
+
expect(hook.isDirty.value).toBe(false)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('reset restores saved config and clears error', async () => {
|
|
460
|
+
getResponse = { plugin_name: 'drp', config: { k: 'v' } }
|
|
461
|
+
const hook = usePluginConfig('drp')
|
|
462
|
+
await hook.load()
|
|
463
|
+
hook.config.value = { k: 'edited' }
|
|
464
|
+
hook.error.value = 'something'
|
|
465
|
+
|
|
466
|
+
hook.reset()
|
|
467
|
+
expect(hook.config.value).toEqual({ k: 'v' })
|
|
468
|
+
expect(hook.error.value).toBeNull()
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('load surfaces network error message', async () => {
|
|
472
|
+
getError = new Error('network down')
|
|
473
|
+
const hook = usePluginConfig('drp')
|
|
474
|
+
await hook.load()
|
|
475
|
+
expect(hook.error.value).toBe('network down')
|
|
476
|
+
expect(hook.isLoading.value).toBe(false)
|
|
477
|
+
expect(hook.lastLoadedAt.value).toBeNull()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('save returns false and records error on failure', async () => {
|
|
481
|
+
patchError = new Error('403')
|
|
482
|
+
const hook = usePluginConfig('drp')
|
|
483
|
+
await hook.load()
|
|
484
|
+
hook.config.value = { x: 1 }
|
|
485
|
+
const ok = await hook.save()
|
|
486
|
+
expect(ok).toBe(false)
|
|
487
|
+
expect(hook.error.value).toBe('403')
|
|
488
|
+
expect(hook.lastSavedAt.value).toBeNull()
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('empty plugin name produces a noop load', async () => {
|
|
492
|
+
const hook = usePluginConfig('')
|
|
493
|
+
await hook.load()
|
|
494
|
+
expect(recordedCalls).toEqual([])
|
|
495
|
+
expect(hook.error.value).toBeNull()
|
|
496
|
+
})
|
|
497
|
+
})
|
|
@@ -271,11 +271,14 @@ describe('frontend docs catalog coverage', () => {
|
|
|
271
271
|
})
|
|
272
272
|
})
|
|
273
273
|
|
|
274
|
-
it('documents
|
|
274
|
+
it('documents the source and MINT 1.1 JobsStatusTray props', () => {
|
|
275
275
|
const manifest = extractFrontendManifest()
|
|
276
276
|
const jobsStatusTray = manifest.categories.components.find(item => item.name === 'JobsStatusTray')
|
|
277
277
|
|
|
278
278
|
expect(jobsStatusTray?.props?.map(prop => prop.name).sort()).toEqual([
|
|
279
|
+
'adapter',
|
|
280
|
+
'eventStream',
|
|
281
|
+
'jobs',
|
|
279
282
|
'loadOnMount',
|
|
280
283
|
'source',
|
|
281
284
|
'teleportTo',
|
|
@@ -319,6 +322,18 @@ describe('frontend docs catalog coverage', () => {
|
|
|
319
322
|
'AppPillNav',
|
|
320
323
|
'usePluginApi',
|
|
321
324
|
'UsePluginApiOptions',
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
for (const name of retiredNames) {
|
|
328
|
+
expect(rootIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
329
|
+
expect(componentsIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
330
|
+
expect(composableExports).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('keeps the MINT 1.1 composable compatibility exports', () => {
|
|
335
|
+
const composablesIndex = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8')
|
|
336
|
+
const expected = [
|
|
322
337
|
'useJobsStatusTray',
|
|
323
338
|
'UseJobsStatusTrayOptions',
|
|
324
339
|
'UseJobsStatusTrayReturn',
|
|
@@ -329,11 +344,7 @@ describe('frontend docs catalog coverage', () => {
|
|
|
329
344
|
'usePluginClient',
|
|
330
345
|
]
|
|
331
346
|
|
|
332
|
-
|
|
333
|
-
expect(rootIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
334
|
-
expect(componentsIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
335
|
-
expect(composableExports).not.toMatch(new RegExp(`\\b${name}\\b`))
|
|
336
|
-
}
|
|
347
|
+
expect(expected.filter(name => !new RegExp(`\\b${name}\\b`).test(composablesIndex))).toEqual([])
|
|
337
348
|
})
|
|
338
349
|
|
|
339
350
|
it('documents every public composable/helper export used by docs and doctor', () => {
|
|
@@ -15,12 +15,24 @@ import {
|
|
|
15
15
|
} from '../jobs'
|
|
16
16
|
import {
|
|
17
17
|
usePluginJobCenter,
|
|
18
|
+
type JobsStatusTrayAdapter,
|
|
18
19
|
type PluginJobCenterSource,
|
|
19
20
|
} from '../composables/useJobsStatusTray'
|
|
20
|
-
import type {
|
|
21
|
+
import type { UsePluginEventStreamReturn } from '../composables/usePluginClient'
|
|
22
|
+
import type {
|
|
23
|
+
JobState,
|
|
24
|
+
JobStateInput,
|
|
25
|
+
JobStreamData,
|
|
26
|
+
} from '../types/jobs'
|
|
21
27
|
|
|
22
28
|
interface Props {
|
|
23
29
|
source?: PluginJobCenterSource
|
|
30
|
+
/** @deprecated Pass a source instead. */
|
|
31
|
+
jobs?: readonly JobStateInput[]
|
|
32
|
+
/** @deprecated Pass source actions instead. */
|
|
33
|
+
adapter?: JobsStatusTrayAdapter
|
|
34
|
+
/** @deprecated Event streams belong to the source runtime. */
|
|
35
|
+
eventStream?: UsePluginEventStreamReturn<JobStreamData>
|
|
24
36
|
loadOnMount?: boolean
|
|
25
37
|
title?: string
|
|
26
38
|
teleportTo?: string | HTMLElement | false
|
|
@@ -49,6 +61,9 @@ const panelRef = ref<HTMLElement | null>(null)
|
|
|
49
61
|
const teleportTarget = computed(() => props.teleportTo || 'body')
|
|
50
62
|
const tray = usePluginJobCenter({
|
|
51
63
|
source: toRef(props, 'source'),
|
|
64
|
+
jobs: toRef(props, 'jobs'),
|
|
65
|
+
adapter: toRef(props, 'adapter'),
|
|
66
|
+
eventStream: toRef(props, 'eventStream'),
|
|
52
67
|
loadOnMount: props.loadOnMount,
|
|
53
68
|
})
|
|
54
69
|
const canClear = computed(() => (
|
package/src/composables/index.ts
CHANGED
|
@@ -153,6 +153,7 @@ export {
|
|
|
153
153
|
type UseGeneratedAnalysisReturn,
|
|
154
154
|
} from './useGeneratedAnalysis'
|
|
155
155
|
export { useAutoGroup, DEFAULT_COLORS, extractSamplesFromDesignData } from './useAutoGroup'
|
|
156
|
+
export { usePluginConfig, type UsePluginConfigReturn } from './usePluginConfig'
|
|
156
157
|
export {
|
|
157
158
|
useReagentSeries,
|
|
158
159
|
generateDilutionSeries,
|
|
@@ -487,6 +488,7 @@ export {
|
|
|
487
488
|
pluginFormDataFromPayload,
|
|
488
489
|
resolvePluginBaseUrl,
|
|
489
490
|
uploadPluginEndpoint,
|
|
491
|
+
usePluginClient,
|
|
490
492
|
usePluginEventStream,
|
|
491
493
|
usePluginSettings,
|
|
492
494
|
useCurrentExperiment,
|
|
@@ -515,9 +517,14 @@ export {
|
|
|
515
517
|
export {
|
|
516
518
|
combinePluginJobSources,
|
|
517
519
|
usePluginJobCenter,
|
|
520
|
+
useJobsStatusTray,
|
|
518
521
|
type CombinedPluginJobCenterSource,
|
|
522
|
+
type JobsStatusTrayAdapter,
|
|
523
|
+
type JobStreamMessage,
|
|
519
524
|
type PluginJobCenterSource,
|
|
520
525
|
type JobsStatusTrayTab,
|
|
526
|
+
type UseJobsStatusTrayOptions,
|
|
527
|
+
type UseJobsStatusTrayReturn,
|
|
521
528
|
type UsePluginJobCenterOptions,
|
|
522
529
|
type UsePluginJobCenterReturn,
|
|
523
530
|
} from './useJobsStatusTray'
|
|
@@ -93,6 +93,7 @@ export function useAuth(): UseAuthReturn {
|
|
|
93
93
|
passkey_enabled: boolean
|
|
94
94
|
passkey_registered?: boolean
|
|
95
95
|
registration_enabled?: boolean
|
|
96
|
+
database_mode?: string
|
|
96
97
|
access?: {
|
|
97
98
|
experiment_visibility_mode?: 'open' | 'restricted'
|
|
98
99
|
experimentVisibilityMode?: 'open' | 'restricted'
|
|
@@ -104,6 +105,7 @@ export function useAuth(): UseAuthReturn {
|
|
|
104
105
|
passkeyEnabled: response.data.passkey_enabled,
|
|
105
106
|
passkeyRegistered: response.data.passkey_registered ?? false,
|
|
106
107
|
registrationEnabled: response.data.registration_enabled ?? false,
|
|
108
|
+
databaseMode: response.data.database_mode ?? 'none',
|
|
107
109
|
experimentVisibilityMode:
|
|
108
110
|
response.data.access?.experiment_visibility_mode
|
|
109
111
|
?? response.data.access?.experimentVisibilityMode
|
|
@@ -119,6 +121,7 @@ export function useAuth(): UseAuthReturn {
|
|
|
119
121
|
passkeyEnabled: false,
|
|
120
122
|
passkeyRegistered: false,
|
|
121
123
|
registrationEnabled: false,
|
|
124
|
+
databaseMode: 'none',
|
|
122
125
|
experimentVisibilityMode: 'open',
|
|
123
126
|
}
|
|
124
127
|
}
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
type ShallowRef,
|
|
9
9
|
} from 'vue'
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
JobsStatusTrayAdapter,
|
|
13
|
+
PluginJobCenterSource,
|
|
14
|
+
} from './useJobsStatusTray'
|
|
12
15
|
import { useEventStream, type EventStreamMessage } from './useEventStream'
|
|
13
16
|
import { normalizeJobState } from '../jobs'
|
|
14
17
|
import type {
|
|
@@ -102,6 +105,8 @@ export interface UsePluginJobsReturn extends PluginJobCenterSource {
|
|
|
102
105
|
pause: () => void
|
|
103
106
|
resume: () => void
|
|
104
107
|
dispose: () => void
|
|
108
|
+
/** @deprecated Pass the runtime itself to SDK job UI as its source. */
|
|
109
|
+
jobsAdapter: JobsStatusTrayAdapter
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export type UseGeneratedAnalysisReturn = UsePluginJobsReturn
|
|
@@ -972,6 +977,13 @@ function createPluginJobs(
|
|
|
972
977
|
return payload.deleted
|
|
973
978
|
}
|
|
974
979
|
|
|
980
|
+
const jobsAdapter: JobsStatusTrayAdapter = {
|
|
981
|
+
listJobs: async () => ({ jobs: await refreshJobs() }),
|
|
982
|
+
cancelJob: async job => cancelJob(job as GeneratedAnalysisJob),
|
|
983
|
+
deleteJob: async job => deleteJob(job as GeneratedAnalysisJob),
|
|
984
|
+
clearFinishedJobs: async () => clearFinishedJobs(),
|
|
985
|
+
}
|
|
986
|
+
|
|
975
987
|
function pause(): void {
|
|
976
988
|
if (disposed || paused) return
|
|
977
989
|
paused = true
|
|
@@ -1065,6 +1077,7 @@ function createPluginJobs(
|
|
|
1065
1077
|
pause,
|
|
1066
1078
|
resume,
|
|
1067
1079
|
dispose,
|
|
1080
|
+
jobsAdapter,
|
|
1068
1081
|
}
|
|
1069
1082
|
}
|
|
1070
1083
|
|