@morscherlab/mint-sdk 1.0.0-rc.1 → 1.0.0-rc.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 (143) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
  3. package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
  4. package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
  5. package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
  7. package/dist/__tests__/utils/instrument.test.d.ts +1 -0
  8. package/dist/__tests__/utils/lcms.test.d.ts +1 -0
  9. package/dist/__tests__/utils/permissions.test.d.ts +1 -0
  10. package/dist/__tests__/utils/rack.test.d.ts +1 -0
  11. package/dist/{auth-CBG3bWEc.js → auth-B7g4J4ZF.js} +99 -5
  12. package/dist/auth-B7g4J4ZF.js.map +1 -0
  13. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  14. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  15. package/dist/components/BaseToggle.vue.d.ts +2 -2
  16. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  17. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
  18. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
  19. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
  20. package/dist/components/FormulaInput.vue.d.ts +1 -1
  21. package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
  22. package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
  23. package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
  24. package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
  25. package/dist/components/ProgressBar.vue.d.ts +1 -0
  26. package/dist/components/RackEditor.vue.d.ts +41 -3
  27. package/dist/components/ReagentList.vue.d.ts +1 -1
  28. package/dist/components/SampleSelector.vue.d.ts +5 -2
  29. package/dist/components/SegmentedControl.vue.d.ts +2 -0
  30. package/dist/components/SequenceInput.vue.d.ts +1 -1
  31. package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
  32. package/dist/components/SettingsModal.vue.d.ts +3 -1
  33. package/dist/components/TagsInput.vue.d.ts +1 -1
  34. package/dist/components/WellPlate.vue.d.ts +42 -3
  35. package/dist/components/index.d.ts +5 -0
  36. package/dist/components/index.js +3 -3
  37. package/dist/{components-5KSfsVqf.js → components-BhK-dW99.js} +2091 -1051
  38. package/dist/components-BhK-dW99.js.map +1 -0
  39. package/dist/composables/experimentDesignData.d.ts +17 -0
  40. package/dist/composables/index.d.ts +2 -0
  41. package/dist/composables/index.js +4 -4
  42. package/dist/composables/useControlSchema.d.ts +11 -0
  43. package/dist/composables/useExperimentData.d.ts +11 -3
  44. package/dist/composables/useExperimentSamples.d.ts +42 -0
  45. package/dist/composables/usePlatformContext.d.ts +54 -0
  46. package/dist/{composables-D4Myb30a.js → composables-Bg7CFuNz.js} +5 -3
  47. package/dist/composables-Bg7CFuNz.js.map +1 -0
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.js +168 -6
  50. package/dist/index.js.map +1 -0
  51. package/dist/install.js +2 -2
  52. package/dist/instrument.d.ts +7 -0
  53. package/dist/lcms.d.ts +27 -0
  54. package/dist/permissions.d.ts +46 -0
  55. package/dist/stores/auth.d.ts +74 -2
  56. package/dist/stores/index.js +1 -1
  57. package/dist/styles.css +3316 -1216
  58. package/dist/templates/builders.d.ts +7 -3
  59. package/dist/templates/index.d.ts +2 -2
  60. package/dist/templates/index.js +2 -2
  61. package/dist/templates/presets.d.ts +12 -0
  62. package/dist/templates/types.d.ts +16 -1
  63. package/dist/{templates-BSlxwV2c.js → templates-BorLR_7p.js} +313 -3
  64. package/dist/templates-BorLR_7p.js.map +1 -0
  65. package/dist/types/auth.d.ts +2 -0
  66. package/dist/types/components.d.ts +32 -3
  67. package/dist/types/form-builder.d.ts +2 -1
  68. package/dist/types/index.d.ts +4 -1
  69. package/dist/types/instrument.d.ts +56 -0
  70. package/dist/types/platform.d.ts +3 -0
  71. package/dist/{useExperimentData-BbbdI5xT.js → useProtocolTemplates-n6AJqSqv.js} +534 -359
  72. package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
  73. package/dist/utils/rack.d.ts +47 -0
  74. package/package.json +1 -1
  75. package/src/__tests__/components/AppTopBar.test.ts +15 -0
  76. package/src/__tests__/components/BaseTabs.test.ts +15 -0
  77. package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
  78. package/src/__tests__/components/ProgressBar.test.ts +18 -0
  79. package/src/__tests__/components/RackEditor.test.ts +125 -0
  80. package/src/__tests__/components/SampleSelector.test.ts +25 -0
  81. package/src/__tests__/components/SegmentedControl.test.ts +45 -0
  82. package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
  83. package/src/__tests__/components/SettingsModal.test.ts +83 -2
  84. package/src/__tests__/composables/useControlSchema.test.ts +4 -0
  85. package/src/__tests__/composables/useExperimentData.test.ts +23 -0
  86. package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
  87. package/src/__tests__/templates/templates.test.ts +86 -0
  88. package/src/__tests__/utils/instrument.test.ts +47 -0
  89. package/src/__tests__/utils/lcms.test.ts +73 -0
  90. package/src/__tests__/utils/permissions.test.ts +50 -0
  91. package/src/__tests__/utils/rack.test.ts +120 -0
  92. package/src/components/AppTopBar.vue +1 -0
  93. package/src/components/BaseTabs.vue +22 -1
  94. package/src/components/InstrumentAlertLog.vue +191 -0
  95. package/src/components/InstrumentStateBadge.vue +50 -0
  96. package/src/components/InstrumentStatusCard.vue +188 -0
  97. package/src/components/LcmsSequenceTable.vue +191 -0
  98. package/src/components/ProgressBar.vue +3 -0
  99. package/src/components/RackEditor.vue +73 -2
  100. package/src/components/SampleSelector.vue +28 -9
  101. package/src/components/SegmentedControl.story.vue +17 -0
  102. package/src/components/SegmentedControl.vue +14 -3
  103. package/src/components/SequenceProgressBar.vue +71 -0
  104. package/src/components/SettingsModal.vue +42 -2
  105. package/src/components/WellPlate.vue +142 -21
  106. package/src/components/index.ts +5 -0
  107. package/src/components/internal/WellEditPopupInternal.vue +1 -0
  108. package/src/composables/experimentDesignData.ts +182 -0
  109. package/src/composables/index.ts +14 -0
  110. package/src/composables/useAuth.ts +4 -0
  111. package/src/composables/useAutoGroup.ts +5 -1
  112. package/src/composables/useControlSchema.ts +21 -0
  113. package/src/composables/useExperimentData.ts +57 -16
  114. package/src/composables/useExperimentSamples.ts +142 -0
  115. package/src/index.ts +27 -0
  116. package/src/instrument.ts +90 -0
  117. package/src/lcms.ts +108 -0
  118. package/src/permissions.ts +143 -0
  119. package/src/stores/auth.ts +31 -3
  120. package/src/styles/components/instrument-monitor.css +478 -0
  121. package/src/styles/components/lcms-sequence-table.css +189 -0
  122. package/src/styles/components/sequence-progress-bar.css +63 -0
  123. package/src/styles/components/tabs.css +9 -0
  124. package/src/styles/components/well-edit-popup.css +7 -1
  125. package/src/styles/components/well-plate.css +5 -0
  126. package/src/styles/index.css +3 -0
  127. package/src/templates/builders.ts +201 -0
  128. package/src/templates/controlSchemas.ts +68 -0
  129. package/src/templates/index.ts +2 -0
  130. package/src/templates/presets.ts +23 -0
  131. package/src/templates/types.ts +17 -0
  132. package/src/types/auth.ts +3 -0
  133. package/src/types/components.ts +45 -3
  134. package/src/types/form-builder.ts +2 -1
  135. package/src/types/index.ts +35 -0
  136. package/src/types/instrument.ts +61 -0
  137. package/src/types/platform.ts +4 -0
  138. package/src/utils/rack.ts +209 -0
  139. package/dist/auth-CBG3bWEc.js.map +0 -1
  140. package/dist/components-5KSfsVqf.js.map +0 -1
  141. package/dist/composables-D4Myb30a.js.map +0 -1
  142. package/dist/templates-BSlxwV2c.js.map +0 -1
  143. package/dist/useExperimentData-BbbdI5xT.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ import { Rack, SlotPosition, WellEditData, WellPlateFormat } from '../types';
2
+ export type LcmsPlateType = '96well' | '54vial';
3
+ export type LcmsControlSampleType = 'blank' | 'qc' | 'iqc';
4
+ export interface LcmsPlateCell {
5
+ row: string;
6
+ column: number;
7
+ sample_name: string;
8
+ sample_type?: string | null;
9
+ injection_volume?: number | null;
10
+ injection_count?: number | null;
11
+ custom_method?: string | null;
12
+ slot?: SlotPosition | null;
13
+ }
14
+ export interface LcmsPlateCellsToRackOptions {
15
+ rackId?: string;
16
+ rackName?: string;
17
+ format?: WellPlateFormat;
18
+ plateType?: LcmsPlateType;
19
+ slot?: SlotPosition;
20
+ injectionVolume?: number;
21
+ }
22
+ export interface LcmsPlateCellsToRacksOptions extends Omit<LcmsPlateCellsToRackOptions, 'rackId' | 'rackName' | 'slot'> {
23
+ defaultSlot?: SlotPosition;
24
+ rackId?: (slot: SlotPosition, index: number) => string;
25
+ rackName?: (slot: SlotPosition, index: number) => string;
26
+ }
27
+ export declare const LCMS_DEFAULT_CONTROL_POSITIONS: Record<LcmsPlateType, Record<LcmsControlSampleType, {
28
+ row: string;
29
+ column: number;
30
+ }>>;
31
+ export declare function lcmsPlateTypeToRackFormat(plateType: LcmsPlateType): WellPlateFormat;
32
+ export declare function rackFormatToLcmsPlateType(format: WellPlateFormat): LcmsPlateType;
33
+ export declare function lcmsWellId(row: string, column: number): string;
34
+ export declare function parseLcmsWellId(wellId: string): {
35
+ row: string;
36
+ column: number;
37
+ };
38
+ export declare function lcmsPlateCellsToRack(cells: LcmsPlateCell[], options?: LcmsPlateCellsToRackOptions): Rack;
39
+ export declare function lcmsPlateCellsToRacks(cells: LcmsPlateCell[], options?: LcmsPlateCellsToRacksOptions): Rack[];
40
+ export declare function rackToLcmsPlateCells(rack: Rack): LcmsPlateCell[];
41
+ export declare function racksToLcmsPlateCells(racks: Rack[]): LcmsPlateCell[];
42
+ export declare function getLcmsDefaultControlWellId(plateType: LcmsPlateType, sampleType: LcmsControlSampleType): string;
43
+ export declare function createLcmsControlWellEditData(sampleType: LcmsControlSampleType, options?: {
44
+ wellId?: string;
45
+ plateType?: LcmsPlateType;
46
+ injectionVolume?: number;
47
+ }): WellEditData;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.2",
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",
@@ -367,6 +367,21 @@ describe('AppTopBar', () => {
367
367
  expect(settingsModal.props('showAppearance')).toBe(false)
368
368
  })
369
369
 
370
+ it('should pass settings userType to SettingsModal', () => {
371
+ const settingsConfig: TopBarSettingsConfig = {
372
+ userType: 'admin',
373
+ }
374
+
375
+ const wrapper = createWrapper({
376
+ title: 'Test App',
377
+ showSettings: true,
378
+ settingsConfig,
379
+ })
380
+
381
+ const settingsModal = wrapper.findComponent(SettingsModal)
382
+ expect(settingsModal.props('userType')).toBe('admin')
383
+ })
384
+
370
385
  it('should default showAppearance to true when not specified', () => {
371
386
  const wrapper = createWrapper({
372
387
  title: 'Test App',
@@ -22,4 +22,19 @@ describe('BaseTabs', () => {
22
22
 
23
23
  expect(wrapper.emitted('update:modelValue')).toEqual([['Results']])
24
24
  })
25
+
26
+ it('renders svg path icons', () => {
27
+ const wrapper = mount(BaseTabs, {
28
+ props: {
29
+ modelValue: 'users',
30
+ tabs: [
31
+ { id: 'users', label: 'Users', icon: 'M12 4h8M12 12h8M12 20h8' },
32
+ { id: 'logs', label: 'Logs', icon: ['M4 4h16v16H4z', 'M8 8h8'] },
33
+ ],
34
+ },
35
+ })
36
+
37
+ expect(wrapper.findAll('svg.mint-tab__icon--svg')).toHaveLength(2)
38
+ expect(wrapper.findAll('path')).toHaveLength(3)
39
+ })
25
40
  })
@@ -0,0 +1,57 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import LcmsSequenceTable from '../../components/LcmsSequenceTable.vue'
4
+ import type { LcmsSequenceItem } from '../../lcms'
5
+
6
+ function makeItems(): LcmsSequenceItem[] {
7
+ return [
8
+ {
9
+ sample_type: 'Unknown',
10
+ file_name: 'Batch_NEG_S1_001',
11
+ sample_id: 'R:A1',
12
+ path: '',
13
+ instrument_method: 'C:\\Methods\\neg.meth',
14
+ position: 'R:A1',
15
+ injection_volume: 5,
16
+ },
17
+ {
18
+ sample_type: 'Blank',
19
+ file_name: 'Batch_NEG_Blank_002',
20
+ sample_id: 'R:F9',
21
+ path: '',
22
+ instrument_method: 'C:\\Methods\\blank.meth',
23
+ position: 'R:F9',
24
+ injection_volume: 5,
25
+ },
26
+ ]
27
+ }
28
+
29
+ describe('LcmsSequenceTable', () => {
30
+ it('renders Xcalibur sequence rows with method basenames', () => {
31
+ const wrapper = mount(LcmsSequenceTable, {
32
+ props: {
33
+ items: makeItems(),
34
+ },
35
+ })
36
+
37
+ expect(wrapper.text()).toContain('Batch_NEG_S1_001')
38
+ expect(wrapper.text()).toContain('neg.meth')
39
+ expect(wrapper.text()).toContain('Blank')
40
+ })
41
+
42
+ it('emits editable row actions', async () => {
43
+ const wrapper = mount(LcmsSequenceTable, {
44
+ props: {
45
+ items: makeItems(),
46
+ editable: true,
47
+ },
48
+ })
49
+
50
+ const actions = wrapper.findAll('.mint-lcms-sequence-table__action')
51
+ await actions[0].trigger('click')
52
+ await actions[1].trigger('click')
53
+
54
+ expect(wrapper.emitted('duplicate')?.[0]).toEqual([0])
55
+ expect(wrapper.emitted('remove')?.[0]).toEqual([0])
56
+ })
57
+ })
@@ -0,0 +1,18 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import ProgressBar from '../../components/ProgressBar.vue'
4
+
5
+ describe('ProgressBar', () => {
6
+ it('clamps rail values and exposes an accessible label', () => {
7
+ const wrapper = mount(ProgressBar, {
8
+ props: {
9
+ value: 130,
10
+ ariaLabel: 'Connection pool usage',
11
+ },
12
+ })
13
+
14
+ const progress = wrapper.get('[role="progressbar"]')
15
+ expect(progress.attributes('aria-valuenow')).toBe('100')
16
+ expect(progress.attributes('aria-label')).toBe('Connection pool usage')
17
+ })
18
+ })
@@ -0,0 +1,125 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { h, nextTick } from 'vue'
4
+ import RackEditor from '../../components/RackEditor.vue'
5
+ import type { Rack, WellEditData } from '../../types'
6
+
7
+ function makeRacks(): Rack[] {
8
+ return [
9
+ {
10
+ id: 'rack-1',
11
+ name: 'Rack 1',
12
+ format: 54,
13
+ slot: 'R',
14
+ injectionVolume: 5,
15
+ wells: {
16
+ A1: {
17
+ id: 'A1',
18
+ row: 0,
19
+ col: 0,
20
+ state: 'filled',
21
+ sampleType: 'sample',
22
+ metadata: {
23
+ label: 'Original',
24
+ injectionVolume: 5,
25
+ injectionCount: 1,
26
+ },
27
+ },
28
+ },
29
+ },
30
+ ]
31
+ }
32
+
33
+ function jsonDataTransfer(data: unknown): DataTransfer {
34
+ return {
35
+ dropEffect: 'copy',
36
+ effectAllowed: 'copy',
37
+ getData: (type: string) => type === 'application/json' ? JSON.stringify(data) : '',
38
+ setData: vi.fn(),
39
+ } as unknown as DataTransfer
40
+ }
41
+
42
+ describe('RackEditor', () => {
43
+ it('passes rack-aware well editor slot props through to WellPlate', async () => {
44
+ const wrapper = mount(RackEditor, {
45
+ props: {
46
+ modelValue: makeRacks(),
47
+ },
48
+ slots: {
49
+ 'well-editor': (slotProps) => h(
50
+ 'button',
51
+ {
52
+ 'data-test': 'custom-well-editor',
53
+ onClick: () => slotProps.save({
54
+ wellId: slotProps.wellId,
55
+ label: 'Custom',
56
+ sampleType: 'iqc',
57
+ injectionVolume: 7,
58
+ injectionCount: 3,
59
+ customMethod: 'C:\\Methods\\custom.meth',
60
+ } satisfies WellEditData),
61
+ },
62
+ `${slotProps.rackId}:${slotProps.rack?.slot}:${slotProps.wellId}:${slotProps.wellData?.metadata?.label}`,
63
+ ),
64
+ },
65
+ })
66
+
67
+ await wrapper.find('.mint-well-plate__well').trigger('click')
68
+
69
+ const customEditor = wrapper.get('[data-test="custom-well-editor"]')
70
+ expect(customEditor.text()).toBe('rack-1:R:A1:Original')
71
+
72
+ await customEditor.trigger('click')
73
+ await nextTick()
74
+
75
+ const emittedRacks = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Rack[]
76
+ expect(emittedRacks[0].wells.A1).toMatchObject({
77
+ sampleType: 'iqc',
78
+ metadata: {
79
+ label: 'Custom',
80
+ injectionVolume: 7,
81
+ injectionCount: 3,
82
+ customMethod: 'C:\\Methods\\custom.meth',
83
+ },
84
+ })
85
+ })
86
+
87
+ it('assigns dropped external samples when sample drop is enabled', async () => {
88
+ const wrapper = mount(RackEditor, {
89
+ props: {
90
+ modelValue: makeRacks(),
91
+ allowSampleDrop: true,
92
+ },
93
+ })
94
+
95
+ await wrapper.findAll('.mint-well-plate__well')[1].trigger('drop', {
96
+ dataTransfer: jsonDataTransfer({
97
+ type: 'sample',
98
+ id: 'sample-2',
99
+ sampleName: 'Dragged Sample',
100
+ }),
101
+ preventDefault: vi.fn(),
102
+ })
103
+ await nextTick()
104
+
105
+ const emittedRacks = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Rack[]
106
+ expect(emittedRacks[0].wells.A2).toMatchObject({
107
+ sampleType: 'sample',
108
+ metadata: {
109
+ label: 'Dragged Sample',
110
+ injectionVolume: 5,
111
+ injectionCount: 1,
112
+ customMethod: null,
113
+ },
114
+ })
115
+
116
+ expect(wrapper.emitted('sample-drop')?.[0]?.slice(0, 3)).toEqual([
117
+ 'rack-1',
118
+ 'A2',
119
+ expect.objectContaining({
120
+ id: 'sample-2',
121
+ sampleName: 'Dragged Sample',
122
+ }),
123
+ ])
124
+ })
125
+ })
@@ -57,4 +57,29 @@ describe('SampleSelector', () => {
57
57
 
58
58
  expect(wrapper.find('.mint-sample-selector__sample-name').exists()).toBe(false)
59
59
  })
60
+
61
+ it('can derive samples directly from experiment design data', () => {
62
+ const wrapper = mount(SampleSelector, {
63
+ props: {
64
+ modelValue: [],
65
+ designData: {
66
+ samples: [
67
+ { sample_name: 'S1', sample_type: 'sample' },
68
+ { sample_name: 'Blank', sample_type: 'blank' },
69
+ { sample_name: 'S2', sample_type: 'sample' },
70
+ ],
71
+ },
72
+ enableGrouping: false,
73
+ enableSmartGroup: false,
74
+ },
75
+ global: {
76
+ stubs: {
77
+ AutoGroupModal: true,
78
+ },
79
+ },
80
+ })
81
+
82
+ expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('2 samples')
83
+ expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2'])
84
+ })
60
85
  })
@@ -21,4 +21,49 @@ describe('SegmentedControl', () => {
21
21
 
22
22
  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([96])
23
23
  })
24
+
25
+ it('renders option-level disabled tabs without allowing selection', async () => {
26
+ const wrapper = mount(SegmentedControl, {
27
+ props: {
28
+ modelValue: 'active',
29
+ options: [
30
+ { value: 'active', label: 'Active' },
31
+ { value: 'archived', label: 'Archived', disabled: true },
32
+ ],
33
+ },
34
+ })
35
+
36
+ const buttons = wrapper.findAll('button')
37
+
38
+ expect(buttons).toHaveLength(2)
39
+ expect(buttons[1].attributes('disabled')).toBeDefined()
40
+ expect(buttons[1].classes()).toContain('mint-segmented-control__option--disabled')
41
+
42
+ await buttons[1].trigger('click')
43
+
44
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
45
+ })
46
+
47
+ it('can disable tabs by value while keeping them visible', async () => {
48
+ const wrapper = mount(SegmentedControl, {
49
+ props: {
50
+ modelValue: 'table',
51
+ options: [
52
+ { value: 'table', label: 'Table' },
53
+ { value: 'chart', label: 'Chart' },
54
+ ],
55
+ disabledValues: ['chart'],
56
+ },
57
+ })
58
+
59
+ const buttons = wrapper.findAll('button')
60
+
61
+ expect(buttons.map(button => button.text())).toEqual(['Table', 'Chart'])
62
+ expect(buttons[1].attributes('disabled')).toBeDefined()
63
+
64
+ await buttons[1].trigger('keydown.enter')
65
+ await buttons[1].trigger('click')
66
+
67
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
68
+ })
24
69
  })
@@ -0,0 +1,39 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import SequenceProgressBar from '../../components/SequenceProgressBar.vue'
4
+
5
+ describe('SequenceProgressBar', () => {
6
+ it('renders sequence progress and remaining time', () => {
7
+ const wrapper = mount(SequenceProgressBar, {
8
+ props: {
9
+ progress: {
10
+ current_sample: 3,
11
+ total_samples: 6,
12
+ elapsed_seconds: 180,
13
+ sample_durations: [50, 70, 60],
14
+ },
15
+ },
16
+ })
17
+
18
+ expect(wrapper.get('.mint-sequence-progress__label').text()).toBe('3 / 6 samples')
19
+ expect(wrapper.get('.mint-sequence-progress__percent').text()).toBe('50%')
20
+ expect(wrapper.text()).toContain('~3m left')
21
+ })
22
+
23
+ it('supports compact rendering without footer labels', () => {
24
+ const wrapper = mount(SequenceProgressBar, {
25
+ props: {
26
+ compact: true,
27
+ label: 'injections',
28
+ progress: {
29
+ current_sample: 2,
30
+ total_samples: 4,
31
+ estimated_remaining_seconds: 120,
32
+ },
33
+ },
34
+ })
35
+
36
+ expect(wrapper.get('.mint-sequence-progress__label').text()).toBe('2 / 4 injections')
37
+ expect(wrapper.find('.mint-sequence-progress__footer').exists()).toBe(false)
38
+ })
39
+ })
@@ -3,10 +3,11 @@ import { createPinia } from 'pinia'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  import SettingsModal from '../../components/SettingsModal.vue'
5
5
  import { defineControlModel, type ControlSchema } from '../../composables/useControlSchema'
6
+ import { useAuthStore } from '../../stores/auth'
6
7
  import type { SettingsModalSchema } from '../../types'
7
8
 
8
9
  describe('SettingsModal', () => {
9
- function mountSettingsModal(props = {}, slots = {}) {
10
+ function mountSettingsModal(props = {}, slots = {}, pinia = createPinia()) {
10
11
  return mount(SettingsModal, {
11
12
  props: {
12
13
  modelValue: true,
@@ -15,7 +16,7 @@ describe('SettingsModal', () => {
15
16
  },
16
17
  slots,
17
18
  global: {
18
- plugins: [createPinia()],
19
+ plugins: [pinia],
19
20
  stubs: {
20
21
  teleport: true,
21
22
  },
@@ -293,4 +294,84 @@ describe('SettingsModal', () => {
293
294
  threshold: 0.25,
294
295
  })
295
296
  })
297
+
298
+ it('filters schema groups and fields by explicit user type', async () => {
299
+ const schema: SettingsModalSchema = {
300
+ groups: [
301
+ {
302
+ id: 'general',
303
+ label: 'General',
304
+ fields: [
305
+ { name: 'common', label: 'Common', type: 'text', defaultValue: 'shared' },
306
+ { name: 'adminSecret', label: 'Admin Secret', type: 'text', visibleFor: 'admin' },
307
+ { name: 'userNote', label: 'User Note', type: 'text', visibleFor: 'user' },
308
+ ],
309
+ },
310
+ {
311
+ id: 'admin',
312
+ label: 'Admin',
313
+ visibleFor: 'admin',
314
+ fields: [
315
+ { name: 'adminOnly', label: 'Admin Only', type: 'text' },
316
+ ],
317
+ },
318
+ ],
319
+ }
320
+
321
+ const userWrapper = mountSettingsModal({ schema, userType: 'user' })
322
+
323
+ expect(userWrapper.text()).toContain('Common')
324
+ expect(userWrapper.text()).toContain('User Note')
325
+ expect(userWrapper.text()).not.toContain('Admin Secret')
326
+ expect(userWrapper.text()).not.toContain('Admin Only')
327
+
328
+ const adminWrapper = mountSettingsModal({ schema, userType: 'admin' })
329
+
330
+ expect(adminWrapper.findAll('.mint-settings-modal__tab').map(tab => tab.text())).toEqual([
331
+ 'General',
332
+ 'Admin',
333
+ ])
334
+ expect(adminWrapper.text()).toContain('Common')
335
+ expect(adminWrapper.text()).toContain('Admin Secret')
336
+ expect(adminWrapper.text()).not.toContain('User Note')
337
+
338
+ await adminWrapper.findAll('.mint-settings-modal__tab')[1].trigger('click')
339
+
340
+ expect(adminWrapper.text()).toContain('Admin Only')
341
+ })
342
+
343
+ it('uses SDK auth permissions when no user type override is passed', () => {
344
+ const pinia = createPinia()
345
+ const auth = useAuthStore(pinia)
346
+ auth.setUserInfo({
347
+ id: '1',
348
+ username: 'admin',
349
+ shortname: null,
350
+ email: null,
351
+ role: 'member',
352
+ roleObj: {
353
+ slug: 'member',
354
+ permissions: ['plugins.configure'],
355
+ },
356
+ isActive: true,
357
+ })
358
+ const schema: SettingsModalSchema = {
359
+ groups: [
360
+ {
361
+ id: 'plugins',
362
+ label: 'Plugins',
363
+ permissions: ['plugins.configure'],
364
+ fields: [
365
+ { name: 'registry', label: 'Registry', type: 'text' },
366
+ { name: 'danger', label: 'Danger Zone', type: 'text', requiresAdmin: true },
367
+ ],
368
+ },
369
+ ],
370
+ }
371
+
372
+ const wrapper = mountSettingsModal({ schema }, {}, pinia)
373
+
374
+ expect(wrapper.text()).toContain('Registry')
375
+ expect(wrapper.text()).not.toContain('Danger Zone')
376
+ })
296
377
  })
@@ -478,6 +478,8 @@ describe('useControlSchema', () => {
478
478
  icon: '<svg viewBox="0 0 24 24"></svg>',
479
479
  columns: 2,
480
480
  condition,
481
+ visibleFor: 'admin',
482
+ permissions: ['plugins.configure'],
481
483
  },
482
484
  },
483
485
  })
@@ -490,6 +492,8 @@ describe('useControlSchema', () => {
490
492
  icon: '<svg viewBox="0 0 24 24"></svg>',
491
493
  columns: 2,
492
494
  condition,
495
+ visibleFor: 'admin',
496
+ permissions: ['plugins.configure'],
493
497
  })
494
498
  expect(schema.groups[0].fields.map(field => field.name)).toEqual(['threshold', 'method'])
495
499
  expect(schema.groups[1].label).toBe('Output')
@@ -70,6 +70,29 @@ describe('useExperimentData', () => {
70
70
  expect(hook2.treeData.value).toEqual([{ id: '2' }])
71
71
  })
72
72
 
73
+ it('unwraps platform design-data responses and exposes sample helpers', async () => {
74
+ const response = {
75
+ experiment_id: 42,
76
+ plugin_id: 'ms-designer',
77
+ schema_version: '1.0',
78
+ data: {
79
+ tree_data: [{ id: 'design' }],
80
+ samples: [
81
+ { sample_name: 'S1', sample_type: 'sample', conditions: { group: 'A' } },
82
+ { sample_name: 'Blank', sample_type: 'blank', conditions: { group: 'QC' } },
83
+ ],
84
+ },
85
+ }
86
+ fakeResponse = response
87
+ const hook = useExperimentData()
88
+ await hook.fetch(42)
89
+
90
+ expect(hook.designData.value).toEqual(response.data)
91
+ expect(hook.treeData.value).toEqual([{ id: 'design' }])
92
+ expect(hook.sampleNames.value).toEqual(['S1'])
93
+ expect(hook.sampleOptions.value).toEqual([{ value: 'S1', label: 'S1' }])
94
+ })
95
+
73
96
  it('tableData falls back to empty array when absent', async () => {
74
97
  fakeResponse = {}
75
98
  const hook = useExperimentData()
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import axios, { type AxiosRequestConfig } from 'axios'
4
+
5
+ import { useExperimentSamples } from '../../composables/useExperimentSamples'
6
+
7
+ let fakeResponse: unknown = null
8
+ let fakeError: Error | null = null
9
+ let recordedUrls: string[] = []
10
+
11
+ describe('useExperimentSamples', () => {
12
+ beforeEach(() => {
13
+ setActivePinia(createPinia())
14
+ fakeResponse = {}
15
+ fakeError = null
16
+ recordedUrls = []
17
+ vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
18
+ this: unknown,
19
+ url: string,
20
+ _config?: AxiosRequestConfig,
21
+ ) {
22
+ recordedUrls.push(url)
23
+ if (fakeError) throw fakeError
24
+ return { data: fakeResponse }
25
+ })
26
+ })
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ it('derives samples from pre-fetched MS Designer design_data', () => {
33
+ const hook = useExperimentSamples({
34
+ syncAppExperiment: false,
35
+ designData: {
36
+ samples: [
37
+ { sample_name: 'S1', sample_type: 'sample', conditions: { group: 'A' } },
38
+ { sample_name: 'QC1', sample_type: 'qc', conditions: { group: 'QC' } },
39
+ ],
40
+ },
41
+ })
42
+
43
+ expect(hook.samples.value).toEqual(['S1'])
44
+ expect(hook.sampleOptions.value).toEqual([{ value: 'S1', label: 'S1' }])
45
+ expect(recordedUrls).toEqual([])
46
+ })
47
+
48
+ it('loads and unwraps platform design_data for an experiment id', async () => {
49
+ fakeResponse = {
50
+ experiment_id: 7,
51
+ plugin_id: 'ms-designer',
52
+ data: {
53
+ samples: [
54
+ { sample_name: 'S1', sample_type: 'sample' },
55
+ { sample_name: 'S2', sample_type: 'sample' },
56
+ ],
57
+ },
58
+ schema_version: '1.0',
59
+ }
60
+ const hook = useExperimentSamples({ syncAppExperiment: false, immediate: false })
61
+
62
+ await hook.fetch(7)
63
+
64
+ expect(recordedUrls).toEqual(['/experiments/7/data'])
65
+ expect(hook.samples.value).toEqual(['S1', 'S2'])
66
+ })
67
+
68
+ it('extracts options from template collection sample sheets', () => {
69
+ const hook = useExperimentSamples({
70
+ syncAppExperiment: false,
71
+ designData: {
72
+ templates: {
73
+ 'sample-sheet': {
74
+ data: {
75
+ samples: [
76
+ { sampleId: 'ctrl', name: 'Control', group: 'baseline' },
77
+ { sampleId: 'drug', name: 'Drug', group: 'treated' },
78
+ ],
79
+ },
80
+ },
81
+ },
82
+ },
83
+ })
84
+
85
+ expect(hook.samples.value).toEqual(['ctrl', 'drug'])
86
+ expect(hook.sampleOptions.value).toEqual([
87
+ { value: 'ctrl', label: 'Control', description: 'baseline' },
88
+ { value: 'drug', label: 'Drug', description: 'treated' },
89
+ ])
90
+ })
91
+ })