@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
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  /** Interactive 96/384-well plate grid with drag-to-select, heatmap overlays, per-well editing, and sample-color mapping. */
3
3
  import { ref, computed } from 'vue'
4
- import type { WellPlateFormat, WellPlateSelectionMode, WellPlateSize, Well, HeatmapConfig, WellShape, WellEditField, WellEditData, WellLegendItem, ColumnCondition, RowCondition } from '../types'
4
+ import type { WellPlateFormat, WellPlateSelectionMode, WellPlateSize, Well, HeatmapConfig, WellShape, WellEditField, WellEditData, WellLegendItem, ColumnCondition, RowCondition, WellSampleDropData, WellSampleDropParser } from '../types'
5
5
  import { useEventListener } from '../composables/useEventListener'
6
6
  import WellEditPopupInternal from './internal/WellEditPopupInternal.vue'
7
7
 
@@ -29,6 +29,8 @@ interface Props {
29
29
  legendItems?: WellLegendItem[]
30
30
  columnConditions?: ColumnCondition[]
31
31
  rowConditions?: RowCondition[]
32
+ allowSampleDrop?: boolean
33
+ sampleDropParser?: WellSampleDropParser
32
34
  }
33
35
 
34
36
  // Drag state for moving wells
@@ -59,6 +61,8 @@ const props = withDefaults(defineProps<Props>(), {
59
61
  legendItems: undefined,
60
62
  columnConditions: () => [],
61
63
  rowConditions: () => [],
64
+ allowSampleDrop: false,
65
+ sampleDropParser: undefined,
62
66
  })
63
67
 
64
68
  const emit = defineEmits<{
@@ -70,6 +74,22 @@ const emit = defineEmits<{
70
74
  'well-move': [sourceWellId: string, targetWellId: string]
71
75
  'well-edit': [wellId: string, data: WellEditData]
72
76
  'well-clear': [wellId: string]
77
+ 'sample-drop': [wellId: string, data: WellSampleDropData, event: DragEvent]
78
+ }>()
79
+
80
+ interface WellEditorSlotProps {
81
+ wellId: string
82
+ wellData?: Partial<Well>
83
+ editFields: WellEditField[]
84
+ defaultInjectionVolume: number
85
+ position: { x: number; y: number }
86
+ save: (data: WellEditData) => void
87
+ clear: () => void
88
+ close: () => void
89
+ }
90
+
91
+ defineSlots<{
92
+ 'well-editor'?: (props: WellEditorSlotProps) => unknown
73
93
  }>()
74
94
 
75
95
  const plateRef = ref<HTMLElement | null>(null)
@@ -164,6 +184,7 @@ const defaultLegendItems: WellLegendItem[] = [
164
184
  { type: 'sample', label: 'Sample', color: '#10b981' },
165
185
  { type: 'blank', label: 'Blank', color: '#f97316' },
166
186
  { type: 'qc', label: 'QC', color: '#8b5cf6' },
187
+ { type: 'iqc', label: 'IQC', color: '#ec4899' },
167
188
  ]
168
189
 
169
190
  const activeLegendItems = computed(() => props.legendItems ?? defaultLegendItems)
@@ -285,6 +306,7 @@ const defaultSampleTypeColors: Record<string, { bg: string; border: string }> =
285
306
  control: { bg: 'rgba(59, 130, 246, 0.15)', border: 'rgba(59, 130, 246, 0.4)' },
286
307
  blank: { bg: 'rgba(249, 115, 22, 0.15)', border: 'rgba(249, 115, 22, 0.4)' },
287
308
  qc: { bg: 'rgba(139, 92, 246, 0.15)', border: 'rgba(139, 92, 246, 0.4)' },
309
+ iqc: { bg: 'rgba(236, 72, 153, 0.15)', border: 'rgba(236, 72, 153, 0.4)' },
288
310
  }
289
311
 
290
312
  const heatmapColors: Record<string, string[]> = {
@@ -373,6 +395,7 @@ function getSampleTypeIndicator(well: Well): string | null {
373
395
  control: 'C',
374
396
  blank: 'B',
375
397
  qc: 'Q',
398
+ iqc: 'I',
376
399
  }
377
400
  return typeMap[well.sampleType] || well.sampleType.charAt(0).toUpperCase()
378
401
  }
@@ -511,10 +534,12 @@ function handleDragStart(well: Well, event: DragEvent) {
511
534
  }
512
535
 
513
536
  function handleDragOver(well: Well, event: DragEvent) {
514
- if (props.selectionMode !== 'drag' || !dragSourceWell.value) return
537
+ const isMovingWell = props.selectionMode === 'drag' && !!dragSourceWell.value
538
+ if (!isMovingWell && !props.allowSampleDrop) return
539
+
515
540
  event.preventDefault()
516
541
  if (event.dataTransfer) {
517
- event.dataTransfer.dropEffect = 'move'
542
+ event.dataTransfer.dropEffect = isMovingWell ? 'move' : 'copy'
518
543
  }
519
544
  dragTargetWell.value = well.id
520
545
  }
@@ -524,14 +549,27 @@ function handleDragLeave() {
524
549
  }
525
550
 
526
551
  function handleDrop(well: Well, event: DragEvent) {
552
+ const isMovingWell = props.selectionMode === 'drag' && !!dragSourceWell.value
553
+ if (!isMovingWell && !props.allowSampleDrop) return
554
+
527
555
  event.preventDefault()
528
- if (props.selectionMode !== 'drag' || !dragSourceWell.value) return
529
556
 
530
- const sourceId = dragSourceWell.value
531
- const targetId = well.id
557
+ if (isMovingWell && dragSourceWell.value) {
558
+ const sourceId = dragSourceWell.value
559
+ const targetId = well.id
560
+
561
+ if (sourceId !== targetId) {
562
+ emit('well-move', sourceId, targetId)
563
+ }
532
564
 
533
- if (sourceId !== targetId) {
534
- emit('well-move', sourceId, targetId)
565
+ dragSourceWell.value = null
566
+ dragTargetWell.value = null
567
+ return
568
+ }
569
+
570
+ const droppedSample = parseDroppedSample(event, well.id)
571
+ if (droppedSample) {
572
+ emit('sample-drop', well.id, droppedSample, event)
535
573
  }
536
574
 
537
575
  dragSourceWell.value = null
@@ -543,6 +581,76 @@ function handleDragEnd() {
543
581
  dragTargetWell.value = null
544
582
  }
545
583
 
584
+ function parseDroppedSample(event: DragEvent, wellId: string): WellSampleDropData | null {
585
+ if (props.sampleDropParser) {
586
+ return props.sampleDropParser(event, { wellId, event }) ?? null
587
+ }
588
+
589
+ const transfer = event.dataTransfer
590
+ if (!transfer) return null
591
+
592
+ const json = transfer.getData('application/json') || transfer.getData('text/json')
593
+ if (json) {
594
+ try {
595
+ return normalizeDroppedSample(JSON.parse(json))
596
+ } catch {
597
+ return null
598
+ }
599
+ }
600
+
601
+ const text = transfer.getData('text/plain')?.trim()
602
+ if (!text) return null
603
+ return {
604
+ sampleName: text,
605
+ label: text,
606
+ raw: text,
607
+ }
608
+ }
609
+
610
+ function normalizeDroppedSample(raw: unknown): WellSampleDropData | null {
611
+ if (!raw || typeof raw !== 'object') return null
612
+
613
+ const record = raw as Record<string, unknown>
614
+ const sampleName =
615
+ getString(record.sampleName) ??
616
+ getString(record.sample_name) ??
617
+ getString(record.name) ??
618
+ getString(record.label)
619
+ const label = getString(record.label) ?? sampleName
620
+ const sampleType =
621
+ getString(record.sampleType) ??
622
+ getString(record.sample_type) ??
623
+ (record.type === 'sample' ? 'sample' : undefined)
624
+
625
+ if (!sampleName && !label && !getString(record.id)) return null
626
+
627
+ return {
628
+ id: getString(record.id),
629
+ sampleName,
630
+ label,
631
+ sampleType,
632
+ injectionVolume: getNumber(record.injectionVolume ?? record.injection_volume),
633
+ injectionCount: getNumber(record.injectionCount ?? record.injection_count),
634
+ customMethod: getString(record.customMethod ?? record.custom_method) ?? null,
635
+ metadata: getRecord(record.metadata),
636
+ raw,
637
+ }
638
+ }
639
+
640
+ function getString(value: unknown): string | undefined {
641
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
642
+ }
643
+
644
+ function getNumber(value: unknown): number | undefined {
645
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
646
+ }
647
+
648
+ function getRecord(value: unknown): Record<string, unknown> | undefined {
649
+ return value && typeof value === 'object' && !Array.isArray(value)
650
+ ? value as Record<string, unknown>
651
+ : undefined
652
+ }
653
+
546
654
  function handleKeyDown(event: KeyboardEvent) {
547
655
  if (props.disabled || props.readonly) return
548
656
 
@@ -726,9 +834,9 @@ const tableStyle = computed(() => ({
726
834
  @mouseleave="handleWellMouseLeave"
727
835
  @contextmenu="handleContextMenu(well, $event)"
728
836
  @dragstart="handleDragStart(well, $event)"
729
- @dragover.prevent="handleDragOver(well, $event)"
837
+ @dragover="handleDragOver(well, $event)"
730
838
  @dragleave="handleDragLeave"
731
- @drop.prevent="handleDrop(well, $event)"
839
+ @drop="handleDrop(well, $event)"
732
840
  @dragend="handleDragEnd"
733
841
  @keydown.enter="handleWellClick(well, $event as unknown as MouseEvent)"
734
842
  @keydown.space.prevent="handleWellClick(well, $event as unknown as MouseEvent)"
@@ -805,17 +913,30 @@ const tableStyle = computed(() => ({
805
913
  </div>
806
914
 
807
915
  <!-- Edit popup -->
808
- <WellEditPopupInternal
809
- v-if="editable && editingWellId"
810
- :well-id="editingWellId"
811
- :well-data="wells[editingWellId]"
812
- :edit-fields="editFields"
813
- :default-injection-volume="defaultInjectionVolume"
814
- :position="editPopupPosition"
815
- @save="handleEditSave"
816
- @clear="handleEditClear"
817
- @close="handleEditClose"
818
- />
916
+ <template v-if="editable && editingWellId">
917
+ <slot
918
+ name="well-editor"
919
+ :well-id="editingWellId"
920
+ :well-data="wells[editingWellId]"
921
+ :edit-fields="editFields"
922
+ :default-injection-volume="defaultInjectionVolume"
923
+ :position="editPopupPosition"
924
+ :save="handleEditSave"
925
+ :clear="handleEditClear"
926
+ :close="handleEditClose"
927
+ >
928
+ <WellEditPopupInternal
929
+ :well-id="editingWellId"
930
+ :well-data="wells[editingWellId]"
931
+ :edit-fields="editFields"
932
+ :default-injection-volume="defaultInjectionVolume"
933
+ :position="editPopupPosition"
934
+ @save="handleEditSave"
935
+ @clear="handleEditClear"
936
+ @close="handleEditClose"
937
+ />
938
+ </slot>
939
+ </template>
819
940
  </div>
820
941
  </template>
821
942
 
@@ -89,6 +89,11 @@ export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
89
89
  // Scientific display components
90
90
  export { default as ScientificNumber } from './ScientificNumber.vue'
91
91
  export { default as ChemicalFormula } from './ChemicalFormula.vue'
92
+ export { default as SequenceProgressBar } from './SequenceProgressBar.vue'
93
+ export { default as InstrumentAlertLog } from './InstrumentAlertLog.vue'
94
+ export { default as InstrumentStateBadge } from './InstrumentStateBadge.vue'
95
+ export { default as InstrumentStatusCard } from './InstrumentStatusCard.vue'
96
+ export { default as LcmsSequenceTable } from './LcmsSequenceTable.vue'
92
97
 
93
98
  // Scientific input components
94
99
  export { default as FormulaInput } from './FormulaInput.vue'
@@ -90,6 +90,7 @@ const sampleTypeButtons = [
90
90
  { type: 'sample', label: 'S', tooltip: 'Sample' },
91
91
  { type: 'blank', label: 'B', tooltip: 'Blank' },
92
92
  { type: 'qc', label: 'Q', tooltip: 'QC' },
93
+ { type: 'iqc', label: 'I', tooltip: 'IQC' },
93
94
  ]
94
95
  </script>
95
96
 
@@ -0,0 +1,182 @@
1
+ import type { SelectOption } from '../types'
2
+
3
+ export interface ExperimentDesignDataResponse {
4
+ experiment_id?: number
5
+ plugin_id?: string
6
+ data?: Record<string, unknown>
7
+ schema_version?: string
8
+ updated_at?: string | null
9
+ }
10
+
11
+ export interface ExtractExperimentSamplesOptions {
12
+ includeControls?: boolean
13
+ }
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ }
18
+
19
+ function isPlatformDesignDataResponse(value: Record<string, unknown>): boolean {
20
+ return (
21
+ 'data' in value
22
+ && (
23
+ 'experiment_id' in value
24
+ || 'plugin_id' in value
25
+ || 'schema_version' in value
26
+ || 'updated_at' in value
27
+ )
28
+ )
29
+ }
30
+
31
+ /** Return the plugin-defined design_data payload from common platform and plugin response shapes. */
32
+ export function unwrapExperimentDesignData(
33
+ rawData: Record<string, unknown> | null | undefined,
34
+ ): Record<string, unknown> | null {
35
+ if (!rawData) return null
36
+
37
+ if (isRecord(rawData.data) && isPlatformDesignDataResponse(rawData)) {
38
+ return rawData.data
39
+ }
40
+
41
+ if (isRecord(rawData.design_data)) {
42
+ return rawData.design_data
43
+ }
44
+
45
+ if (isRecord(rawData.designData)) {
46
+ return rawData.designData
47
+ }
48
+
49
+ return rawData
50
+ }
51
+
52
+ function shouldIncludeSample(record: Record<string, unknown>, options: ExtractExperimentSamplesOptions): boolean {
53
+ if (options.includeControls) return true
54
+ const rawType = record.sample_type ?? record.sampleType ?? record.type ?? record.well_type ?? record.wellType
55
+ const type = typeof rawType === 'string' ? rawType.toLowerCase() : ''
56
+ return !['blank', 'qc'].includes(type)
57
+ }
58
+
59
+ function readSampleValue(record: Record<string, unknown>): string | null {
60
+ const candidates = [
61
+ record.sample_name,
62
+ record.sampleName,
63
+ record.sample_id,
64
+ record.sampleId,
65
+ record.name,
66
+ record.id,
67
+ record.label,
68
+ ]
69
+
70
+ for (const candidate of candidates) {
71
+ if (typeof candidate === 'string' && candidate.trim()) {
72
+ return candidate
73
+ }
74
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
75
+ return String(candidate)
76
+ }
77
+ }
78
+
79
+ return null
80
+ }
81
+
82
+ function readSampleLabel(record: Record<string, unknown>, fallback: string): string {
83
+ const label = record.name ?? record.label ?? record.sample_name ?? record.sampleName
84
+ return typeof label === 'string' && label.trim() ? label : fallback
85
+ }
86
+
87
+ function readSampleDescription(record: Record<string, unknown>): string | undefined {
88
+ const parts = [
89
+ record.group,
90
+ record.batch,
91
+ record.batch_id,
92
+ record.batchId,
93
+ record.plate_id,
94
+ record.plateId,
95
+ record.well_id,
96
+ record.wellId,
97
+ ].filter((value): value is string | number =>
98
+ (typeof value === 'string' && value.trim().length > 0)
99
+ || typeof value === 'number',
100
+ )
101
+
102
+ return parts.length > 0 ? parts.map(String).join(' / ') : undefined
103
+ }
104
+
105
+ function addSampleRecord(
106
+ options: SelectOption<string>[],
107
+ seen: Set<string>,
108
+ value: unknown,
109
+ extractOptions: ExtractExperimentSamplesOptions,
110
+ ): void {
111
+ if (typeof value === 'string') {
112
+ const trimmed = value.trim()
113
+ if (trimmed && !seen.has(trimmed)) {
114
+ seen.add(trimmed)
115
+ options.push({ value: trimmed, label: trimmed })
116
+ }
117
+ return
118
+ }
119
+
120
+ if (!isRecord(value) || !shouldIncludeSample(value, extractOptions)) return
121
+
122
+ const sampleValue = readSampleValue(value)
123
+ if (!sampleValue || seen.has(sampleValue)) return
124
+
125
+ seen.add(sampleValue)
126
+ options.push({
127
+ value: sampleValue,
128
+ label: readSampleLabel(value, sampleValue),
129
+ description: readSampleDescription(value),
130
+ })
131
+ }
132
+
133
+ function addSampleArray(
134
+ options: SelectOption<string>[],
135
+ seen: Set<string>,
136
+ value: unknown,
137
+ extractOptions: ExtractExperimentSamplesOptions,
138
+ ): void {
139
+ if (!Array.isArray(value)) return
140
+ for (const sample of value) {
141
+ addSampleRecord(options, seen, sample, extractOptions)
142
+ }
143
+ }
144
+
145
+ /** Extract selectable sample options from raw or wrapped experiment design_data. */
146
+ export function extractSampleOptionsFromDesignData(
147
+ rawData: Record<string, unknown> | null | undefined,
148
+ options: ExtractExperimentSamplesOptions = {},
149
+ ): SelectOption<string>[] {
150
+ const designData = unwrapExperimentDesignData(rawData)
151
+ if (!designData) return []
152
+
153
+ const sampleOptions: SelectOption<string>[] = []
154
+ const seen = new Set<string>()
155
+
156
+ addSampleArray(sampleOptions, seen, designData.samples, options)
157
+
158
+ if (isRecord(designData.templates)) {
159
+ for (const template of Object.values(designData.templates)) {
160
+ if (!isRecord(template)) continue
161
+ const templateData = isRecord(template.data) ? template.data : template
162
+ addSampleArray(sampleOptions, seen, templateData.samples, options)
163
+ }
164
+ }
165
+
166
+ if (Array.isArray(designData.plates)) {
167
+ for (const plate of designData.plates) {
168
+ if (!isRecord(plate)) continue
169
+ addSampleArray(sampleOptions, seen, plate.samples, options)
170
+ }
171
+ }
172
+
173
+ return sampleOptions
174
+ }
175
+
176
+ /** Extract SampleSelector-compatible string values from raw or wrapped experiment design_data. */
177
+ export function extractSampleNamesFromDesignData(
178
+ rawData: Record<string, unknown> | null | undefined,
179
+ options: ExtractExperimentSamplesOptions = {},
180
+ ): string[] {
181
+ return extractSampleOptionsFromDesignData(rawData, options).map(sample => sample.value)
182
+ }
@@ -144,6 +144,20 @@ export {
144
144
  type UseExperimentDataOptions,
145
145
  type UseExperimentDataReturn,
146
146
  } from './useExperimentData'
147
+ export {
148
+ extractSampleNamesFromDesignData,
149
+ extractSampleOptionsFromDesignData,
150
+ unwrapExperimentDesignData,
151
+ type ExperimentDesignDataResponse,
152
+ type ExtractExperimentSamplesOptions,
153
+ } from './experimentDesignData'
154
+ export {
155
+ useExperimentSamples,
156
+ type ExperimentDesignDataSource,
157
+ type ExperimentIdSource,
158
+ type UseExperimentSamplesOptions,
159
+ type UseExperimentSamplesReturn,
160
+ } from './useExperimentSamples'
147
161
  export {
148
162
  getFieldRegistryEntry,
149
163
  getTypeDefault,
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, watch, getCurrentInstance, type Ref } from
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'
6
+ import type { RoleInfo } from '../permissions'
6
7
 
7
8
  interface UserResponse {
8
9
  id: string
@@ -10,6 +11,7 @@ interface UserResponse {
10
11
  shortname: string | null
11
12
  email: string | null
12
13
  role: string
14
+ role_obj?: RoleInfo | null
13
15
  is_active: boolean
14
16
  }
15
17
 
@@ -187,6 +189,7 @@ export function useAuth(): UseAuthReturn {
187
189
  shortname: response.data.shortname,
188
190
  email: response.data.email,
189
191
  role: response.data.role,
192
+ roleObj: response.data.role_obj ?? null,
190
193
  isActive: response.data.is_active,
191
194
  }
192
195
 
@@ -379,6 +382,7 @@ export function useAuth(): UseAuthReturn {
379
382
  shortname: response.data.shortname,
380
383
  email: response.data.email,
381
384
  role: response.data.role,
385
+ roleObj: response.data.role_obj ?? null,
382
386
  isActive: response.data.is_active,
383
387
  }
384
388
  authStore.setUserInfo(userInfo)
@@ -9,6 +9,7 @@ import type {
9
9
  ParsedCsvData,
10
10
  } from '../types/auto-group'
11
11
  import type { SampleGroup } from '../types/components'
12
+ import { unwrapExperimentDesignData } from './experimentDesignData'
12
13
 
13
14
  export const DEFAULT_COLORS = [
14
15
  '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
@@ -311,7 +312,10 @@ export function computeGroups(
311
312
  export function extractSamplesFromDesignData(
312
313
  rawData: Record<string, unknown>,
313
314
  ): ParsedCsvData | null {
314
- const samples = rawData.samples
315
+ const designData = unwrapExperimentDesignData(rawData)
316
+ if (!designData) return null
317
+
318
+ const samples = designData.samples
315
319
  if (!Array.isArray(samples) || samples.length === 0) return null
316
320
 
317
321
  // Single pass: filter QC/blank and collect all condition keys
@@ -6,6 +6,7 @@ import type {
6
6
  SettingsModalSchema,
7
7
  TopBarSettingsConfig,
8
8
  } from '../types'
9
+ import type { AccessPolicy, AccessAudienceInput } from '../permissions'
9
10
  import type {
10
11
  FieldCondition,
11
12
  FieldValidation,
@@ -34,6 +35,11 @@ export interface ControlSectionConfig {
34
35
  iconBg?: string
35
36
  columns?: 1 | 2 | 3
36
37
  condition?: FieldCondition
38
+ access?: AccessPolicy
39
+ visibleFor?: AccessAudienceInput
40
+ requiresAdmin?: boolean
41
+ permissions?: readonly string[]
42
+ anyPermissions?: readonly string[]
37
43
  defaultOpen?: boolean
38
44
  showToggle?: boolean
39
45
  }
@@ -70,6 +76,11 @@ export interface ControlDefinition {
70
76
  pattern?: string | { value: string; message: string }
71
77
  validation?: FieldValidation
72
78
  condition?: FieldCondition
79
+ access?: AccessPolicy
80
+ visibleFor?: AccessAudienceInput
81
+ requiresAdmin?: boolean
82
+ permissions?: readonly string[]
83
+ anyPermissions?: readonly string[]
73
84
  options?: readonly ControlOption[]
74
85
  section?: string
75
86
  sectionLabel?: string
@@ -760,6 +771,11 @@ export function controlsToSettingsSchema(
760
771
  fields: sectionControls.map(controlToFormField),
761
772
  columns: config.columns ?? options.columns ?? 1,
762
773
  condition: config.condition,
774
+ access: config.access,
775
+ visibleFor: config.visibleFor,
776
+ requiresAdmin: config.requiresAdmin,
777
+ permissions: config.permissions,
778
+ anyPermissions: config.anyPermissions,
763
779
  }
764
780
  }),
765
781
  }
@@ -1150,6 +1166,11 @@ function controlToFormField(control: NormalizedControl): FormFieldSchema {
1150
1166
  readonly: definition.readonly,
1151
1167
  validation: validationForControl(definition),
1152
1168
  condition: definition.condition,
1169
+ access: definition.access,
1170
+ visibleFor: definition.visibleFor,
1171
+ requiresAdmin: definition.requiresAdmin,
1172
+ permissions: definition.permissions,
1173
+ anyPermissions: definition.anyPermissions,
1153
1174
  colSpan: definition.colSpan,
1154
1175
  props,
1155
1176
  }