@morscherlab/mld-sdk 0.9.6 → 0.9.8

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 (33) hide show
  1. package/dist/components/ExperimentPopover.vue.d.ts +4 -0
  2. package/dist/components/ExperimentPopover.vue.js +31 -17
  3. package/dist/components/ExperimentPopover.vue.js.map +1 -1
  4. package/dist/components/FormulaInput.vue.js +24 -18
  5. package/dist/components/FormulaInput.vue.js.map +1 -1
  6. package/dist/components/MoleculeInput.vue.js +15 -6
  7. package/dist/components/MoleculeInput.vue.js.map +1 -1
  8. package/dist/components/PlateMapEditor.vue.js +1 -1
  9. package/dist/components/PlateMapEditor.vue.js.map +1 -1
  10. package/dist/composables/useAuth.js +26 -25
  11. package/dist/composables/useAuth.js.map +1 -1
  12. package/dist/composables/useAutoGroup.js +7 -32
  13. package/dist/composables/useAutoGroup.js.map +1 -1
  14. package/dist/composables/useForm.js +1 -1
  15. package/dist/composables/useForm.js.map +1 -1
  16. package/dist/composables/useWellPlateEditor.d.ts +1 -0
  17. package/dist/composables/useWellPlateEditor.js +21 -10
  18. package/dist/composables/useWellPlateEditor.js.map +1 -1
  19. package/dist/stores/settings.js +17 -30
  20. package/dist/stores/settings.js.map +1 -1
  21. package/dist/styles.css +68 -14
  22. package/package.json +1 -1
  23. package/src/components/ExperimentPopover.vue +16 -3
  24. package/src/components/FormulaInput.vue +17 -16
  25. package/src/components/MoleculeInput.vue +29 -14
  26. package/src/components/PlateMapEditor.vue +1 -1
  27. package/src/composables/useAuth.ts +29 -31
  28. package/src/composables/useAutoGroup.ts +7 -33
  29. package/src/composables/useForm.ts +1 -1
  30. package/src/composables/useWellPlateEditor.ts +22 -10
  31. package/src/stores/settings.ts +22 -38
  32. package/src/styles/components/experiment-popover.css +25 -1
  33. package/src/styles/components/formula-input.css +13 -6
@@ -112,7 +112,7 @@ watch(
112
112
  watch(
113
113
  () => props.modelValue,
114
114
  (newValue) => {
115
- if (newValue) editor.reset()
115
+ if (newValue) editor.loadState(newValue)
116
116
  }
117
117
  )
118
118
 
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios'
2
- import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue'
2
+ import { ref, onMounted, onUnmounted, watch, getCurrentInstance, type Ref } from 'vue'
3
3
  import { useAuthStore } from '../stores/auth'
4
4
  import { useSettingsStore } from '../stores/settings'
5
5
  import type { AuthConfig, UserInfo, LoginResponse, TokenVerifyResponse, UpdateProfileRequest } from '../types'
@@ -66,8 +66,6 @@ export function useAuth(): UseAuthReturn {
66
66
  const authStore = useAuthStore()
67
67
  const settingsStore = useSettingsStore()
68
68
 
69
- // Track refresh timer (module-level to prevent timer multiplication)
70
- const refreshTimerId = ref<number | null>(_refreshTimerId)
71
69
  const isRefreshing = ref(false)
72
70
 
73
71
  function getApiBaseUrl(): string {
@@ -281,7 +279,7 @@ export function useAuth(): UseAuthReturn {
281
279
 
282
280
  // Schedule refresh
283
281
  const delay = refreshAt - now
284
- refreshTimerId.value = window.setTimeout(() => {
282
+ _refreshTimerId = window.setTimeout(() => {
285
283
  refreshToken()
286
284
  }, delay)
287
285
  }
@@ -290,9 +288,9 @@ export function useAuth(): UseAuthReturn {
290
288
  * Stop automatic token refresh.
291
289
  */
292
290
  function stopTokenRefresh(): void {
293
- if (refreshTimerId.value !== null) {
294
- window.clearTimeout(refreshTimerId.value)
295
- refreshTimerId.value = null
291
+ if (_refreshTimerId !== null) {
292
+ window.clearTimeout(_refreshTimerId)
293
+ _refreshTimerId = null
296
294
  }
297
295
  }
298
296
 
@@ -382,34 +380,34 @@ export function useAuth(): UseAuthReturn {
382
380
  }
383
381
  }
384
382
 
385
- // Set up periodic check as safety net
383
+ // Set up periodic check as safety net (only inside component setup)
386
384
  let checkInterval: number | null = null
387
385
 
388
- onMounted(() => {
389
- // Start periodic check
390
- checkInterval = window.setInterval(checkAndRefreshIfNeeded, TOKEN_REFRESH_CHECK_INTERVAL_MS)
391
- })
386
+ if (getCurrentInstance()) {
387
+ onMounted(() => {
388
+ checkInterval = window.setInterval(checkAndRefreshIfNeeded, TOKEN_REFRESH_CHECK_INTERVAL_MS)
389
+ })
392
390
 
393
- onUnmounted(() => {
394
- // Clean up on unmount
395
- stopTokenRefresh()
396
- if (checkInterval !== null) {
397
- window.clearInterval(checkInterval)
398
- checkInterval = null
399
- }
400
- })
401
-
402
- // Watch for token changes to reschedule refresh
403
- watch(
404
- () => authStore.tokenExpires,
405
- (newExpires) => {
406
- if (newExpires) {
407
- scheduleTokenRefresh()
408
- } else {
409
- stopTokenRefresh()
391
+ onUnmounted(() => {
392
+ stopTokenRefresh()
393
+ if (checkInterval !== null) {
394
+ window.clearInterval(checkInterval)
395
+ checkInterval = null
410
396
  }
411
- }
412
- )
397
+ })
398
+
399
+ // Watch for token changes to reschedule refresh
400
+ watch(
401
+ () => authStore.tokenExpires,
402
+ (newExpires) => {
403
+ if (newExpires) {
404
+ scheduleTokenRefresh()
405
+ } else {
406
+ stopTokenRefresh()
407
+ }
408
+ }
409
+ )
410
+ }
413
411
 
414
412
  return {
415
413
  // Core auth methods
@@ -331,24 +331,9 @@ export function useAutoGroup() {
331
331
  }))
332
332
  })
333
333
 
334
- const groups = computed(() => {
334
+ const _computedResult = computed(() => {
335
335
  if (effectiveColumns.value.length === 0 || enabledFields.value.size === 0) {
336
- return []
337
- }
338
- const result = computeGroups(
339
- samples.value,
340
- effectiveColumns.value,
341
- enabledFields.value,
342
- outlierActions.value,
343
- delimiter.value,
344
- minFieldCount.value,
345
- )
346
- return result.groups
347
- })
348
-
349
- const metadata = computed(() => {
350
- if (effectiveColumns.value.length === 0 || enabledFields.value.size === 0) {
351
- return []
336
+ return { groups: [] as SampleGroup[], metadata: [] as MetadataRow[], excludedSamples: [] as string[] }
352
337
  }
353
338
  return computeGroups(
354
339
  samples.value,
@@ -357,25 +342,14 @@ export function useAutoGroup() {
357
342
  outlierActions.value,
358
343
  delimiter.value,
359
344
  minFieldCount.value,
360
- ).metadata
345
+ )
361
346
  })
362
347
 
363
- const excludedSamples = computed(() => {
364
- return computeGroups(
365
- samples.value,
366
- effectiveColumns.value,
367
- enabledFields.value,
368
- outlierActions.value,
369
- delimiter.value,
370
- minFieldCount.value,
371
- ).excludedSamples
372
- })
348
+ const groups = computed(() => _computedResult.value.groups)
349
+ const metadata = computed(() => _computedResult.value.metadata)
350
+ const excludedSamples = computed(() => _computedResult.value.excludedSamples)
373
351
 
374
- const result = computed<AutoGroupResult>(() => ({
375
- groups: groups.value,
376
- metadata: metadata.value,
377
- excludedSamples: excludedSamples.value,
378
- }))
352
+ const result = computed<AutoGroupResult>(() => _computedResult.value)
379
353
 
380
354
  function parseInput() {
381
355
  if (inputMode.value === 'csv' && csvData.value) {
@@ -354,7 +354,7 @@ export function useForm<T extends Record<string, unknown>>(
354
354
  function reset(values?: Partial<T>): void {
355
355
  const resetValues = values ? { ..._initialValues, ...values } : _initialValues
356
356
  for (const key of Object.keys(data)) {
357
- ;(data as Record<string, unknown>)[key] = resetValues[key as keyof T]
357
+ ;(data as Record<string, unknown>)[key] = structuredClone(resetValues[key as keyof T])
358
358
  errors[key] = null
359
359
  touched[key] = false
360
360
  dirty[key] = false
@@ -52,6 +52,7 @@ export interface UseWellPlateEditorReturn {
52
52
  redo: () => void
53
53
  exportData: (format: 'json' | 'csv') => string
54
54
  importData: (data: string, format: 'json' | 'csv') => boolean
55
+ loadState: (state: Partial<PlateMapEditorState>) => void
55
56
  reset: () => void
56
57
  }
57
58
 
@@ -103,14 +104,10 @@ export function useWellPlateEditor(
103
104
  const canUndo = computed(() => historyIndex.value >= 0)
104
105
  const canRedo = computed(() => historyIndex.value < history.value.length - 1)
105
106
 
106
- function deepClone<T>(obj: T): T {
107
- return structuredClone(obj)
108
- }
109
-
110
107
  function saveToHistory() {
111
108
  const entry: HistoryEntry = {
112
- plates: deepClone(internalState.value.plates),
113
- samples: deepClone(internalState.value.samples),
109
+ plates: structuredClone(internalState.value.plates),
110
+ samples: structuredClone(internalState.value.samples),
114
111
  }
115
112
 
116
113
  if (historyIndex.value < history.value.length - 1) {
@@ -257,8 +254,8 @@ export function useWellPlateEditor(
257
254
  const entry = history.value[historyIndex.value]
258
255
  historyIndex.value--
259
256
 
260
- internalState.value.plates = deepClone(entry.plates)
261
- internalState.value.samples = deepClone(entry.samples)
257
+ internalState.value.plates = structuredClone(entry.plates)
258
+ internalState.value.samples = structuredClone(entry.samples)
262
259
 
263
260
  const activePlateExists = internalState.value.plates.some(p => p.id === internalState.value.activePlateId)
264
261
  if (!activePlateExists) {
@@ -273,8 +270,8 @@ export function useWellPlateEditor(
273
270
  historyIndex.value++
274
271
  const entry = history.value[historyIndex.value]
275
272
 
276
- internalState.value.plates = deepClone(entry.plates)
277
- internalState.value.samples = deepClone(entry.samples)
273
+ internalState.value.plates = structuredClone(entry.plates)
274
+ internalState.value.samples = structuredClone(entry.samples)
278
275
  internalState.value.selectedWells = []
279
276
  }
280
277
 
@@ -368,6 +365,20 @@ export function useWellPlateEditor(
368
365
  }
369
366
  }
370
367
 
368
+ function loadState(state: Partial<PlateMapEditorState>) {
369
+ saveToHistory()
370
+ if (state.plates && state.plates.length > 0) {
371
+ internalState.value.plates = structuredClone(state.plates)
372
+ internalState.value.activePlateId = state.activePlateId ?? state.plates[0].id
373
+ }
374
+ if (state.samples) {
375
+ internalState.value.samples = structuredClone(state.samples)
376
+ }
377
+ internalState.value.selectedWells = state.selectedWells ?? []
378
+ internalState.value.activeSampleId = state.activeSampleId
379
+ updateSampleCounts()
380
+ }
381
+
371
382
  function reset() {
372
383
  const plate = createEmptyPlate('Plate 1', defaultFormat)
373
384
  internalState.value = {
@@ -403,6 +414,7 @@ export function useWellPlateEditor(
403
414
  redo,
404
415
  exportData,
405
416
  importData,
417
+ loadState,
406
418
  reset,
407
419
  }
408
420
  }
@@ -33,8 +33,12 @@ function getDefaultServerHost(): string {
33
33
  }
34
34
 
35
35
  function getDefaultServerPort(): number {
36
- if (typeof window !== 'undefined' && window.location.port) {
37
- return parseInt(window.location.port, 10)
36
+ if (typeof window !== 'undefined') {
37
+ if (window.location.port) {
38
+ return parseInt(window.location.port, 10)
39
+ }
40
+ // Standard ports: 443 for HTTPS, 80 for HTTP (browser omits from location.port)
41
+ return window.location.protocol === 'https:' ? 443 : 80
38
42
  }
39
43
  return 8000
40
44
  }
@@ -92,34 +96,26 @@ export const useSettingsStore = defineStore('mld-settings', () => {
92
96
  // API prefix - can be configured via env variable VITE_API_PREFIX
93
97
  const apiPrefix: string = (import.meta.env?.VITE_API_PREFIX as string | undefined) ?? '/api'
94
98
 
95
- function getApiBaseUrl(): string {
99
+ function _isSameOrigin(): { same: boolean; currentHost: string; currentPort: string } {
96
100
  const currentHost = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
97
101
  const currentPort = typeof window !== 'undefined' ? window.location.port : '8000'
102
+ const effectivePort = currentPort || (window.location.protocol === 'https:' ? '443' : '80')
103
+ const same =
104
+ (serverHost.value === currentHost && String(serverPort.value) === effectivePort) ||
105
+ (serverHost.value === 'localhost' && currentHost !== 'localhost')
106
+ return { same, currentHost, currentPort }
107
+ }
98
108
 
99
- if (serverHost.value === currentHost && String(serverPort.value) === currentPort) {
100
- return apiPrefix
101
- }
102
-
103
- if (serverHost.value === 'localhost' && currentHost !== 'localhost') {
104
- return apiPrefix
105
- }
106
-
109
+ function getApiBaseUrl(): string {
110
+ const { same } = _isSameOrigin()
111
+ if (same) return apiPrefix
107
112
  return `http://${serverHost.value}:${serverPort.value}${apiPrefix}`
108
113
  }
109
114
 
110
115
  function getWsBaseUrl(): string {
111
- const currentHost = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
112
- const currentPort = typeof window !== 'undefined' ? window.location.port : '8000'
116
+ const { same, currentHost, currentPort } = _isSameOrigin()
113
117
  const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'
114
-
115
- if (serverHost.value === currentHost && String(serverPort.value) === currentPort) {
116
- return `${protocol}//${currentHost}:${currentPort}${apiPrefix}`
117
- }
118
-
119
- if (serverHost.value === 'localhost' && currentHost !== 'localhost') {
120
- return `${protocol}//${currentHost}:${currentPort}${apiPrefix}`
121
- }
122
-
118
+ if (same) return `${protocol}//${currentHost}${currentPort ? ':' + currentPort : ''}${apiPrefix}`
123
119
  return `ws://${serverHost.value}:${serverPort.value}${apiPrefix}`
124
120
  }
125
121
 
@@ -138,19 +134,10 @@ export const useSettingsStore = defineStore('mld-settings', () => {
138
134
  }
139
135
 
140
136
  function applyTheme() {
141
- let isDark = false
142
-
143
- if (theme.value === 'system') {
144
- isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
145
- } else {
146
- isDark = theme.value === 'dark'
147
- }
148
-
149
- if (isDark) {
150
- document.documentElement.classList.add('dark')
151
- } else {
152
- document.documentElement.classList.remove('dark')
153
- }
137
+ const dark = theme.value === 'system'
138
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
139
+ : theme.value === 'dark'
140
+ document.documentElement.classList.toggle('dark', dark)
154
141
  }
155
142
 
156
143
  watch(theme, () => {
@@ -206,7 +193,6 @@ export const useSettingsStore = defineStore('mld-settings', () => {
206
193
  }
207
194
 
208
195
  return {
209
- // State
210
196
  serverHost,
211
197
  serverPort,
212
198
  requestTimeout,
@@ -215,8 +201,6 @@ export const useSettingsStore = defineStore('mld-settings', () => {
215
201
  theme,
216
202
  colorPalette,
217
203
  tableDensity,
218
-
219
- // Actions
220
204
  initialize,
221
205
  applyTheme,
222
206
  persistSettings,
@@ -181,8 +181,15 @@
181
181
  color: var(--text-muted, var(--mld-text-muted, #94a3b8));
182
182
  }
183
183
 
184
- .mld-experiment-popover__change-btn {
184
+ .mld-experiment-popover__card-actions {
185
+ display: flex;
186
+ flex-direction: column;
187
+ align-items: flex-end;
188
+ gap: 0.125rem;
185
189
  flex-shrink: 0;
190
+ }
191
+
192
+ .mld-experiment-popover__change-btn {
186
193
  padding: 0.25rem 0.375rem;
187
194
  border: none;
188
195
  background: transparent;
@@ -198,6 +205,23 @@
198
205
  background: rgba(99, 102, 241, 0.08);
199
206
  }
200
207
 
208
+ .mld-experiment-popover__detach-btn {
209
+ padding: 0.25rem 0.375rem;
210
+ border: none;
211
+ background: transparent;
212
+ border-radius: 0.25rem;
213
+ color: var(--text-muted, var(--mld-text-muted, #94a3b8));
214
+ font-size: 0.6875rem;
215
+ font-weight: 400;
216
+ cursor: pointer;
217
+ transition: color 0.15s ease, background-color 0.15s ease;
218
+ }
219
+
220
+ .mld-experiment-popover__detach-btn:hover {
221
+ color: #ef4444;
222
+ background: rgba(239, 68, 68, 0.08);
223
+ }
224
+
201
225
  /* Divider */
202
226
  .mld-experiment-popover__divider {
203
227
  height: 1px;
@@ -65,22 +65,29 @@
65
65
  background-color: var(--bg-tertiary);
66
66
  }
67
67
 
68
- /* Preview */
68
+ /* Preview row */
69
69
  .mld-formula-input__preview {
70
- padding: 0.375rem 0.75rem;
70
+ padding: 0.25rem 0.75rem;
71
71
  background-color: var(--bg-tertiary);
72
72
  border-top: 1px solid var(--border-color);
73
- font-size: 1.125rem;
74
- min-height: 1.75rem;
73
+ min-height: 1.5rem;
75
74
  display: flex;
76
75
  align-items: center;
76
+ justify-content: space-between;
77
+ gap: 0.75rem;
78
+ }
79
+
80
+ .mld-formula-input__preview-formula {
81
+ font-size: 1rem;
82
+ line-height: 1.4;
77
83
  }
78
84
 
79
85
  /* Molecular weight display */
80
86
  .mld-formula-input__mw {
81
- font-size: 0.75rem;
87
+ font-size: 0.6875rem;
82
88
  color: var(--text-muted);
83
- padding-left: 0.125rem;
89
+ white-space: nowrap;
90
+ flex-shrink: 0;
84
91
  }
85
92
 
86
93
  .mld-formula-input__mw::before {