@morscherlab/mint-sdk 1.1.0-beta.4 → 1.1.0-beta.6

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 (70) hide show
  1. package/README.md +18 -4
  2. package/dist/{EmptyState-D1c4RdC7.js → EmptyState-DgBRoYAy.js} +22 -13
  3. package/dist/EmptyState-DgBRoYAy.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js → ExperimentSelectorModal-BBWXC7KW.js} +3 -3
  5. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js.map → ExperimentSelectorModal-BBWXC7KW.js.map} +1 -1
  6. package/dist/{SettingsModal-DPDwCbXK.js → SettingsModal-Dpg-X1OM.js} +3 -3
  7. package/dist/{SettingsModal-DPDwCbXK.js.map → SettingsModal-Dpg-X1OM.js.map} +1 -1
  8. package/dist/__tests__/components/BaseToggle.test.d.ts +1 -0
  9. package/dist/__tests__/composables/analysisArtifactTarget.test.d.ts +1 -0
  10. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -1
  11. package/dist/components/BaseSelect.vue.d.ts +1 -1
  12. package/dist/components/BaseToggle.vue.d.ts +2 -0
  13. package/dist/components/FitPanel.vue.d.ts +1 -1
  14. package/dist/components/PluginWorkspaceView.props.d.ts +1 -1
  15. package/dist/components/index.js +5 -5
  16. package/dist/{components-DFH5whuh.js → components-Ci_BTx00.js} +13 -14
  17. package/dist/components-Ci_BTx00.js.map +1 -0
  18. package/dist/composables/analysisArtifactTarget.d.ts +5 -0
  19. package/dist/composables/index.d.ts +3 -1
  20. package/dist/composables/index.js +8 -8
  21. package/dist/composables/platformContextHelpers.d.ts +2 -0
  22. package/dist/composables/useAnalysisArtifacts.d.ts +3 -1
  23. package/dist/composables/useGeneratedAnalysis.d.ts +1 -1
  24. package/dist/composables/usePluginClient.d.ts +11 -5
  25. package/dist/{composables-B512bWJP.js → composables-C0VEJ73O.js} +40 -5
  26. package/dist/composables-C0VEJ73O.js.map +1 -0
  27. package/dist/index.js +11 -11
  28. package/dist/install.js +5 -5
  29. package/dist/styles.css +11 -11
  30. package/dist/templates/index.js +3 -3
  31. package/dist/{templates-Cttbjbyc.js → templates-Cdj93S9C.js} +2 -2
  32. package/dist/{templates-Cttbjbyc.js.map → templates-Cdj93S9C.js.map} +1 -1
  33. package/dist/types/analysisArtifactTypes.d.ts +8 -0
  34. package/dist/types/generated-ui.d.ts +2 -0
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/{useControlSchema-Dallso-L.js → useControlSchema-B_qgU4rW.js} +48 -19
  37. package/dist/useControlSchema-B_qgU4rW.js.map +1 -0
  38. package/dist/{useExperimentSelector-BMjzNcf2.js → useExperimentSelector-BuQZRgMt.js} +2 -2
  39. package/dist/{useExperimentSelector-BMjzNcf2.js.map → useExperimentSelector-BuQZRgMt.js.map} +1 -1
  40. package/dist/{useFormBuilder-BXaaDI41.js → useFormBuilder-D1G5J5Z5.js} +2 -2
  41. package/dist/{useFormBuilder-BXaaDI41.js.map → useFormBuilder-D1G5J5Z5.js.map} +1 -1
  42. package/dist/{useProtocolTemplates-BP7_4RGQ.js → useProtocolTemplates-DeaOmMaw.js} +4 -4
  43. package/dist/{useProtocolTemplates-BP7_4RGQ.js.map → useProtocolTemplates-DeaOmMaw.js.map} +1 -1
  44. package/package.json +1 -1
  45. package/src/__tests__/components/BaseSelect.test.ts +52 -0
  46. package/src/__tests__/components/BaseToggle.test.ts +40 -0
  47. package/src/__tests__/components/GeneratedPluginView.test.ts +10 -0
  48. package/src/__tests__/components/PluginWorkspaceView.test.ts +68 -14
  49. package/src/__tests__/composables/analysisArtifactTarget.test.ts +77 -0
  50. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +74 -1
  51. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +2 -0
  52. package/src/__tests__/composables/usePluginClient.test.ts +36 -1
  53. package/src/components/BaseSelect.vue +16 -2
  54. package/src/components/BaseToggle.vue +3 -1
  55. package/src/components/GeneratedPluginView.vue +1 -1
  56. package/src/components/PluginWorkspaceView.props.ts +1 -1
  57. package/src/components/PluginWorkspaceView.vue +1 -5
  58. package/src/composables/analysisArtifactTarget.ts +53 -0
  59. package/src/composables/index.ts +6 -0
  60. package/src/composables/platformContextHelpers.ts +10 -3
  61. package/src/composables/useAnalysisArtifacts.ts +48 -0
  62. package/src/composables/useGeneratedAnalysis.ts +30 -12
  63. package/src/composables/usePluginClient.ts +12 -5
  64. package/src/types/analysisArtifactTypes.ts +9 -0
  65. package/src/types/generated-ui.ts +2 -0
  66. package/src/types/index.ts +1 -0
  67. package/dist/EmptyState-D1c4RdC7.js.map +0 -1
  68. package/dist/components-DFH5whuh.js.map +0 -1
  69. package/dist/composables-B512bWJP.js.map +0 -1
  70. package/dist/useControlSchema-Dallso-L.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.4",
3
+ "version": "1.1.0-beta.6",
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",
@@ -3,6 +3,58 @@ import { describe, expect, it } from 'vitest'
3
3
  import BaseSelect from '../../components/BaseSelect.vue'
4
4
 
5
5
  describe('BaseSelect', () => {
6
+ it('forwards native attributes to the select instead of the wrapper', () => {
7
+ const wrapper = mount(BaseSelect, {
8
+ props: {
9
+ options: ['linear'],
10
+ },
11
+ attrs: {
12
+ id: 'curve-model',
13
+ name: 'curve-model',
14
+ 'aria-label': 'Curve model',
15
+ 'data-testid': 'curve-model-select',
16
+ class: 'curve-model-layout',
17
+ style: 'width: 12rem;',
18
+ },
19
+ })
20
+
21
+ const select = wrapper.get('select')
22
+
23
+ expect({
24
+ select: {
25
+ id: select.attributes('id'),
26
+ name: select.attributes('name'),
27
+ ariaLabel: select.attributes('aria-label'),
28
+ testId: select.attributes('data-testid'),
29
+ layoutClass: select.classes().includes('curve-model-layout'),
30
+ },
31
+ wrapper: {
32
+ id: wrapper.attributes('id'),
33
+ name: wrapper.attributes('name'),
34
+ ariaLabel: wrapper.attributes('aria-label'),
35
+ testId: wrapper.attributes('data-testid'),
36
+ layoutClass: wrapper.classes().includes('curve-model-layout'),
37
+ style: wrapper.attributes('style'),
38
+ },
39
+ }).toEqual({
40
+ select: {
41
+ id: 'curve-model',
42
+ name: 'curve-model',
43
+ ariaLabel: 'Curve model',
44
+ testId: 'curve-model-select',
45
+ layoutClass: false,
46
+ },
47
+ wrapper: {
48
+ id: undefined,
49
+ name: undefined,
50
+ ariaLabel: undefined,
51
+ testId: undefined,
52
+ layoutClass: true,
53
+ style: 'width: 12rem;',
54
+ },
55
+ })
56
+ })
57
+
6
58
  it('accepts string and number shorthand options', async () => {
7
59
  const wrapper = mount(BaseSelect, {
8
60
  props: {
@@ -0,0 +1,40 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import BaseToggle from '../../components/BaseToggle.vue'
4
+
5
+ describe('BaseToggle', () => {
6
+ it('uses ariaLabel for the switch name without changing the visual label', () => {
7
+ const wrapper = mount(BaseToggle, {
8
+ props: {
9
+ label: 'Enabled',
10
+ ariaLabel: 'Email notifications',
11
+ },
12
+ })
13
+
14
+ expect({
15
+ accessibleName: wrapper.get('[role="switch"]').attributes('aria-label'),
16
+ visualText: wrapper.text(),
17
+ wrapperAriaLabel: wrapper.attributes('aria-label'),
18
+ }).toEqual({
19
+ accessibleName: 'Email notifications',
20
+ visualText: 'Enabled',
21
+ wrapperAriaLabel: undefined,
22
+ })
23
+ })
24
+
25
+ it('keeps using the visual label as the default switch name', () => {
26
+ const wrapper = mount(BaseToggle, {
27
+ props: {
28
+ label: 'Enabled',
29
+ },
30
+ })
31
+
32
+ expect(wrapper.get('[role="switch"]').attributes('aria-label')).toBe('Enabled')
33
+ })
34
+
35
+ it('keeps the existing fallback name when no labels are provided', () => {
36
+ const wrapper = mount(BaseToggle)
37
+
38
+ expect(wrapper.get('[role="switch"]').attributes('aria-label')).toBe('Toggle')
39
+ })
40
+ })
@@ -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', () => {
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import {
4
+ buildAnalysisArtifactTargetQuery,
5
+ parseAnalysisArtifactTarget,
6
+ } from '../../composables/analysisArtifactTarget'
7
+ import {
8
+ currentExperimentIdFromUrl,
9
+ resolveCurrentPluginId,
10
+ } from '../../composables/platformContextHelpers'
11
+
12
+ describe('analysis artifact target', () => {
13
+ afterEach(() => {
14
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ it('should resolve the canonical injected plugin id', () => {
19
+ ;(window as unknown as { __MINT_PLATFORM__: unknown }).__MINT_PLATFORM__ = {
20
+ isIntegrated: true,
21
+ plugin: { id: 'LEAF', name: 'Leaf UI' },
22
+ theme: 'system',
23
+ }
24
+
25
+ expect(resolveCurrentPluginId()).toBe('LEAF')
26
+ })
27
+
28
+ it('should tolerate a host that provides a partial location object', () => {
29
+ vi.stubGlobal('window', {
30
+ location: { search: '' },
31
+ })
32
+
33
+ expect(currentExperimentIdFromUrl()).toBeUndefined()
34
+ })
35
+
36
+ it('should build the canonical readable query when an artifact id hint is present', () => {
37
+ const query = buildAnalysisArtifactTargetQuery({
38
+ experimentId: 66,
39
+ artifactPluginId: 'LEAF',
40
+ artifactKey: 'exp316_260703_ys_rna_ide_scr',
41
+ artifactId: 206,
42
+ })
43
+
44
+ expect(query.toString()).toBe(
45
+ 'experiment_id=66&artifact_plugin_id=LEAF'
46
+ + '&artifact_key=exp316_260703_ys_rna_ide_scr&artifact_id=206',
47
+ )
48
+ })
49
+
50
+ it('should parse the canonical identity without requiring an artifact id hint', () => {
51
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
52
+ 'experiment_id=66&artifact_plugin_id=LEAF&artifact_key=result',
53
+ ))
54
+
55
+ expect(target).toEqual({
56
+ experimentId: 66,
57
+ artifactPluginId: 'LEAF',
58
+ artifactKey: 'result',
59
+ })
60
+ })
61
+
62
+ it('should reject an incomplete target instead of guessing another artifact', () => {
63
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
64
+ 'experiment_id=66&artifact_key=result',
65
+ ))
66
+
67
+ expect(target).toBeNull()
68
+ })
69
+
70
+ it('should reject non-positive numeric identifiers', () => {
71
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
72
+ 'experiment_id=0&artifact_plugin_id=LEAF&artifact_key=result&artifact_id=-1',
73
+ ))
74
+
75
+ expect(target).toBeNull()
76
+ })
77
+ })
@@ -19,7 +19,10 @@ vi.mock('../../composables/useApi', () => ({
19
19
  }))
20
20
 
21
21
  import { useAnalysisArtifacts } from '../../composables/useAnalysisArtifacts'
22
- import type { AnalysisArtifactSummary } from '../../types/analysisArtifactTypes'
22
+ import type {
23
+ AnalysisArtifactDetail,
24
+ AnalysisArtifactSummary,
25
+ } from '../../types/analysisArtifactTypes'
23
26
 
24
27
  // ---------------------------------------------------------------------------
25
28
  // Fixtures
@@ -177,6 +180,76 @@ describe('useAnalysisArtifacts', () => {
177
180
  })
178
181
  })
179
182
 
183
+ describe('resolveTarget', () => {
184
+ it('should resolve the exact stable identity when the numeric hint is stale', async () => {
185
+ const detail = {
186
+ ...makeArtifact({
187
+ id: 2,
188
+ experiment_id: 42,
189
+ plugin_id: 'plugin-b',
190
+ artifact_key: 'peaks',
191
+ }),
192
+ result: { schema_version: 'mint.analysis_file.v1' },
193
+ tree: [],
194
+ summary: null,
195
+ } satisfies AnalysisArtifactDetail
196
+ mockGet
197
+ .mockResolvedValueOnce({ artifacts: ARTIFACTS })
198
+ .mockResolvedValueOnce(detail)
199
+ const { resolveTarget } = setup()
200
+
201
+ const resolved = await resolveTarget({
202
+ experimentId: 42,
203
+ artifactPluginId: 'plugin-b',
204
+ artifactKey: 'peaks',
205
+ artifactId: 999,
206
+ })
207
+
208
+ expect({
209
+ resolvedId: resolved?.id,
210
+ detailPath: mockGet.mock.calls[1]?.[0],
211
+ }).toEqual({
212
+ resolvedId: 2,
213
+ detailPath: '/experiments/42/analysis-artifacts/2',
214
+ })
215
+ })
216
+
217
+ it('should fail explicitly when the stable identity is not available', async () => {
218
+ const { resolveTarget, error } = setup()
219
+
220
+ const resolved = await resolveTarget({
221
+ experimentId: 42,
222
+ artifactPluginId: 'LEAF',
223
+ artifactKey: 'missing',
224
+ })
225
+
226
+ expect({ resolved, error: error.value }).toEqual({
227
+ resolved: null,
228
+ error: 'Analysis artifact LEAF/missing is not available.',
229
+ })
230
+ })
231
+
232
+ it('should reject a target from another experiment before making a request', async () => {
233
+ const { resolveTarget, error } = setup()
234
+
235
+ const resolved = await resolveTarget({
236
+ experimentId: 66,
237
+ artifactPluginId: 'LEAF',
238
+ artifactKey: 'result',
239
+ })
240
+
241
+ expect({
242
+ resolved,
243
+ error: error.value,
244
+ requests: mockGet.mock.calls,
245
+ }).toEqual({
246
+ resolved: null,
247
+ error: 'Analysis artifact target belongs to experiment 66, not 42.',
248
+ requests: [],
249
+ })
250
+ })
251
+ })
252
+
180
253
  // -------------------------------------------------------------------------
181
254
  // experiment id resolution
182
255
  // -------------------------------------------------------------------------
@@ -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,
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  /** Renders a native `<select>` element with size, error, and disabled states. */
3
- import { computed } from 'vue'
3
+ import { computed, useAttrs } from 'vue'
4
+ import type { HTMLAttributes } from 'vue'
4
5
  import type { SelectOption, SelectOptionInput } from '../types'
5
6
  import { findOptionByDomValue, normalizeOptionInput } from '../utils/options'
6
7
 
8
+ defineOptions({ inheritAttrs: false })
9
+
7
10
  interface Props {
8
11
  modelValue?: string | number
9
12
  options: SelectOptionInput<string | number>[]
@@ -19,6 +22,16 @@ const props = withDefaults(defineProps<Props>(), {
19
22
  error: false,
20
23
  size: 'md',
21
24
  })
25
+ const attrs = useAttrs()
26
+
27
+ const controlAttrs = computed(() => {
28
+ const { class: _class, style: _style, ...nativeAttrs } = attrs
29
+ return nativeAttrs
30
+ })
31
+ const wrapperAttrs = computed<Pick<HTMLAttributes, 'class' | 'style'>>(() => ({
32
+ class: attrs.class as HTMLAttributes['class'],
33
+ style: attrs.style as HTMLAttributes['style'],
34
+ }))
22
35
 
23
36
  const emit = defineEmits<{
24
37
  'update:modelValue': [value: string | number]
@@ -36,8 +49,9 @@ function handleChange(event: Event) {
36
49
  </script>
37
50
 
38
51
  <template>
39
- <div class="mint-select">
52
+ <div v-bind="wrapperAttrs" class="mint-select">
40
53
  <select
54
+ v-bind="controlAttrs"
41
55
  :value="modelValue"
42
56
  :disabled="disabled"
43
57
  :aria-invalid="error || undefined"
@@ -5,6 +5,8 @@ import Tooltip from './Tooltip.vue'
5
5
  interface Props {
6
6
  modelValue?: boolean
7
7
  label?: string
8
+ /** Accessible switch name; defaults to the visual label, then "Toggle". */
9
+ ariaLabel?: string
8
10
  description?: string
9
11
  icon?: string | string[]
10
12
  iconColor?: string
@@ -111,7 +113,7 @@ function iconStyle(): Record<string, string> {
111
113
  role="switch"
112
114
  :tabindex="disabled ? -1 : 0"
113
115
  :aria-checked="modelValue"
114
- :aria-label="label || 'Toggle'"
116
+ :aria-label="ariaLabel || label || 'Toggle'"
115
117
  :class="[
116
118
  'mint-toggle__track',
117
119
  `mint-toggle__track--${size}`,
@@ -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"
@@ -0,0 +1,53 @@
1
+ import type { AnalysisArtifactTarget } from '../types/analysisArtifactTypes'
2
+
3
+ const EXPERIMENT_ID = 'experiment_id'
4
+ const ARTIFACT_PLUGIN_ID = 'artifact_plugin_id'
5
+ const ARTIFACT_KEY = 'artifact_key'
6
+ const ARTIFACT_ID = 'artifact_id'
7
+
8
+ function parsePositiveInteger(value: string | null): number | undefined {
9
+ if (value === null || !/^\d+$/.test(value)) return undefined
10
+ const parsed = Number(value)
11
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined
12
+ }
13
+
14
+ /** Build the canonical query used to open one analysis artifact in a plugin. */
15
+ export function buildAnalysisArtifactTargetQuery(
16
+ target: AnalysisArtifactTarget,
17
+ ): URLSearchParams {
18
+ const query = new URLSearchParams()
19
+ query.set(EXPERIMENT_ID, String(target.experimentId))
20
+ query.set(ARTIFACT_PLUGIN_ID, target.artifactPluginId)
21
+ query.set(ARTIFACT_KEY, target.artifactKey)
22
+ if (target.artifactId !== undefined) {
23
+ query.set(ARTIFACT_ID, String(target.artifactId))
24
+ }
25
+ return query
26
+ }
27
+
28
+ /** Parse a complete canonical target. Incomplete or invalid input is not guessed. */
29
+ export function parseAnalysisArtifactTarget(
30
+ query: URLSearchParams,
31
+ ): AnalysisArtifactTarget | null {
32
+ const experimentId = parsePositiveInteger(query.get(EXPERIMENT_ID))
33
+ const artifactPluginId = query.get(ARTIFACT_PLUGIN_ID)?.trim()
34
+ const artifactKey = query.get(ARTIFACT_KEY)?.trim()
35
+ const rawArtifactId = query.get(ARTIFACT_ID)
36
+ const artifactId = parsePositiveInteger(rawArtifactId)
37
+
38
+ if (
39
+ experimentId === undefined
40
+ || !artifactPluginId
41
+ || !artifactKey
42
+ || (rawArtifactId !== null && artifactId === undefined)
43
+ ) {
44
+ return null
45
+ }
46
+
47
+ return {
48
+ experimentId,
49
+ artifactPluginId,
50
+ artifactKey,
51
+ ...(artifactId === undefined ? {} : { artifactId }),
52
+ }
53
+ }
@@ -159,6 +159,11 @@ export {
159
159
  type UseAnalysisArtifactsOptions,
160
160
  type UseAnalysisArtifactsReturn,
161
161
  } from './useAnalysisArtifacts'
162
+ export {
163
+ buildAnalysisArtifactTargetQuery,
164
+ parseAnalysisArtifactTarget,
165
+ } from './analysisArtifactTarget'
166
+ export { resolveCurrentPluginId } from './platformContextHelpers'
162
167
  export {
163
168
  formatExperimentDate,
164
169
  formatExperimentStatus,
@@ -476,6 +481,7 @@ export {
476
481
  type DownloadBlobOptions,
477
482
  type DownloadPluginEndpointOptions,
478
483
  type PluginContract,
484
+ type PluginContractSettings,
479
485
  type PluginClientRequestOptions,
480
486
  type PluginEndpointContract,
481
487
  type PluginEndpointDefinition,