@morscherlab/mint-sdk 1.0.15 → 1.0.17

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 (57) hide show
  1. package/dist/{BaseSelect-DksaKYq_.js → BaseSelect-ekgr9fDo.js} +4 -1
  2. package/dist/BaseSelect-ekgr9fDo.js.map +1 -0
  3. package/dist/{ExperimentSelectorModal-DIFyL5ta.js → ExperimentSelectorModal-BOzDs8TU.js} +2 -2
  4. package/dist/{ExperimentSelectorModal-CHsU-LIh.js → ExperimentSelectorModal-CX0oBzpV.js} +2 -2
  5. package/dist/{ExperimentSelectorModal-CHsU-LIh.js.map → ExperimentSelectorModal-CX0oBzpV.js.map} +1 -1
  6. package/dist/{SettingsModal-LEKI6Ebl.js → SettingsModal-BTyXD0uP.js} +3 -3
  7. package/dist/{SettingsModal-LEKI6Ebl.js.map → SettingsModal-BTyXD0uP.js.map} +1 -1
  8. package/dist/SettingsModal-DXcSKk9D.js +5 -0
  9. package/dist/__tests__/components/MobileSupportGate.test.d.ts +1 -0
  10. package/dist/__tests__/composables/useMobileSupportGate.test.d.ts +1 -0
  11. package/dist/components/AutoGroupModal.vue.d.ts +6 -0
  12. package/dist/components/BaseInput.vue.d.ts +1 -0
  13. package/dist/components/MobileSupportGate.vue.d.ts +40 -0
  14. package/dist/components/SampleSelector.colors.d.ts +2 -1
  15. package/dist/components/index.d.ts +1 -0
  16. package/dist/components/index.js +6 -6
  17. package/dist/{components-Cyk8QEyL.js → components-CzdeV1xe.js} +1122 -535
  18. package/dist/components-CzdeV1xe.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -0
  20. package/dist/composables/index.js +7 -7
  21. package/dist/composables/useMobileSupportGate.d.ts +14 -0
  22. package/dist/{composables-D9mexHSW.js → composables-Da-4XOe2.js} +3 -3
  23. package/dist/{composables-D9mexHSW.js.map → composables-Da-4XOe2.js.map} +1 -1
  24. package/dist/index.js +10 -10
  25. package/dist/install.js +5 -5
  26. package/dist/styles.css +4004 -2537
  27. package/dist/templates/index.js +3 -3
  28. package/dist/{templates-Do43ZIMb.js → templates-Dnf8UNxg.js} +2 -2
  29. package/dist/{templates-Do43ZIMb.js.map → templates-Dnf8UNxg.js.map} +1 -1
  30. package/dist/{useControlSchema-0n8Bcftq.js → useControlSchema-Dkm-W_lg.js} +2 -2
  31. package/dist/{useControlSchema-0n8Bcftq.js.map → useControlSchema-Dkm-W_lg.js.map} +1 -1
  32. package/dist/{useFormBuilder-COfYWDuC.js → useFormBuilder-BOJ52N4M.js} +2 -2
  33. package/dist/{useFormBuilder-COfYWDuC.js.map → useFormBuilder-BOJ52N4M.js.map} +1 -1
  34. package/dist/{useProtocolTemplates-DODHlhxr.js → useProtocolTemplates-r2GOnnH1.js} +55 -5
  35. package/dist/useProtocolTemplates-r2GOnnH1.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/__tests__/components/MobileSupportGate.test.ts +120 -0
  38. package/src/__tests__/components/SampleSelector.test.ts +119 -0
  39. package/src/__tests__/composables/useMobileSupportGate.test.ts +74 -0
  40. package/src/components/AutoGroupModal.story.vue +46 -0
  41. package/src/components/AutoGroupModal.vue +578 -2
  42. package/src/components/BaseInput.vue +2 -0
  43. package/src/components/MobileSupportGate.story.vue +52 -0
  44. package/src/components/MobileSupportGate.vue +115 -0
  45. package/src/components/SampleSelector.colors.ts +7 -2
  46. package/src/components/SampleSelector.story.vue +45 -1
  47. package/src/components/SampleSelector.vue +32 -6
  48. package/src/components/index.ts +1 -0
  49. package/src/composables/index.ts +8 -0
  50. package/src/composables/useMobileSupportGate.ts +80 -0
  51. package/src/styles/components/auto-group-modal.css +758 -0
  52. package/src/styles/components/mobile-support-gate.css +119 -0
  53. package/src/styles/components/sample-selector.css +23 -9
  54. package/dist/BaseSelect-DksaKYq_.js.map +0 -1
  55. package/dist/SettingsModal-L7Ejny45.js +0 -5
  56. package/dist/components-Cyk8QEyL.js.map +0 -1
  57. package/dist/useProtocolTemplates-DODHlhxr.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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",
@@ -0,0 +1,120 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { nextTick } from 'vue'
4
+ import MobileSupportGate from '../../components/MobileSupportGate.vue'
5
+
6
+ function mockMatchMedia(initialMatches: boolean) {
7
+ const listeners = new Set<(event: MediaQueryListEvent) => void>()
8
+ const queryList = {
9
+ matches: initialMatches,
10
+ media: '',
11
+ onchange: null,
12
+ addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
13
+ listeners.add(listener)
14
+ }),
15
+ removeEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
16
+ listeners.delete(listener)
17
+ }),
18
+ addListener: vi.fn((listener: (event: MediaQueryListEvent) => void) => {
19
+ listeners.add(listener)
20
+ }),
21
+ removeListener: vi.fn((listener: (event: MediaQueryListEvent) => void) => {
22
+ listeners.delete(listener)
23
+ }),
24
+ dispatchEvent: vi.fn(),
25
+ } as unknown as MediaQueryList
26
+
27
+ Object.defineProperty(window, 'matchMedia', {
28
+ configurable: true,
29
+ value: vi.fn(() => queryList),
30
+ })
31
+
32
+ return {
33
+ setMatches(matches: boolean) {
34
+ Object.defineProperty(queryList, 'matches', {
35
+ configurable: true,
36
+ value: matches,
37
+ })
38
+ listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
39
+ },
40
+ }
41
+ }
42
+
43
+ describe('MobileSupportGate', () => {
44
+ afterEach(() => {
45
+ vi.restoreAllMocks()
46
+ })
47
+
48
+ it('shows the unsupported message on mobile when the route is unsupported', async () => {
49
+ mockMatchMedia(true)
50
+
51
+ const wrapper = mount(MobileSupportGate, {
52
+ props: {
53
+ supported: false,
54
+ },
55
+ slots: {
56
+ default: '<div data-testid="content">Workspace content</div>',
57
+ },
58
+ })
59
+ await nextTick()
60
+
61
+ expect(wrapper.find('[data-testid="mobile-unsupported"]').exists()).toBe(true)
62
+ expect(wrapper.text()).toContain('Desktop workspace recommended')
63
+ expect(wrapper.find('[data-testid="content"]').exists()).toBe(false)
64
+ })
65
+
66
+ it('renders content on desktop even when the route is mobile-unsupported', async () => {
67
+ mockMatchMedia(false)
68
+
69
+ const wrapper = mount(MobileSupportGate, {
70
+ props: {
71
+ supported: false,
72
+ },
73
+ slots: {
74
+ default: '<div data-testid="content">Workspace content</div>',
75
+ },
76
+ })
77
+ await nextTick()
78
+
79
+ expect(wrapper.find('[data-testid="mobile-unsupported"]').exists()).toBe(false)
80
+ expect(wrapper.find('[data-testid="content"]').exists()).toBe(true)
81
+ })
82
+
83
+ it('renders content on mobile when the route is supported', async () => {
84
+ mockMatchMedia(true)
85
+
86
+ const wrapper = mount(MobileSupportGate, {
87
+ props: {
88
+ supported: true,
89
+ },
90
+ slots: {
91
+ default: '<div data-testid="content">Workspace content</div>',
92
+ },
93
+ })
94
+ await nextTick()
95
+
96
+ expect(wrapper.find('[data-testid="mobile-unsupported"]').exists()).toBe(false)
97
+ expect(wrapper.find('[data-testid="content"]').exists()).toBe(true)
98
+ })
99
+
100
+ it('reacts when the viewport moves into the mobile query', async () => {
101
+ const media = mockMatchMedia(false)
102
+
103
+ const wrapper = mount(MobileSupportGate, {
104
+ props: {
105
+ supported: false,
106
+ },
107
+ slots: {
108
+ default: '<div data-testid="content">Workspace content</div>',
109
+ },
110
+ })
111
+ await nextTick()
112
+
113
+ expect(wrapper.find('[data-testid="content"]').exists()).toBe(true)
114
+
115
+ media.setMatches(true)
116
+ await nextTick()
117
+
118
+ expect(wrapper.find('[data-testid="mobile-unsupported"]').exists()).toBe(true)
119
+ })
120
+ })
@@ -1,5 +1,7 @@
1
1
  import { mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
2
3
  import { describe, expect, it } from 'vitest'
4
+ import AutoGroupModal from '../../components/AutoGroupModal.vue'
3
5
  import SampleSelector from '../../components/SampleSelector.vue'
4
6
  import type { AutoGroupResult, SampleGroup } from '../../types'
5
7
 
@@ -86,6 +88,123 @@ describe('SampleSelector', () => {
86
88
  expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('2 samples')
87
89
  expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2'])
88
90
  })
91
+
92
+ it('opens the grouping modal in manual mode', async () => {
93
+ const wrapper = mount(SampleSelector, {
94
+ props: {
95
+ samples: ['S1', 'S2'],
96
+ modelValue: [],
97
+ groups: [],
98
+ enableSmartGroup: false,
99
+ },
100
+ global: {
101
+ stubs: {
102
+ AutoGroupModal: true,
103
+ },
104
+ },
105
+ })
106
+
107
+ await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click')
108
+
109
+ const modal = wrapper.findComponent({ name: 'AutoGroupModal' })
110
+ expect(modal.props('initialMode')).toBe('manual')
111
+ expect(modal.props('groups')).toEqual([])
112
+ })
113
+ })
114
+
115
+ describe('AutoGroupModal manual workflow', () => {
116
+ it('assigns selected samples to a manual group and subgroup', async () => {
117
+ const wrapper = mount(AutoGroupModal, {
118
+ props: {
119
+ modelValue: true,
120
+ initialMode: 'manual',
121
+ samples: ['S1', 'S2'],
122
+ groups: [],
123
+ },
124
+ global: {
125
+ plugins: [createPinia()],
126
+ stubs: {
127
+ Teleport: true,
128
+ },
129
+ },
130
+ })
131
+
132
+ await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
133
+ const fields = wrapper.findAll('.mint-auto-group__manual-field input')
134
+ await fields[0].setValue('Treatment')
135
+ await fields[1].setValue('Baseline')
136
+ await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
137
+ await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
138
+
139
+ const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
140
+ expect(result.groups).toHaveLength(1)
141
+ expect(result.groups[0]).toMatchObject({
142
+ name: 'Treatment/Baseline',
143
+ samples: ['S1'],
144
+ })
145
+ })
146
+
147
+ it('moves manual selections out of existing groups before assigning them', async () => {
148
+ const wrapper = mount(AutoGroupModal, {
149
+ props: {
150
+ modelValue: true,
151
+ initialMode: 'manual',
152
+ samples: ['S1', 'S2'],
153
+ groups: [
154
+ { name: 'Control', color: '#3B82F6', samples: ['S1'] },
155
+ { name: 'Treatment', color: '#10B981', samples: [] },
156
+ ],
157
+ },
158
+ global: {
159
+ plugins: [createPinia()],
160
+ stubs: {
161
+ Teleport: true,
162
+ },
163
+ },
164
+ })
165
+
166
+ await wrapper.find('.mint-auto-group__manual-filter input').setValue(false)
167
+ await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
168
+ await wrapper.findAll('.mint-auto-group__manual-chip')[1].trigger('click')
169
+ await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
170
+ await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
171
+
172
+ const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
173
+ expect(result.groups).toEqual([
174
+ { name: 'Control', color: '#3B82F6', samples: [] },
175
+ { name: 'Treatment', color: '#10B981', samples: ['S1'] },
176
+ ])
177
+ })
178
+
179
+ it('preserves an existing cohort color when the target is typed manually', async () => {
180
+ const wrapper = mount(AutoGroupModal, {
181
+ props: {
182
+ modelValue: true,
183
+ initialMode: 'manual',
184
+ samples: ['S1', 'S2'],
185
+ groups: [
186
+ { name: 'Control', color: '#10B981', samples: ['S1'] },
187
+ ],
188
+ },
189
+ global: {
190
+ plugins: [createPinia()],
191
+ stubs: {
192
+ Teleport: true,
193
+ },
194
+ },
195
+ })
196
+
197
+ await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
198
+ const fields = wrapper.findAll('.mint-auto-group__manual-field input')
199
+ await fields[0].setValue('Control')
200
+ await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
201
+ await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
202
+
203
+ const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
204
+ expect(result.groups).toEqual([
205
+ { name: 'Control', color: '#10B981', samples: ['S1', 'S2'] },
206
+ ])
207
+ })
89
208
  })
90
209
 
91
210
  describe('SampleSelector — QC overlay regression', () => {
@@ -0,0 +1,74 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { computed, defineComponent, nextTick, ref } from 'vue'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { useMobileSupportGate } from '../../composables/useMobileSupportGate'
5
+
6
+ function mockMatchMedia(initialMatches: boolean) {
7
+ const listeners = new Set<(event: MediaQueryListEvent) => void>()
8
+ const queryList = {
9
+ matches: initialMatches,
10
+ media: '',
11
+ onchange: null,
12
+ addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
13
+ listeners.add(listener)
14
+ }),
15
+ removeEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
16
+ listeners.delete(listener)
17
+ }),
18
+ addListener: vi.fn((listener: (event: MediaQueryListEvent) => void) => {
19
+ listeners.add(listener)
20
+ }),
21
+ removeListener: vi.fn((listener: (event: MediaQueryListEvent) => void) => {
22
+ listeners.delete(listener)
23
+ }),
24
+ dispatchEvent: vi.fn(),
25
+ } as unknown as MediaQueryList
26
+
27
+ Object.defineProperty(window, 'matchMedia', {
28
+ configurable: true,
29
+ value: vi.fn(() => queryList),
30
+ })
31
+
32
+ return {
33
+ setMatches(matches: boolean) {
34
+ Object.defineProperty(queryList, 'matches', {
35
+ configurable: true,
36
+ value: matches,
37
+ })
38
+ listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
39
+ },
40
+ }
41
+ }
42
+
43
+ describe('useMobileSupportGate', () => {
44
+ afterEach(() => {
45
+ vi.restoreAllMocks()
46
+ })
47
+
48
+ it('computes the unsupported state from viewport and supported sources', async () => {
49
+ const media = mockMatchMedia(true)
50
+ const supported = ref(false)
51
+
52
+ const wrapper = mount(defineComponent({
53
+ setup() {
54
+ const gate = useMobileSupportGate({
55
+ supported: computed(() => supported.value),
56
+ })
57
+ return gate
58
+ },
59
+ template: '<div>{{ shouldShowUnsupported }}</div>',
60
+ }))
61
+ await nextTick()
62
+
63
+ expect(wrapper.text()).toBe('true')
64
+
65
+ supported.value = true
66
+ await nextTick()
67
+ expect(wrapper.text()).toBe('false')
68
+
69
+ supported.value = false
70
+ media.setMatches(false)
71
+ await nextTick()
72
+ expect(wrapper.text()).toBe('false')
73
+ })
74
+ })
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import AutoGroupModal from './AutoGroupModal.vue'
4
4
  import type { AutoGroupResult } from '../types/auto-group'
5
+ import type { SampleGroup } from '../types'
5
6
  import mixedLcMsRaw from '../__tests__/fixtures/auto-group/mixed-lc-ms-batch.txt?raw'
6
7
 
7
8
  const mixedLcMsSamples = mixedLcMsRaw.trim().split('\n')
@@ -92,6 +93,29 @@ const hyphenSamples = [
92
93
  'KO-Drug-1', 'KO-Drug-2', 'KO-Drug-3',
93
94
  ]
94
95
 
96
+ const manualSamples = [
97
+ 'Pt001_TumorA_d7_rep1',
98
+ 'Pt001_TumorA_d7_rep1_reseq2024',
99
+ 'Pt001_TumorA_d14_rep1',
100
+ 'Pt002_TumorB_d7_rep1',
101
+ 'Pt002_tumorb_d7_rep2',
102
+ 'Pt002_TumorB_d14_rep1',
103
+ 'Pt003_NoID_d7_rep1',
104
+ 'Pt003_TumorC_d14_rep1_redo',
105
+ 'Ctrl_pooled_old_naming',
106
+ 'control_2_(redo)',
107
+ 'Pt004_TumorA_d7_2024batch2',
108
+ 'Pt004_TumorA_d14',
109
+ 'QC_pool_01',
110
+ 'blank_run3',
111
+ ]
112
+
113
+ const manualGroups: SampleGroup[] = [
114
+ { name: 'Responder/Day 7', color: '#0EA5A4', samples: ['Pt001_TumorA_d7_rep1'] },
115
+ { name: 'Responder/Day 14', color: '#0EA5A4', samples: ['Pt001_TumorA_d14_rep1'] },
116
+ { name: 'Non-responder/Day 7', color: '#F43F5E', samples: ['Pt002_TumorB_d7_rep1'] },
117
+ ]
118
+
95
119
  const isOpen = ref(false)
96
120
  const lastResult = ref<AutoGroupResult | null>(null)
97
121
 
@@ -239,6 +263,28 @@ function initState(samples: string[] = proteomicsSamples) {
239
263
  </template>
240
264
  </Variant>
241
265
 
266
+ <Variant title="Manual Workflow" :init-state="() => ({ isOpen: true })">
267
+ <template #default="{ state }">
268
+ <div style="padding: 2rem;">
269
+ <button
270
+ v-if="!state.isOpen"
271
+ type="button"
272
+ style="padding: 0.5rem 1rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e5e7eb); border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;"
273
+ @click="state.isOpen = true"
274
+ >
275
+ Re-open Modal
276
+ </button>
277
+ <AutoGroupModal
278
+ v-model="state.isOpen"
279
+ :samples="manualSamples"
280
+ :groups="manualGroups"
281
+ initial-mode="manual"
282
+ @apply="handleApply"
283
+ />
284
+ </div>
285
+ </template>
286
+ </Variant>
287
+
242
288
  <Variant title="From Experiment Metadata" :init-state="() => ({ isOpen: true, lastResult: null as AutoGroupResult | null })">
243
289
  <template #default="{ state }">
244
290
  <div style="padding: 2rem;">