@morscherlab/mint-sdk 1.0.0-beta.7 → 1.0.0-rc.1

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 (48) hide show
  1. package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
  2. package/dist/__tests__/stores/settings.test.d.ts +1 -0
  3. package/dist/{auth-QQj2kkze.js → auth-CBG3bWEc.js} +50 -20
  4. package/dist/auth-CBG3bWEc.js.map +1 -0
  5. package/dist/components/SettingsModal.vue.d.ts +5 -0
  6. package/dist/components/index.js +2 -2
  7. package/dist/{components-DihbSJjU.js → components-5KSfsVqf.js} +49 -29
  8. package/dist/components-5KSfsVqf.js.map +1 -0
  9. package/dist/composables/index.js +3 -3
  10. package/dist/{composables-BcgZ6diz.js → composables-D4Myb30a.js} +3 -3
  11. package/dist/{composables-BcgZ6diz.js.map → composables-D4Myb30a.js.map} +1 -1
  12. package/dist/index.js +5 -5
  13. package/dist/install.js +2 -2
  14. package/dist/stores/index.js +1 -1
  15. package/dist/styles.css +16 -0
  16. package/dist/templates/index.js +1 -1
  17. package/dist/{templates-Cyt0Suwf.js → templates-BSlxwV2c.js} +12 -8
  18. package/dist/templates-BSlxwV2c.js.map +1 -0
  19. package/dist/{useExperimentData-CM6Y0u5L.js → useExperimentData-BbbdI5xT.js} +97 -25
  20. package/dist/useExperimentData-BbbdI5xT.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/__tests__/components/GroupAssigner.test.ts +18 -0
  23. package/src/__tests__/composables/useApi.test.ts +45 -0
  24. package/src/__tests__/composables/useAuth.test.ts +20 -0
  25. package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
  26. package/src/__tests__/stores/settings.test.ts +78 -0
  27. package/src/components/AppAvatarMenu.vue +6 -3
  28. package/src/components/AppTopBar.vue +15 -10
  29. package/src/components/AuditTrail.vue +1 -1
  30. package/src/components/Calendar.vue +6 -2
  31. package/src/components/ConcentrationInput.vue +3 -2
  32. package/src/components/GroupAssigner.vue +8 -3
  33. package/src/components/NumberInput.vue +5 -3
  34. package/src/components/SampleHierarchyTree.vue +3 -2
  35. package/src/components/SettingsModal.vue +7 -0
  36. package/src/components/UnitInput.vue +6 -2
  37. package/src/components/WellPlate.vue +3 -3
  38. package/src/composables/useApi.ts +113 -16
  39. package/src/composables/useAutoGroup.ts +13 -8
  40. package/src/composables/useProtocolTemplates.ts +13 -1
  41. package/src/composables/useRackEditor.ts +3 -2
  42. package/src/stores/auth.ts +48 -23
  43. package/src/stores/settings.ts +10 -0
  44. package/src/styles/components/settings-modal.css +9 -0
  45. package/dist/auth-QQj2kkze.js.map +0 -1
  46. package/dist/components-DihbSJjU.js.map +0 -1
  47. package/dist/templates-Cyt0Suwf.js.map +0 -1
  48. package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-rc.1",
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",
@@ -27,4 +27,22 @@ describe('GroupAssigner', () => {
27
27
  expect(wrapper.emitted('update:group1')?.[0]).toEqual([[]])
28
28
  expect(wrapper.emitted('update:group2')).toBeUndefined()
29
29
  })
30
+
31
+ it('does not throw when drag events omit dataTransfer', async () => {
32
+ const wrapper = mount(GroupAssigner, {
33
+ props: {
34
+ groups,
35
+ group1: [],
36
+ group2: [],
37
+ },
38
+ })
39
+
40
+ await expect(
41
+ wrapper.find('.mint-group-assigner__pill--unassigned').trigger('dragstart', { dataTransfer: null }),
42
+ ).resolves.toBeUndefined()
43
+
44
+ await expect(
45
+ wrapper.find('.mint-group-assigner__zone').trigger('dragover', { dataTransfer: null }),
46
+ ).resolves.toBeUndefined()
47
+ })
30
48
  })
@@ -1,7 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createPinia, setActivePinia } from 'pinia'
3
+ import type { InternalAxiosRequestConfig } from 'axios'
3
4
 
4
5
  import { useApi } from '../../composables/useApi'
6
+ import { useAuthStore } from '../../stores/auth'
7
+
8
+ function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
9
+ const bag = headers as Record<string, unknown> & { get?: (header: string) => unknown }
10
+ return (typeof bag.get === 'function' ? bag.get('Authorization') : undefined)
11
+ ?? bag.Authorization
12
+ ?? bag.authorization
13
+ }
5
14
 
6
15
  describe('useApi URL helpers', () => {
7
16
  beforeEach(() => {
@@ -9,6 +18,7 @@ describe('useApi URL helpers', () => {
9
18
  })
10
19
 
11
20
  afterEach(() => {
21
+ vi.restoreAllMocks()
12
22
  vi.unstubAllEnvs()
13
23
  })
14
24
 
@@ -27,4 +37,39 @@ describe('useApi URL helpers', () => {
27
37
  expect(api.buildWsUrl('/events')).toMatch(/\/api\/events$/)
28
38
  expect(api.buildWsUrl('/events')).not.toContain('/api//events')
29
39
  })
40
+
41
+ it('normalizes paths that already include the configured API prefix', async () => {
42
+ const api = useApi({ baseUrl: '/api' })
43
+ const getSpy = vi.spyOn(api.client, 'get').mockResolvedValue({ data: { ok: true } })
44
+
45
+ expect(api.buildUrl('/api/projects')).toBe('/api/projects')
46
+ expect(api.buildUrl('/api')).toBe('/api')
47
+ expect(api.buildUrl('/api?active=true')).toBe('/api?active=true')
48
+
49
+ await expect(api.get<{ ok: boolean }>('/api/projects')).resolves.toEqual({ ok: true })
50
+ expect(getSpy).toHaveBeenCalledWith('/projects', expect.objectContaining({ baseURL: '/api' }))
51
+ })
52
+
53
+ it('honors withAuth=false even when the SDK auth store has a token', async () => {
54
+ const authStore = useAuthStore()
55
+ authStore.setToken('secret-token', 3600)
56
+ const api = useApi({ baseUrl: '/api', withAuth: false })
57
+
58
+ let authorization: unknown
59
+ const result = await api.get<{ ok: boolean }>('/public', {
60
+ adapter: async (config) => {
61
+ authorization = readAuthorizationHeader(config.headers)
62
+ return {
63
+ data: { ok: true },
64
+ status: 200,
65
+ statusText: 'OK',
66
+ headers: {},
67
+ config,
68
+ }
69
+ },
70
+ })
71
+
72
+ expect(result).toEqual({ ok: true })
73
+ expect(authorization).toBeUndefined()
74
+ })
30
75
  })
@@ -94,6 +94,26 @@ describe('useAuth', () => {
94
94
  })
95
95
  })
96
96
 
97
+ describe('storage fallback', () => {
98
+ it('keeps auth usable when localStorage operations throw', () => {
99
+ vi.stubGlobal('localStorage', {
100
+ getItem: vi.fn(() => { throw new Error('blocked') }),
101
+ setItem: vi.fn(() => { throw new Error('blocked') }),
102
+ removeItem: vi.fn(() => { throw new Error('blocked') }),
103
+ })
104
+
105
+ const authStore = useAuthStore()
106
+
107
+ expect(() => authStore.initialize()).not.toThrow()
108
+
109
+ authStore.setToken('memory-token', 3600)
110
+ expect(authStore.token).toBe('memory-token')
111
+
112
+ authStore.clearToken()
113
+ expect(authStore.token).toBeNull()
114
+ })
115
+ })
116
+
97
117
  describe('refreshToken - race condition', () => {
98
118
  it('should deduplicate concurrent refresh calls', async () => {
99
119
  const authStore = useAuthStore()
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { useProtocolTemplates } from '../../composables/useProtocolTemplates'
3
+
4
+ const STORAGE_KEY = 'mint-custom-protocol-templates'
5
+
6
+ function installLocalStorage() {
7
+ const storage: Record<string, string> = {}
8
+ vi.stubGlobal('localStorage', {
9
+ get length() {
10
+ return Object.keys(storage).length
11
+ },
12
+ key: vi.fn((index: number) => Object.keys(storage)[index] ?? null),
13
+ getItem: vi.fn((key: string) => storage[key] ?? null),
14
+ setItem: vi.fn((key: string, value: string) => {
15
+ storage[key] = value
16
+ }),
17
+ removeItem: vi.fn((key: string) => {
18
+ delete storage[key]
19
+ }),
20
+ clear: vi.fn(() => {
21
+ for (const key of Object.keys(storage)) delete storage[key]
22
+ }),
23
+ } as unknown as Storage)
24
+ }
25
+
26
+ describe('useProtocolTemplates', () => {
27
+ beforeEach(() => {
28
+ vi.restoreAllMocks()
29
+ vi.unstubAllGlobals()
30
+ installLocalStorage()
31
+ })
32
+
33
+ it('ignores corrupted custom template storage', () => {
34
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ invalid: true }))
35
+
36
+ const templates = useProtocolTemplates()
37
+
38
+ expect(templates.customTemplates.value).toEqual([])
39
+ expect(templates.allTemplates.value.length).toBeGreaterThan(0)
40
+ })
41
+
42
+ it('filters invalid custom template entries', () => {
43
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([
44
+ { id: 'bad', name: 'Missing parameters', type: 'custom' },
45
+ {
46
+ id: 'custom-valid',
47
+ name: 'Valid custom step',
48
+ type: 'custom',
49
+ parameters: [],
50
+ },
51
+ ]))
52
+
53
+ const templates = useProtocolTemplates()
54
+
55
+ expect(templates.customTemplates.value).toEqual([
56
+ {
57
+ id: 'custom-valid',
58
+ name: 'Valid custom step',
59
+ type: 'custom',
60
+ parameters: [],
61
+ },
62
+ ])
63
+ })
64
+ })
@@ -0,0 +1,78 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { useSettingsStore } from '../../stores/settings'
4
+
5
+ function mockMatchMedia(matches = false) {
6
+ Object.defineProperty(window, 'matchMedia', {
7
+ configurable: true,
8
+ writable: true,
9
+ value: vi.fn(() => ({
10
+ matches,
11
+ media: '(prefers-color-scheme: dark)',
12
+ onchange: null,
13
+ addEventListener: vi.fn(),
14
+ removeEventListener: vi.fn(),
15
+ addListener: vi.fn(),
16
+ removeListener: vi.fn(),
17
+ dispatchEvent: vi.fn(),
18
+ })),
19
+ })
20
+ }
21
+
22
+ function installLocalStorage() {
23
+ const storage: Record<string, string> = {}
24
+ vi.stubGlobal('localStorage', {
25
+ get length() {
26
+ return Object.keys(storage).length
27
+ },
28
+ key: vi.fn((index: number) => Object.keys(storage)[index] ?? null),
29
+ getItem: vi.fn((key: string) => storage[key] ?? null),
30
+ setItem: vi.fn((key: string, value: string) => {
31
+ storage[key] = value
32
+ }),
33
+ removeItem: vi.fn((key: string) => {
34
+ delete storage[key]
35
+ }),
36
+ clear: vi.fn(() => {
37
+ for (const key of Object.keys(storage)) delete storage[key]
38
+ }),
39
+ } as unknown as Storage)
40
+ }
41
+
42
+ describe('useSettingsStore', () => {
43
+ beforeEach(() => {
44
+ vi.restoreAllMocks()
45
+ vi.unstubAllGlobals()
46
+ setActivePinia(createPinia())
47
+ installLocalStorage()
48
+ document.documentElement.classList.remove('dark')
49
+ mockMatchMedia(false)
50
+ })
51
+
52
+ it('migrates legacy MLD settings into the MINT settings key', () => {
53
+ localStorage.setItem('mld-settings', JSON.stringify({
54
+ serverHost: '127.0.0.1',
55
+ serverPort: 8001,
56
+ theme: 'dark',
57
+ colorPalette: 'viridis',
58
+ tableDensity: 'compact',
59
+ }))
60
+
61
+ const settings = useSettingsStore()
62
+ settings.initialize()
63
+
64
+ expect(settings.serverHost).toBe('127.0.0.1')
65
+ expect(settings.serverPort).toBe(8001)
66
+ expect(settings.theme).toBe('dark')
67
+ expect(settings.colorPalette).toBe('viridis')
68
+ expect(settings.tableDensity).toBe('compact')
69
+ expect(localStorage.getItem('mld-settings')).toBeNull()
70
+ expect(JSON.parse(localStorage.getItem('mint-settings') || '{}')).toEqual(expect.objectContaining({
71
+ serverHost: '127.0.0.1',
72
+ serverPort: 8001,
73
+ theme: 'dark',
74
+ colorPalette: 'viridis',
75
+ tableDensity: 'compact',
76
+ }))
77
+ })
78
+ })
@@ -35,9 +35,12 @@ const { isOpen, rootRef, close, toggle } = useDropdownState({
35
35
  const initials = computed(() => {
36
36
  if (props.userInitial) return props.userInitial.slice(0, 2).toUpperCase()
37
37
  if (props.userName) {
38
- const parts = props.userName.trim().split(/\s+/)
39
- if (parts.length >= 2) return (parts[0]!.charAt(0) + parts[1]!.charAt(0)).toUpperCase()
40
- return parts[0]!.slice(0, 2).toUpperCase()
38
+ const parts = props.userName.trim().split(/\s+/).filter(Boolean)
39
+ const first = parts[0]
40
+ if (!first) return 'U'
41
+ const second = parts[1]
42
+ if (second) return (first.charAt(0) + second.charAt(0)).toUpperCase()
43
+ return first.slice(0, 2).toUpperCase()
41
44
  }
42
45
  return 'U'
43
46
  })
@@ -117,7 +117,12 @@ const { isIntegrated, plugin } = usePlatformContext()
117
117
  const isStandalone = computed(() => !isIntegrated.value)
118
118
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
119
119
 
120
- const hasPluginIcon = computed(() => !!plugin.value?.icon)
120
+ const pluginIcon = computed(() => {
121
+ const currentPlugin = plugin.value
122
+ return currentPlugin?.icon
123
+ ? { icon: currentPlugin.icon, color: currentPlugin.color }
124
+ : null
125
+ })
121
126
 
122
127
  const profileInitial = computed(() => {
123
128
  if (props.userInitial) return props.userInitial
@@ -225,10 +230,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
225
230
  >
226
231
  <slot name="icon">
227
232
  <PluginIcon
228
- v-if="hasPluginIcon"
233
+ v-if="pluginIcon"
229
234
  class="mint-topbar__plugin-icon"
230
- :icon="plugin!.icon"
231
- :tone="plugin!.color"
235
+ :icon="pluginIcon.icon"
236
+ :tone="pluginIcon.color"
232
237
  size="md"
233
238
  variant="solid"
234
239
  />
@@ -248,10 +253,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
248
253
  >
249
254
  <slot name="icon">
250
255
  <PluginIcon
251
- v-if="hasPluginIcon"
256
+ v-if="pluginIcon"
252
257
  class="mint-topbar__plugin-icon"
253
- :icon="plugin!.icon"
254
- :tone="plugin!.color"
258
+ :icon="pluginIcon.icon"
259
+ :tone="pluginIcon.color"
255
260
  size="md"
256
261
  variant="solid"
257
262
  />
@@ -267,10 +272,10 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
267
272
  <template v-else>
268
273
  <slot name="icon">
269
274
  <PluginIcon
270
- v-if="hasPluginIcon"
275
+ v-if="pluginIcon"
271
276
  class="mint-topbar__plugin-icon"
272
- :icon="plugin!.icon"
273
- :tone="plugin!.color"
277
+ :icon="pluginIcon.icon"
278
+ :tone="pluginIcon.color"
274
279
  size="md"
275
280
  variant="solid"
276
281
  />
@@ -32,7 +32,7 @@ const uniqueTypes = computed(() => {
32
32
  })
33
33
 
34
34
  const uniqueUsers = computed(() => {
35
- const users = new Set(props.entries.filter(e => e.user).map(e => e.user!))
35
+ const users = new Set(props.entries.flatMap(e => e.user ? [e.user] : []))
36
36
  return Array.from(users)
37
37
  })
38
38
 
@@ -134,8 +134,12 @@ const markerMap = computed(() => {
134
134
  for (const marker of props.markers) {
135
135
  const date = parseDate(marker.date)
136
136
  const key = toDateKey(date)
137
- if (!map.has(key)) map.set(key, [])
138
- map.get(key)!.push(marker)
137
+ const markers = map.get(key)
138
+ if (markers) {
139
+ markers.push(marker)
140
+ } else {
141
+ map.set(key, [marker])
142
+ }
139
143
  }
140
144
  return map
141
145
  })
@@ -37,13 +37,14 @@ const { unitCategories, getConversionHint } = useConcentrationUnits()
37
37
 
38
38
  // Filter categories based on allowedUnits
39
39
  const filteredCategories = computed(() => {
40
- if (!props.allowedUnits || props.allowedUnits.length === 0) {
40
+ const allowedUnits = props.allowedUnits
41
+ if (!allowedUnits || allowedUnits.length === 0) {
41
42
  return unitCategories.value
42
43
  }
43
44
  return unitCategories.value
44
45
  .map(cat => ({
45
46
  label: cat.label,
46
- units: cat.units.filter(u => props.allowedUnits!.includes(u)),
47
+ units: cat.units.filter(u => allowedUnits.includes(u)),
47
48
  }))
48
49
  .filter(cat => cat.units.length > 0)
49
50
  })
@@ -42,9 +42,12 @@ const assignment = useGroupAssignment({
42
42
  const { unassignedGroups, zone1Groups, zone2Groups, zone1Count, zone2Count, validationMessage } = assignment
43
43
 
44
44
  function handleDragStart(event: DragEvent, groupName: string) {
45
+ const transfer = event.dataTransfer
46
+ if (!transfer) return
47
+
45
48
  draggingGroup.value = groupName
46
- event.dataTransfer?.setData('groupName', groupName)
47
- event.dataTransfer!.effectAllowed = 'move'
49
+ transfer.setData('groupName', groupName)
50
+ transfer.effectAllowed = 'move'
48
51
  }
49
52
 
50
53
  function handleDragEnd() {
@@ -54,7 +57,9 @@ function handleDragEnd() {
54
57
 
55
58
  function handleDragOver(event: DragEvent, zone: GroupAssignmentZone) {
56
59
  event.preventDefault()
57
- event.dataTransfer!.dropEffect = 'move'
60
+ if (event.dataTransfer) {
61
+ event.dataTransfer.dropEffect = 'move'
62
+ }
58
63
  dragOverZone.value = zone
59
64
  }
60
65
 
@@ -28,10 +28,12 @@ const emit = defineEmits<{
28
28
  const isSliderMode = computed(() => props.min !== undefined && props.max !== undefined)
29
29
 
30
30
  const sliderPercent = computed(() => {
31
- if (!isSliderMode.value || props.modelValue === undefined) return 0
32
- const range = (props.max! - props.min!)
31
+ const min = props.min
32
+ const max = props.max
33
+ if (min === undefined || max === undefined || props.modelValue === undefined) return 0
34
+ const range = max - min
33
35
  if (range === 0) return 0
34
- return ((props.modelValue - props.min!) / range) * 100
36
+ return ((props.modelValue - min) / range) * 100
35
37
  })
36
38
 
37
39
  const canDecrement = computed(() => {
@@ -149,6 +149,7 @@ function canShowChildren(node: TreeNode, depth: number): boolean {
149
149
 
150
150
  // Render tree node recursively
151
151
  function renderNode(node: TreeNode, depth: number): VNode {
152
+ const children = node.children
152
153
  const expanded = isExpanded(node.id)
153
154
  const canExpand = hasChildren(node)
154
155
  const showChildNodes = canShowChildren(node, depth)
@@ -197,11 +198,11 @@ function renderNode(node: TreeNode, depth: number): VNode {
197
198
  )
198
199
 
199
200
  const childNodes =
200
- showChildNodes && node.children
201
+ showChildNodes && children
201
202
  ? h(
202
203
  Transition,
203
204
  { enterActiveClass: 'mint-sample-tree__children--entering', leaveActiveClass: 'mint-sample-tree__children--leaving' },
204
- () => h('div', { class: 'mint-sample-tree__children' }, node.children!.map((child) => renderNode(child, depth + 1)))
205
+ () => h('div', { class: 'mint-sample-tree__children' }, children.map((child) => renderNode(child, depth + 1)))
205
206
  )
206
207
  : null
207
208
 
@@ -416,6 +416,13 @@ function isControlModelBinding(model: ControlModel | ControlModelBinding): model
416
416
  </div>
417
417
  </component>
418
418
  </div>
419
+ <div v-if="$slots.footer" class="mint-settings-modal__footer">
420
+ <slot
421
+ name="footer"
422
+ :values="builder.form.data"
423
+ :close="handleClose"
424
+ />
425
+ </div>
419
426
  </BaseModal>
420
427
  </template>
421
428
 
@@ -47,8 +47,12 @@ const groupedUnits = computed(() => {
47
47
  const ungrouped: UnitOption[] = []
48
48
  for (const u of props.units) {
49
49
  if (u.group) {
50
- if (!groups.has(u.group)) groups.set(u.group, [])
51
- groups.get(u.group)!.push(u)
50
+ const group = groups.get(u.group)
51
+ if (group) {
52
+ group.push(u)
53
+ } else {
54
+ groups.set(u.group, [u])
55
+ }
52
56
  } else {
53
57
  ungrouped.push(u)
54
58
  }
@@ -759,10 +759,10 @@ const tableStyle = computed(() => ({
759
759
  <span
760
760
  v-if="getWellBadge(well)"
761
761
  class="mint-well-plate__badge"
762
- :style="{ backgroundColor: getWellBadge(well)!.color }"
763
- :title="getWellBadge(well)!.text === '+' ? 'Custom method' : `${getWellBadge(well)!.text}x injections`"
762
+ :style="{ backgroundColor: getWellBadge(well)?.color }"
763
+ :title="getWellBadge(well)?.text === '+' ? 'Custom method' : `${getWellBadge(well)?.text ?? ''}x injections`"
764
764
  >
765
- {{ getWellBadge(well)!.text }}
765
+ {{ getWellBadge(well)?.text }}
766
766
  </span>
767
767
  </div>
768
768
  </td>