@morscherlab/mint-sdk 1.0.63 → 1.1.0-beta.2

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 (165) hide show
  1. package/README.md +101 -2
  2. package/dist/EmptyState-D1c4RdC7.js +638 -0
  3. package/dist/EmptyState-D1c4RdC7.js.map +1 -0
  4. package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
  5. package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
  7. package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
  8. package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
  9. package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
  10. package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
  11. package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
  12. package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
  13. package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
  14. package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
  15. package/dist/__tests__/generatedUi.test.d.ts +1 -0
  16. package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
  17. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -0
  18. package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
  19. package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
  20. package/dist/chunk-BoAXSpZd.js +33 -0
  21. package/dist/components/AppSidebar.vue.d.ts +1 -1
  22. package/dist/components/AutoGroupModal.vue.d.ts +2 -2
  23. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  25. package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
  26. package/dist/components/ConfirmDialog.vue.d.ts +1 -1
  27. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  28. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  29. package/dist/components/FileUploader.vue.d.ts +4 -2
  30. package/dist/components/FitPanel.vue.d.ts +1 -1
  31. package/dist/components/FormActions.vue.d.ts +1 -1
  32. package/dist/components/FormBuilder.vue.d.ts +1 -1
  33. package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
  34. package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
  35. package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
  36. package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
  37. package/dist/components/JobsStatusTray.vue.d.ts +5 -1
  38. package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
  39. package/dist/components/SampleLegend.vue.d.ts +1 -1
  40. package/dist/components/SampleSelector.vue.d.ts +2 -2
  41. package/dist/components/SettingsModal.vue.d.ts +1 -1
  42. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  43. package/dist/components/index.d.ts +4 -0
  44. package/dist/components/index.js +8 -9
  45. package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
  46. package/dist/{components-DdzHh3a_.js → components-UfkPN1bK.js} +2255 -2303
  47. package/dist/components-UfkPN1bK.js.map +1 -0
  48. package/dist/composables/index.d.ts +2 -3
  49. package/dist/composables/index.js +10 -11
  50. package/dist/composables/platformContextHelpers.d.ts +0 -2
  51. package/dist/composables/useAnalysisArtifacts.d.ts +1 -3
  52. package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
  53. package/dist/composables/useJobsStatusTray.d.ts +48 -4
  54. package/dist/composables/usePlatformContext.d.ts +3 -0
  55. package/dist/composables/usePluginClient.d.ts +6 -3
  56. package/dist/{composables-BUUphBqg.js → composables-B512bWJP.js} +155 -74
  57. package/dist/composables-B512bWJP.js.map +1 -0
  58. package/dist/experiment-utils-CxkruZ3G.js +392 -0
  59. package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
  60. package/dist/generated/schema.d.ts +7 -0
  61. package/dist/generated-ui/main.d.ts +0 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.js +15 -18
  64. package/dist/index.js.map +1 -1
  65. package/dist/install.js +7 -8
  66. package/dist/install.js.map +1 -1
  67. package/dist/plotly.min-BR53hja4.js +180634 -0
  68. package/dist/plotly.min-BR53hja4.js.map +1 -0
  69. package/dist/stores/auth.d.ts +2 -2
  70. package/dist/stores/index.js +1 -1
  71. package/dist/styles.css +10543 -10404
  72. package/dist/templates/index.js +4 -3
  73. package/dist/templates/packs.d.ts +0 -1
  74. package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
  75. package/dist/templates-Cttbjbyc.js.map +1 -0
  76. package/dist/types/analysisArtifactTypes.d.ts +0 -8
  77. package/dist/types/form-builder.d.ts +1 -1
  78. package/dist/types/generated-ui.d.ts +170 -0
  79. package/dist/types/index.d.ts +2 -1
  80. package/dist/types/index.js +1 -1
  81. package/dist/types/instrument.d.ts +1 -0
  82. package/dist/types/jobs.d.ts +7 -1
  83. package/dist/types/platform.d.ts +2 -0
  84. package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
  85. package/dist/useControlSchema-Dallso-L.js.map +1 -0
  86. package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
  87. package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
  88. package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
  89. package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
  90. package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
  91. package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
  92. package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-BP7_4RGQ.js} +307 -475
  93. package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
  94. package/package.json +7 -3
  95. package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
  96. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
  98. package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
  99. package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
  100. package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
  101. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +1 -74
  102. package/src/__tests__/composables/useApi.test.ts +8 -0
  103. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
  104. package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
  105. package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
  106. package/src/__tests__/composables/usePluginClient.test.ts +366 -20
  107. package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
  108. package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
  109. package/src/__tests__/generatedUi.test.ts +119 -0
  110. package/src/__tests__/templates/templates.test.ts +1 -1
  111. package/src/components/FileUploader.vue +49 -11
  112. package/src/components/GeneratedPathInput.vue +218 -0
  113. package/src/components/GeneratedPlotlyResult.vue +87 -0
  114. package/src/components/GeneratedPluginView.vue +279 -0
  115. package/src/components/GeneratedResultRenderer.vue +137 -0
  116. package/src/components/InstrumentAlertLog.vue +2 -2
  117. package/src/components/JobsStatusTray.vue +14 -8
  118. package/src/components/index.ts +4 -0
  119. package/src/composables/formBuilderRegistry.ts +2 -0
  120. package/src/composables/index.ts +18 -5
  121. package/src/composables/platformContextHelpers.ts +3 -10
  122. package/src/composables/useAnalysisArtifacts.ts +0 -48
  123. package/src/composables/useApi.ts +1 -0
  124. package/src/composables/useGeneratedAnalysis.ts +1252 -0
  125. package/src/composables/useJobsStatusTray.ts +234 -45
  126. package/src/composables/usePluginClient.ts +152 -25
  127. package/src/composables/usePluginConfig.ts +156 -20
  128. package/src/generated/schema.ts +130 -0
  129. package/src/generated-ui/main.ts +21 -0
  130. package/src/index.ts +3 -0
  131. package/src/jobs.ts +5 -0
  132. package/src/plotly-dist.d.ts +3 -0
  133. package/src/templates/packs.ts +0 -3
  134. package/src/types/analysisArtifactTypes.ts +0 -9
  135. package/src/types/form-builder.ts +1 -0
  136. package/src/types/generated-ui.ts +176 -0
  137. package/src/types/index.ts +2 -1
  138. package/src/types/instrument.ts +1 -0
  139. package/src/types/jobs.ts +10 -1
  140. package/src/types/platform.ts +2 -0
  141. package/dist/BaseButton-Dgqrze41.js +0 -74
  142. package/dist/BaseButton-Dgqrze41.js.map +0 -1
  143. package/dist/BaseModal-B9UA8Y_I.js +0 -165
  144. package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
  145. package/dist/BaseSelect-ekgr9fDo.js +0 -179
  146. package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
  147. package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
  148. package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
  149. package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
  150. package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
  151. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +0 -1
  152. package/dist/components-DdzHh3a_.js.map +0 -1
  153. package/dist/composables/analysisArtifactTarget.d.ts +0 -5
  154. package/dist/composables-BUUphBqg.js.map +0 -1
  155. package/dist/experiment-utils-Bfa7CwPU.js +0 -108
  156. package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
  157. package/dist/templates-CNbPQNID.js.map +0 -1
  158. package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
  159. package/dist/useControlSchema-BZNdalmL.js.map +0 -1
  160. package/dist/useEventListener-CfVkP9Xz.js +0 -57
  161. package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
  162. package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
  163. package/src/__tests__/composables/analysisArtifactTarget.test.ts +0 -77
  164. package/src/composables/analysisArtifactTarget.ts +0 -53
  165. /package/dist/__tests__/{composables/analysisArtifactTarget.test.d.ts → components/GeneratedPathInput.test.d.ts} +0 -0
@@ -38,16 +38,24 @@ type FakeResponse = Record<string, unknown>
38
38
 
39
39
  let getResponse: FakeResponse = { plugin_name: 'drp', config: {} }
40
40
  let patchResponse: FakeResponse = { plugin_name: 'drp', config: {} }
41
+ let putResponse: FakeResponse = { plugin_name: 'drp', config: {} }
41
42
  let getError: Error | null = null
42
43
  let patchError: Error | null = null
43
- let recordedCalls: { method: string; url: string; data?: unknown }[] = []
44
+ let recordedBaseUrls: Array<string | undefined> = []
45
+ let recordedCalls: {
46
+ method: string
47
+ url: string
48
+ data?: unknown
49
+ headers?: AxiosRequestConfig['headers']
50
+ }[] = []
44
51
 
45
52
  function mockAxios() {
46
53
  vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
47
54
  this: unknown,
48
55
  url: string,
49
- _config?: AxiosRequestConfig,
56
+ config?: AxiosRequestConfig,
50
57
  ) {
58
+ recordedBaseUrls.push(config?.baseURL)
51
59
  recordedCalls.push({ method: 'GET', url })
52
60
  if (getError) throw getError
53
61
  return { data: getResponse }
@@ -56,12 +64,23 @@ function mockAxios() {
56
64
  this: unknown,
57
65
  url: string,
58
66
  data?: unknown,
59
- _config?: AxiosRequestConfig,
67
+ config?: AxiosRequestConfig,
60
68
  ) {
69
+ recordedBaseUrls.push(config?.baseURL)
61
70
  recordedCalls.push({ method: 'PATCH', url, data })
62
71
  if (patchError) throw patchError
63
72
  return { data: patchResponse }
64
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
+ })
65
84
  }
66
85
 
67
86
  describe('usePluginConfig', () => {
@@ -70,13 +89,17 @@ describe('usePluginConfig', () => {
70
89
  autoLoadOnMount = false
71
90
  getResponse = { plugin_name: 'drp', config: {} }
72
91
  patchResponse = { plugin_name: 'drp', config: {} }
92
+ putResponse = { plugin_name: 'drp', config: {} }
73
93
  getError = null
74
94
  patchError = null
75
95
  recordedCalls = []
96
+ recordedBaseUrls = []
97
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
76
98
  mockAxios()
77
99
  })
78
100
 
79
101
  afterEach(() => {
102
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
80
103
  vi.restoreAllMocks()
81
104
  })
82
105
 
@@ -106,12 +129,148 @@ describe('usePluginConfig', () => {
106
129
  expect(recordedCalls[0]!.url).toBe('/plugins/my%2Fplugin/config')
107
130
  })
108
131
 
109
- it('save PATCHes the plugin config endpoint', async () => {
110
- getResponse = { plugin_name: 'drp', config: { n: 1 } }
111
- patchResponse = { plugin_name: 'drp', config: { n: 2 } }
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
+ }
112
190
  const hook = usePluginConfig('drp')
113
191
  await hook.load()
114
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
+ }
115
274
  await nextTick()
116
275
  expect(hook.isDirty.value).toBe(true)
117
276
 
@@ -120,11 +279,168 @@ describe('usePluginConfig', () => {
120
279
  const patchCall = recordedCalls.find((c) => c.method === 'PATCH')!
121
280
  expect(patchCall.url).toBe('/plugins/drp/config')
122
281
  expect(patchCall.data).toEqual({ config: { n: 2 } })
123
- expect(hook.config.value).toEqual({ n: 2 })
282
+ expect(patchCall.headers).toBeUndefined()
283
+ expect(hook.config.value).toEqual({
284
+ n: 2,
285
+ allowed_experiment_types: ['metabolomics'],
286
+ })
124
287
  expect(hook.isDirty.value).toBe(false)
125
288
  expect(hook.lastSavedAt.value).toBeInstanceOf(Date)
126
289
  })
127
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
+
128
444
  it('isDirty reflects edits vs last saved snapshot', async () => {
129
445
  getResponse = { plugin_name: 'drp', config: { k: 'v' } }
130
446
  const hook = usePluginConfig('drp')
@@ -47,7 +47,6 @@ describe('extract docs template helpers', () => {
47
47
  group: packEntry.category,
48
48
  source: 'packages/sdk-frontend/src/templates/packs.ts',
49
49
  add_command: packEntry.add_command,
50
- init_command: packEntry.init_command,
51
50
  docs_command: packEntry.docs_command,
52
51
  })
53
52
  })
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ generatedJobDefaults,
5
+ generatedJobFormSchema,
6
+ normalizeGeneratedJobInput,
7
+ } from '../generated/schema'
8
+ import type { GeneratedJobDefinition } from '../types/generated-ui'
9
+
10
+ const definition: GeneratedJobDefinition = {
11
+ id: 'summarize',
12
+ title: 'Summarize',
13
+ description: 'Summarize a dataset',
14
+ cpu: 2,
15
+ input_schema: {
16
+ type: 'object',
17
+ properties: {
18
+ threshold: { type: 'number', title: 'Threshold', default: 2.5, minimum: 0, maximum: 10 },
19
+ method: { type: 'string', title: 'Method', enum: ['fast', 'robust'], default: 'fast' },
20
+ normalize: { type: 'boolean', title: 'Normalize', default: true },
21
+ source: {
22
+ anyOf: [{ type: 'string', format: 'path' }, { type: 'null' }],
23
+ default: null,
24
+ 'x-mint-component': 'path',
25
+ },
26
+ },
27
+ required: ['threshold'],
28
+ },
29
+ output_schema: {},
30
+ }
31
+
32
+ describe('generated UI schema adapter', () => {
33
+ it('maps the supported JSON Schema subset to standard FormBuilder fields', () => {
34
+ expect(generatedJobFormSchema(definition).sections[0]?.fields).toMatchObject([
35
+ { name: 'threshold', type: 'number', validation: { required: true, min: 0, max: 10 } },
36
+ { name: 'method', type: 'select', props: { options: ['fast', 'robust'] } },
37
+ { name: 'normalize', type: 'toggle' },
38
+ { name: 'source', type: 'path' },
39
+ ])
40
+ })
41
+
42
+ it('extracts defaults without sharing mutable input state', () => {
43
+ const defaults = generatedJobDefaults(definition)
44
+ const second = generatedJobDefaults(definition)
45
+ defaults.threshold = 9
46
+
47
+ expect(second).toEqual({ threshold: 2.5, method: 'fast', normalize: true, source: null })
48
+ })
49
+
50
+ it('preserves explicit null defaults without inventing values for missing defaults', () => {
51
+ const nullableDefinition: GeneratedJobDefinition = {
52
+ ...definition,
53
+ input_schema: {
54
+ type: 'object',
55
+ properties: {
56
+ optional_count: {
57
+ anyOf: [{ type: 'integer' }, { type: 'null' }],
58
+ default: null,
59
+ },
60
+ required_count: { type: 'integer' },
61
+ },
62
+ required: ['required_count'],
63
+ },
64
+ }
65
+
66
+ expect({
67
+ defaults: generatedJobDefaults(nullableDefinition),
68
+ fields: generatedJobFormSchema(nullableDefinition).sections[0]?.fields.map(field => ({
69
+ name: field.name,
70
+ defaultValue: field.defaultValue,
71
+ })),
72
+ }).toEqual({
73
+ defaults: { optional_count: null },
74
+ fields: [
75
+ { name: 'optional_count', defaultValue: null },
76
+ { name: 'required_count', defaultValue: undefined },
77
+ ],
78
+ })
79
+ })
80
+
81
+ it('keeps a selected path for SDK staging during submission', () => {
82
+ const selection = {
83
+ kind: 'path' as const,
84
+ files: [new File(['data'], 'data.csv')],
85
+ relativePaths: ['data.csv'],
86
+ }
87
+
88
+ expect(normalizeGeneratedJobInput(definition, {
89
+ threshold: 2.5,
90
+ source: selection,
91
+ })).toEqual({
92
+ threshold: 2.5,
93
+ source: selection,
94
+ })
95
+ })
96
+
97
+ it('marks path arrays as multiple path inputs', () => {
98
+ const listDefinition: GeneratedJobDefinition = {
99
+ ...definition,
100
+ input_schema: {
101
+ type: 'object',
102
+ properties: {
103
+ sources: {
104
+ type: 'array',
105
+ items: { type: 'string', format: 'path' },
106
+ 'x-mint-component': 'path',
107
+ },
108
+ },
109
+ required: ['sources'],
110
+ },
111
+ }
112
+
113
+ expect(generatedJobFormSchema(listDefinition).sections[0]?.fields[0]).toMatchObject({
114
+ name: 'sources',
115
+ type: 'path',
116
+ props: { multiple: true },
117
+ })
118
+ })
119
+ })
@@ -166,7 +166,7 @@ describe('bio data templates', () => {
166
166
  expect(getBioTemplatePackInfo('metabolism')?.name).toBe('omics-assay')
167
167
  expect(getBioTemplatePackInfo('metabolite profiling')?.name).toBe('omics-assay')
168
168
  expect(getBioTemplatePackInfo('metabolomics')?.components).toContain('ExperimentTimeline')
169
- expect(getBioTemplatePackInfo('longitudinal')?.init_command).toBe('mint init --template longitudinal-study')
169
+ expect(getBioTemplatePackInfo('longitudinal')?.add_command).toBe('mint add data-template-pack longitudinal-study --page')
170
170
  expect(getBioTemplatePackInfo('gene expression')?.templates.at(-1)).toBe('qpcr-plate')
171
171
  expect(getBioTemplatePackInfo('gene-expression')?.templates.at(-1)).toBe('qpcr-plate')
172
172
  expect(searchBioTemplatePacks('WellPlate').map(entry => entry.name)).toEqual([
@@ -19,7 +19,8 @@ const props = withDefaults(defineProps<Props>(), {
19
19
  })
20
20
 
21
21
  const emit = defineEmits<{
22
- upload: [files: File[]]
22
+ upload: [files: File[], rootName?: string, relativePaths?: string[]]
23
+ remove: [files: File[]]
23
24
  error: [message: string]
24
25
  }>()
25
26
 
@@ -116,20 +117,31 @@ function validateFiles(files: FileList | File[]): File[] {
116
117
  return validFiles
117
118
  }
118
119
 
119
- function handleFiles(files: FileList | File[], folder?: string) {
120
+ function handleFiles(
121
+ files: FileList | File[],
122
+ folder?: string,
123
+ explicitRelativePaths?: string[],
124
+ ) {
125
+ const candidates = Array.from(files)
120
126
  const validFiles = validateFiles(files)
121
127
  if (validFiles.length === 0) return
128
+ const relativePaths = validFiles.map((file) => {
129
+ const candidateIndex = candidates.indexOf(file)
130
+ return explicitRelativePaths?.[candidateIndex]
131
+ || file.webkitRelativePath
132
+ || (folder ? `${folder}/${file.name}` : file.name)
133
+ })
122
134
 
123
135
  if (isFolder.value) {
124
136
  folderName.value = folder || extractFolderName(validFiles)
125
137
  selectedFiles.value = validFiles
126
- emit('upload', validFiles)
138
+ emit('upload', validFiles, folderName.value ?? undefined, relativePaths)
127
139
  } else if (!props.multiple) {
128
140
  selectedFiles.value = [validFiles[0]]
129
- emit('upload', [validFiles[0]])
141
+ emit('upload', [validFiles[0]], undefined, [relativePaths[0]])
130
142
  } else {
131
143
  selectedFiles.value = [...selectedFiles.value, ...validFiles]
132
- emit('upload', validFiles)
144
+ emit('upload', validFiles, undefined, relativePaths)
133
145
  }
134
146
  }
135
147
 
@@ -156,25 +168,39 @@ function handleDrop(event: DragEvent) {
156
168
 
157
169
  async function handleFolderDrop(items: DataTransferItemList) {
158
170
  const files: File[] = []
171
+ const relativePaths: string[] = []
159
172
  let folderNameFromDrop: string | undefined
160
173
 
161
174
  for (const item of Array.from(items)) {
162
175
  const entry = item.webkitGetAsEntry?.()
163
176
  if (entry?.isDirectory) {
164
177
  folderNameFromDrop = entry.name
165
- await readDirectory(entry as FileSystemDirectoryEntry, files)
178
+ await readDirectory(
179
+ entry as FileSystemDirectoryEntry,
180
+ files,
181
+ relativePaths,
182
+ entry.name,
183
+ )
166
184
  } else if (entry?.isFile) {
167
185
  const file = await getFile(entry as FileSystemFileEntry)
168
- if (file) files.push(file)
186
+ if (file) {
187
+ files.push(file)
188
+ relativePaths.push(file.name)
189
+ }
169
190
  }
170
191
  }
171
192
 
172
193
  if (files.length > 0) {
173
- handleFiles(files, folderNameFromDrop)
194
+ handleFiles(files, folderNameFromDrop, relativePaths)
174
195
  }
175
196
  }
176
197
 
177
- async function readDirectory(directory: FileSystemDirectoryEntry, files: File[]): Promise<void> {
198
+ async function readDirectory(
199
+ directory: FileSystemDirectoryEntry,
200
+ files: File[],
201
+ relativePaths: string[],
202
+ prefix: string,
203
+ ): Promise<void> {
178
204
  const reader = directory.createReader()
179
205
  // readEntries returns at most 100 entries per call per spec — must loop
180
206
  let batch: FileSystemEntry[]
@@ -185,9 +211,17 @@ async function readDirectory(directory: FileSystemDirectoryEntry, files: File[])
185
211
  for (const entry of batch) {
186
212
  if (entry.isFile) {
187
213
  const file = await getFile(entry as FileSystemFileEntry)
188
- if (file) files.push(file)
214
+ if (file) {
215
+ files.push(file)
216
+ relativePaths.push(`${prefix}/${file.name}`)
217
+ }
189
218
  } else if (entry.isDirectory) {
190
- await readDirectory(entry as FileSystemDirectoryEntry, files)
219
+ await readDirectory(
220
+ entry as FileSystemDirectoryEntry,
221
+ files,
222
+ relativePaths,
223
+ `${prefix}/${entry.name}`,
224
+ )
191
225
  }
192
226
  }
193
227
  } while (batch.length > 0)
@@ -225,15 +259,19 @@ function openFilePicker() {
225
259
  }
226
260
 
227
261
  function removeFile(index: number) {
262
+ const removed = selectedFiles.value[index]
228
263
  selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
229
264
  if (isFolder.value && selectedFiles.value.length === 0) {
230
265
  folderName.value = null
231
266
  }
267
+ if (removed) emit('remove', [removed])
232
268
  }
233
269
 
234
270
  function clearAll() {
271
+ const removed = [...selectedFiles.value]
235
272
  selectedFiles.value = []
236
273
  folderName.value = null
274
+ if (removed.length) emit('remove', removed)
237
275
  }
238
276
 
239
277
  function formatFileSize(bytes: number): string {