@morscherlab/mint-sdk 1.1.0-beta.5 → 1.1.0-beta.7

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 (42) hide show
  1. package/README.md +18 -4
  2. package/dist/{SettingsModal-DDaq294M.js → SettingsModal-Dpg-X1OM.js} +3 -3
  3. package/dist/{SettingsModal-DDaq294M.js.map → SettingsModal-Dpg-X1OM.js.map} +1 -1
  4. package/dist/__tests__/types/GeneratedJsonSchema.test.d.ts +1 -0
  5. package/dist/components/PluginWorkspaceView.props.d.ts +1 -1
  6. package/dist/components/index.js +3 -3
  7. package/dist/{components-t0iBwn5R.js → components-Ci_BTx00.js} +10 -11
  8. package/dist/components-Ci_BTx00.js.map +1 -0
  9. package/dist/composables/index.d.ts +1 -1
  10. package/dist/composables/index.js +5 -5
  11. package/dist/composables/useGeneratedAnalysis.d.ts +1 -1
  12. package/dist/composables/usePluginClient.d.ts +11 -5
  13. package/dist/{composables-CqjA4Gth.js → composables-C0VEJ73O.js} +3 -3
  14. package/dist/{composables-CqjA4Gth.js.map → composables-C0VEJ73O.js.map} +1 -1
  15. package/dist/index.js +7 -7
  16. package/dist/install.js +3 -3
  17. package/dist/styles.css +11 -11
  18. package/dist/templates/index.js +2 -2
  19. package/dist/{templates-BdArs8LL.js → templates-Cdj93S9C.js} +2 -2
  20. package/dist/{templates-BdArs8LL.js.map → templates-Cdj93S9C.js.map} +1 -1
  21. package/dist/types/generated-ui.d.ts +4 -0
  22. package/dist/{useControlSchema-BAqu3kqE.js → useControlSchema-B_qgU4rW.js} +16 -14
  23. package/dist/useControlSchema-B_qgU4rW.js.map +1 -0
  24. package/dist/{useFormBuilder-DvO-TrfX.js → useFormBuilder-D1G5J5Z5.js} +2 -2
  25. package/dist/{useFormBuilder-DvO-TrfX.js.map → useFormBuilder-D1G5J5Z5.js.map} +1 -1
  26. package/dist/{useProtocolTemplates-BuY_MlBP.js → useProtocolTemplates-DeaOmMaw.js} +3 -3
  27. package/dist/{useProtocolTemplates-BuY_MlBP.js.map → useProtocolTemplates-DeaOmMaw.js.map} +1 -1
  28. package/package.json +1 -1
  29. package/src/__tests__/components/GeneratedPluginView.test.ts +10 -0
  30. package/src/__tests__/components/PluginWorkspaceView.test.ts +68 -14
  31. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +2 -0
  32. package/src/__tests__/composables/usePluginClient.test.ts +36 -1
  33. package/src/__tests__/types/GeneratedJsonSchema.test.ts +17 -0
  34. package/src/components/GeneratedPluginView.vue +1 -1
  35. package/src/components/PluginWorkspaceView.props.ts +1 -1
  36. package/src/components/PluginWorkspaceView.vue +1 -5
  37. package/src/composables/index.ts +1 -0
  38. package/src/composables/useGeneratedAnalysis.ts +30 -12
  39. package/src/composables/usePluginClient.ts +12 -5
  40. package/src/types/generated-ui.ts +4 -0
  41. package/dist/components-t0iBwn5R.js.map +0 -1
  42. package/dist/useControlSchema-BAqu3kqE.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.0-beta.5",
3
+ "version": "1.1.0-beta.7",
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",
@@ -190,6 +190,16 @@ describe('GeneratedPluginView', () => {
190
190
  expect(wrapper.find('[aria-label="Job resource requirements"]').text()).toBe('Host service')
191
191
  })
192
192
 
193
+ it('describes process CPU declarations as an upper bound', async () => {
194
+ const wrapper = mount(GeneratedPluginView, {
195
+ props: { baseUrl: '/api/demo' },
196
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
197
+ })
198
+ await flushPromises()
199
+
200
+ expect(wrapper.find('[aria-label="Job resource requirements"]').text()).toBe('Up to 2 CPU')
201
+ })
202
+
193
203
  it('should pass the host transport to the generated analysis composable', async () => {
194
204
  const transport = vi.fn()
195
205
  mount(GeneratedPluginView, {
@@ -1,4 +1,5 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
+ import type { VueWrapper } from '@vue/test-utils'
2
3
  import { createPinia } from 'pinia'
3
4
  import { computed, h, nextTick, ref } from 'vue'
4
5
  import { describe, expect, it, vi } from 'vitest'
@@ -10,7 +11,7 @@ import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vu
10
11
  import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
11
12
  import { defineDoseDesignControlModel } from '../../composables/useControlSchema'
12
13
  import { usePlatformContext } from '../../composables/usePlatformContext'
13
- import type { PillNavItem, SidebarToolSection } from '../../types'
14
+ import type { PageSelectorItemInput, PillNavItem, SidebarToolSection } from '../../types'
14
15
 
15
16
  vi.mock('../../composables/usePlatformContext', () => ({
16
17
  usePlatformContext: vi.fn(() => ({
@@ -79,6 +80,44 @@ const globalOptions = {
79
80
  },
80
81
  }
81
82
 
83
+ interface PageSelectorWorkspaceProps {
84
+ pageSelector?: PageSelectorItemInput[]
85
+ currentPageSelectorId?: string
86
+ }
87
+
88
+ function mountIntegratedPageSelectorWorkspace(
89
+ props: PageSelectorWorkspaceProps = {},
90
+ ): VueWrapper {
91
+ vi.mocked(usePlatformContext).mockReturnValueOnce({
92
+ isIntegrated: computed(() => true),
93
+ context: ref({ isIntegrated: true, theme: 'system' }),
94
+ plugin: computed(() => ({
95
+ id: 'workspace-plugin',
96
+ name: 'Workspace Plugin',
97
+ version: '1.0.0',
98
+ route_prefix: '/workspace-plugin',
99
+ api_prefix: '/api/workspace-plugin',
100
+ nav_items: [
101
+ { path: '/platform-one', label: 'Platform One' },
102
+ { path: '/platform-two', label: 'Platform Two' },
103
+ ],
104
+ })),
105
+ user: computed(() => undefined),
106
+ theme: computed(() => 'system' as const),
107
+ features: computed(() => undefined),
108
+ navigate: vi.fn(),
109
+ notify: vi.fn(),
110
+ sendToPlatform: vi.fn(),
111
+ })
112
+ return mount(PluginWorkspaceView, {
113
+ props: {
114
+ title: 'Workspace',
115
+ ...props,
116
+ },
117
+ global: globalOptions,
118
+ })
119
+ }
120
+
82
121
  describe('PluginWorkspaceView', () => {
83
122
  it('renders the standard AppLayout, AppTopBar, and AppSidebar plugin shell', () => {
84
123
  const wrapper = mount(PluginWorkspaceView, {
@@ -146,22 +185,37 @@ describe('PluginWorkspaceView', () => {
146
185
  expect(wrapper.emitted('pill-select')).toEqual([[{ id: 'results', label: 'Results' }]])
147
186
  })
148
187
 
149
- it('hides topbar page selector until multiple pages are registered', () => {
150
- const wrapper = mount(PluginWorkspaceView, {
151
- props: {
152
- title: 'Downloader',
153
- subtitle: 'Downloads',
154
- pageSelector: [
155
- { id: 'downloads', label: 'Downloads', to: '/' },
156
- ],
157
- currentPageSelectorId: 'downloads',
158
- },
159
- global: globalOptions,
188
+ it('should use platform pages when pageSelector is omitted', () => {
189
+ const wrapper = mountIntegratedPageSelectorWorkspace()
190
+
191
+ expect(wrapper.find('.mint-page-selector').exists()).toBe(true)
192
+ })
193
+
194
+ it('should suppress platform pages when pageSelector is explicitly empty', () => {
195
+ const wrapper = mountIntegratedPageSelectorWorkspace({ pageSelector: [] })
196
+
197
+ expect(wrapper.find('.mint-page-selector').exists()).toBe(false)
198
+ })
199
+
200
+ it('should hide one explicit page without falling back to platform pages', () => {
201
+ const wrapper = mountIntegratedPageSelectorWorkspace({
202
+ pageSelector: [{ id: 'downloads', label: 'Downloads', to: '/' }],
203
+ currentPageSelectorId: 'downloads',
160
204
  })
161
205
 
162
- expect(wrapper.findComponent(AppTopBar).props('pageSelector')).toBeUndefined()
163
206
  expect(wrapper.find('.mint-page-selector').exists()).toBe(false)
164
- expect(wrapper.find('.mint-topbar-title-group').text()).toContain('Downloader')
207
+ })
208
+
209
+ it('should render multiple explicit pages ahead of platform pages', () => {
210
+ const wrapper = mountIntegratedPageSelectorWorkspace({
211
+ pageSelector: [
212
+ { id: 'overview', label: 'Overview', to: '/' },
213
+ { id: 'reports', label: 'Reports', to: '/reports' },
214
+ ],
215
+ currentPageSelectorId: 'overview',
216
+ })
217
+
218
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
165
219
  })
166
220
 
167
221
  it('passes compact control models to the sidebar for generated forms', () => {
@@ -407,6 +407,7 @@ describe('useGeneratedAnalysis', () => {
407
407
  uploadUrl: upload?.[0],
408
408
  uploadMethod: upload?.[1]?.method,
409
409
  uploadSessionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Session'),
410
+ uploadDefinitionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Definition'),
410
411
  sessionId: form.get('session_id'),
411
412
  relativePaths: form.getAll('relative_paths'),
412
413
  submittedSource: submit.inputs.source,
@@ -414,6 +415,7 @@ describe('useGeneratedAnalysis', () => {
414
415
  uploadUrl: '/api/demo/jobs/uploads',
415
416
  uploadMethod: 'POST',
416
417
  uploadSessionHeader: 'session-1',
418
+ uploadDefinitionHeader: 'run',
417
419
  sessionId: 'session-1',
418
420
  relativePaths: ['data.csv'],
419
421
  submittedSource: { token: 'upload-token' },
@@ -22,12 +22,37 @@ import { AuthenticationRequiredError } from '../../composables/useApi'
22
22
  import { useAuthStore } from '../../stores/auth'
23
23
 
24
24
  const contract: PluginContract = {
25
- schemaVersion: 1,
25
+ schemaVersion: 2,
26
26
  plugin: {
27
27
  routesPrefix: '/drp',
28
28
  apiPrefix: '/api/drp',
29
29
  type: 'analysis',
30
30
  },
31
+ settings: {
32
+ modelName: 'PluginSettings',
33
+ valueSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ threshold: { type: 'number', default: 0.05 },
37
+ },
38
+ },
39
+ formSchema: {
40
+ groups: [
41
+ {
42
+ id: 'settings',
43
+ label: 'Settings',
44
+ fields: [
45
+ {
46
+ name: 'threshold',
47
+ label: 'Threshold',
48
+ type: 'number',
49
+ defaultValue: 0.05,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ },
55
+ },
31
56
  endpoints: [],
32
57
  hash: 'sha256:test',
33
58
  }
@@ -818,6 +843,16 @@ describe('usePluginClient', () => {
818
843
  })
819
844
  })
820
845
 
846
+ it('should preserve the contract v2 form schema in settingsConfig', () => {
847
+ const settings = usePluginSettings({
848
+ apiBaseUrl: '/api/drp',
849
+ loadOnMount: false,
850
+ schema: contract.settings.formSchema,
851
+ })
852
+
853
+ expect(settings.settingsConfig.value.schema).toBe(contract.settings.formSchema)
854
+ })
855
+
821
856
  it('should use the platform facade for an explicit different plugin name', async () => {
822
857
  ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
823
858
  isIntegrated: true,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { GeneratedJsonSchema } from '../../types/generated-ui'
4
+
5
+ describe('GeneratedJsonSchema', () => {
6
+ it('should accept array size constraints emitted by Pydantic', () => {
7
+ const schema: GeneratedJsonSchema = { maxItems: 100 }
8
+
9
+ expect(schema.maxItems).toBe(100)
10
+ })
11
+
12
+ it('should accept write-only fields emitted by Pydantic', () => {
13
+ const schema: GeneratedJsonSchema = { writeOnly: true }
14
+
15
+ expect(schema.writeOnly).toBe(true)
16
+ })
17
+ })
@@ -116,7 +116,7 @@ function resourceSummary(definition: GeneratedJobDefinition): string {
116
116
  if (definition.lifecycle === 'staged' || definition.profile?.kind === 'staged') {
117
117
  return 'Staged upload'
118
118
  }
119
- return `${definition.cpu} CPU`
119
+ return `Up to ${definition.cpu} CPU`
120
120
  }
121
121
 
122
122
  onMounted(() => {
@@ -34,7 +34,7 @@ export interface PluginWorkspaceViewProps {
34
34
  homePath?: string
35
35
  /** Show the default MINT logo when no icon/logo slot is provided. */
36
36
  showLogo?: boolean
37
- /** Preferred route-level page selector entries. */
37
+ /** Preferred route-level page selector entries. Pass [] to suppress integrated platform fallback. */
38
38
  pageSelector?: PageSelectorItemInput[]
39
39
  /** Active page selector id. Defaults to activeView. */
40
40
  currentPageSelectorId?: string
@@ -208,10 +208,6 @@ const {
208
208
  emitPillSelect: (item) => emit('pill-select', item),
209
209
  })
210
210
 
211
- const visiblePageSelector = computed(() =>
212
- (props.pageSelector?.length ?? 0) > 1 ? props.pageSelector : undefined,
213
- )
214
-
215
211
  const hasSidebarSurface = computed(() => {
216
212
  return hasPluginWorkspaceSidebarSurface({
217
213
  showSidebar: props.showSidebar,
@@ -274,7 +270,7 @@ function handleFormCancel(sectionId: string) {
274
270
  :variant="topBarVariant"
275
271
  :home-path="homePath"
276
272
  :show-logo="showLogo"
277
- :page-selector="visiblePageSelector"
273
+ :page-selector="pageSelector"
278
274
  :current-page-selector-id="resolvedCurrentPageSelectorId"
279
275
  :plugin-switcher="pluginSwitcher"
280
276
  :pill-nav="resolvedPillNav"
@@ -481,6 +481,7 @@ export {
481
481
  type DownloadBlobOptions,
482
482
  type DownloadPluginEndpointOptions,
483
483
  type PluginContract,
484
+ type PluginContractSettings,
484
485
  type PluginClientRequestOptions,
485
486
  type PluginEndpointContract,
486
487
  type PluginEndpointDefinition,
@@ -75,7 +75,10 @@ export interface UsePluginJobsReturn extends PluginJobCenterSource {
75
75
  status: ShallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>
76
76
  initialize: () => Promise<void>
77
77
  ready: () => Promise<void>
78
- uploadPath: (input: PluginJobPathInput) => Promise<PluginJobPathUpload>
78
+ uploadPath: (
79
+ input: PluginJobPathInput,
80
+ definitionId?: string,
81
+ ) => Promise<PluginJobPathUpload>
79
82
  uploadStagedPart: <Metadata>(
80
83
  input: PluginJobStagedPartInput<Metadata>,
81
84
  ) => Promise<PluginJobStagedPartUploadResult<Metadata>>
@@ -279,7 +282,7 @@ function createPluginJobs(
279
282
  jobInput = await submitToSession(
280
283
  session.value.id,
281
284
  clientRequestId,
282
- await stagePathInputs(inputs, session.value.id),
285
+ await stagePathInputs(inputs, session.value.id, definitionId),
283
286
  )
284
287
  } catch (cause) {
285
288
  if (!isSessionNotFoundError(cause)) throw cause
@@ -287,7 +290,7 @@ function createPluginJobs(
287
290
  jobInput = await submitToSession(
288
291
  session.value.id,
289
292
  clientRequestId,
290
- await stagePathInputs(inputs, session.value.id),
293
+ await stagePathInputs(inputs, session.value.id, definitionId),
291
294
  )
292
295
  }
293
296
  const job = upsertJob(jobInput, definitionId)
@@ -317,9 +320,12 @@ function createPluginJobs(
317
320
  }
318
321
  }
319
322
 
320
- async function uploadPath(input: PluginJobPathInput): Promise<PluginJobPathUpload> {
323
+ async function uploadPath(
324
+ input: PluginJobPathInput,
325
+ definitionId?: string,
326
+ ): Promise<PluginJobPathUpload> {
321
327
  if (!session.value) throw new Error('Generated analysis session is not initialized')
322
- return uploadPathForSession(input, session.value.id)
328
+ return uploadPathForSession(input, session.value.id, definitionId)
323
329
  }
324
330
 
325
331
  async function uploadStagedPart<Metadata>(
@@ -375,6 +381,7 @@ function createPluginJobs(
375
381
  async function uploadPathForSession(
376
382
  input: PluginJobPathInput,
377
383
  sessionId: string,
384
+ definitionId?: string,
378
385
  ): Promise<PluginJobPathUpload> {
379
386
  if (!input.files.length || input.files.length !== input.relativePaths.length) {
380
387
  throw new Error('Path upload requires one relative path for each file')
@@ -386,40 +393,51 @@ function createPluginJobs(
386
393
  body.append('files', input.files[index]!)
387
394
  body.append('relative_paths', input.relativePaths[index]!)
388
395
  }
396
+ const headers: Record<string, string> = {
397
+ 'X-Mint-Job-Session': sessionId,
398
+ }
399
+ if (definitionId) headers['X-Mint-Job-Definition'] = definitionId
389
400
  return request<PluginJobPathUpload>('/jobs/uploads', {
390
401
  method: 'POST',
391
402
  body,
392
403
  signal: input.signal,
393
- headers: { 'X-Mint-Job-Session': sessionId },
404
+ headers,
394
405
  })
395
406
  }
396
407
 
397
408
  async function stagePathInputs(
398
409
  inputs: Record<string, unknown>,
399
410
  sessionId: string,
411
+ definitionId: string,
400
412
  ): Promise<Record<string, unknown>> {
401
- return await stagePathValue(inputs, sessionId) as Record<string, unknown>
413
+ return await stagePathValue(inputs, sessionId, definitionId) as Record<string, unknown>
402
414
  }
403
415
 
404
- async function stagePathValue(value: unknown, sessionId: string): Promise<unknown> {
416
+ async function stagePathValue(
417
+ value: unknown,
418
+ sessionId: string,
419
+ definitionId: string,
420
+ ): Promise<unknown> {
405
421
  if (typeof File !== 'undefined' && value instanceof File) {
406
422
  const upload = await uploadPathForSession({
407
423
  kind: 'path',
408
424
  files: [value],
409
425
  relativePaths: [value.name],
410
- }, sessionId)
426
+ }, sessionId, definitionId)
411
427
  return { token: upload.token }
412
428
  }
413
429
  if (isPluginJobPathInput(value)) {
414
- const upload = await uploadPathForSession(value, sessionId)
430
+ const upload = await uploadPathForSession(value, sessionId, definitionId)
415
431
  return { token: upload.token }
416
432
  }
417
433
  if (Array.isArray(value)) {
418
- return await Promise.all(value.map(item => stagePathValue(item, sessionId)))
434
+ return await Promise.all(value.map(
435
+ item => stagePathValue(item, sessionId, definitionId),
436
+ ))
419
437
  }
420
438
  if (value && typeof value === 'object') {
421
439
  const entries = await Promise.all(Object.entries(value).map(async ([key, child]) => (
422
- [key, await stagePathValue(child, sessionId)] as const
440
+ [key, await stagePathValue(child, sessionId, definitionId)] as const
423
441
  )))
424
442
  return Object.fromEntries(entries)
425
443
  }
@@ -45,6 +45,7 @@ export type PluginHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
45
45
 
46
46
  export interface PluginEndpointContract {
47
47
  name: string
48
+ operationId?: string
48
49
  method: PluginHttpMethod
49
50
  path: string
50
51
  requiresAuth?: boolean
@@ -75,8 +76,16 @@ export interface PluginNavItemContract {
75
76
  requiresFeature?: string
76
77
  }
77
78
 
79
+ export interface PluginContractSettings {
80
+ modelName: string | null
81
+ /** Pydantic JSON Schema used to generate typed settings values. */
82
+ valueSchema: Record<string, unknown> | null
83
+ /** SDK SettingsModal schema used to render the settings form. */
84
+ formSchema: SettingsModalSchema | null
85
+ }
86
+
78
87
  export interface PluginContract {
79
- schemaVersion: number
88
+ schemaVersion: 2
80
89
  plugin: {
81
90
  name?: string
82
91
  packageName?: string
@@ -92,11 +101,9 @@ export interface PluginContract {
92
101
  analysisResultReaders?: string[]
93
102
  capabilities?: Record<string, unknown>
94
103
  }
95
- settings?: {
96
- modelName?: string | null
97
- schema?: SettingsModalSchema | null
98
- }
104
+ settings: PluginContractSettings
99
105
  endpoints: PluginEndpointContract[]
106
+ schemas?: Record<string, Record<string, unknown>>
100
107
  jobs?: GeneratedJobDefinition[]
101
108
  hash: string
102
109
  }
@@ -12,6 +12,8 @@ export interface GeneratedJsonSchema {
12
12
  maximum?: number
13
13
  minLength?: number
14
14
  maxLength?: number
15
+ maxItems?: number
16
+ writeOnly?: boolean
15
17
  properties?: Record<string, GeneratedJsonSchema>
16
18
  additionalProperties?: boolean | GeneratedJsonSchema
17
19
  required?: string[]
@@ -102,6 +104,8 @@ export interface GeneratedJobDefinition {
102
104
  title: string
103
105
  description: string
104
106
  cpu: number
107
+ /** Whether integrated submission requires a platform or current-plugin administrator. */
108
+ requires_admin?: boolean
105
109
  /** Defaults to process for manifests generated before lifecycle profiles. */
106
110
  lifecycle?: GeneratedJobLifecycle
107
111
  /** Defaults to `{ kind: 'process' }` for older manifests. */