@morscherlab/mint-sdk 1.1.8 → 1.1.10

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 (91) hide show
  1. package/dist/__tests__/components/RetiredStyles.test.d.ts +1 -0
  2. package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
  4. package/dist/components/AppTopBar.vue.d.ts +6 -11
  5. package/dist/components/AutoGroupModal.adapter.d.ts +22 -7
  6. package/dist/components/PluginIcon.vue.d.ts +2 -2
  7. package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
  8. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
  9. package/dist/components/SampleSelector.groups.d.ts +7 -7
  10. package/dist/components/SampleSelector.selection.d.ts +6 -12
  11. package/dist/components/SampleSelector.tree.d.ts +39 -0
  12. package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
  13. package/dist/components/SmartGroup.types.d.ts +5 -0
  14. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
  15. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
  16. package/dist/components/index.js +1 -1
  17. package/dist/{components-CDomhGSj.js → components-HXV-ZkBg.js} +939 -948
  18. package/dist/components-HXV-ZkBg.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +1 -1
  21. package/dist/composables/useAutoGroup.d.ts +3 -0
  22. package/dist/composables/usePlatformContext.d.ts +3 -0
  23. package/dist/composables/useSampleGroups.d.ts +25 -0
  24. package/dist/index.js +2 -2
  25. package/dist/install.js +1 -1
  26. package/dist/styles.css +130 -557
  27. package/dist/types/platform.d.ts +1 -0
  28. package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-D3IZKLqr.js} +198 -24
  29. package/dist/useProtocolTemplates-D3IZKLqr.js.map +1 -0
  30. package/dist/utils/pluginIcon.d.ts +1 -1
  31. package/dist/utils/sampleGroupPath.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
  34. package/src/__tests__/components/AppTopBar.test.ts +84 -10
  35. package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
  36. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +47 -7
  37. package/src/__tests__/components/PluginIcon.test.ts +19 -0
  38. package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
  39. package/src/__tests__/components/RetiredStyles.test.ts +82 -0
  40. package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
  41. package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
  42. package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
  43. package/src/__tests__/components/SampleSelector.test.ts +35 -0
  44. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  45. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  46. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +278 -0
  47. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  48. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  49. package/src/components/AppTopBar.story.vue +9 -7
  50. package/src/components/AppTopBar.vue +79 -72
  51. package/src/components/AutoGroupModal.adapter.ts +69 -13
  52. package/src/components/AutoGroupModal.story.vue +89 -0
  53. package/src/components/AutoGroupModal.vue +18 -8
  54. package/src/components/PluginIcon.story.vue +11 -9
  55. package/src/components/PluginIcon.vue +3 -3
  56. package/src/components/PluginWorkspaceView.props.ts +5 -7
  57. package/src/components/PluginWorkspaceView.vue +0 -4
  58. package/src/components/SampleSelector.drag.ts +5 -5
  59. package/src/components/SampleSelector.groups.ts +15 -15
  60. package/src/components/SampleSelector.selection.ts +7 -46
  61. package/src/components/SampleSelector.story.vue +37 -0
  62. package/src/components/SampleSelector.tree.ts +48 -0
  63. package/src/components/SampleSelector.vue +52 -320
  64. package/src/components/SampleSelectorGroupNode.vue +197 -0
  65. package/src/components/SmartGroup.types.ts +5 -0
  66. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  67. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  68. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  69. package/src/composables/autoGroup/compose.ts +31 -10
  70. package/src/composables/index.ts +1 -0
  71. package/src/composables/useAutoGroup.ts +112 -5
  72. package/src/composables/useSampleGroups.ts +118 -33
  73. package/src/styles/components/app-avatar-menu.css +2 -1
  74. package/src/styles/components/app-plugin-switcher.css +0 -13
  75. package/src/styles/components/app-top-bar.css +13 -54
  76. package/src/styles/components/dose-calculator.css +0 -4
  77. package/src/styles/components/experiment-popover.css +2 -104
  78. package/src/styles/components/experiment-selector-modal.css +0 -7
  79. package/src/styles/components/plugin-icon.css +3 -0
  80. package/src/styles/components/protocol-step-editor.css +0 -6
  81. package/src/styles/components/reagent-editor.css +0 -5
  82. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  83. package/src/styles/components/sample-selector.css +0 -96
  84. package/src/styles/components/schedule-calendar.css +0 -6
  85. package/src/styles/components/smart-group.css +22 -0
  86. package/src/styles/components/well-plate.css +1 -27
  87. package/src/types/platform.ts +1 -0
  88. package/src/utils/pluginIcon.ts +5 -2
  89. package/src/utils/sampleGroupPath.ts +19 -0
  90. package/dist/components-CDomhGSj.js.map +0 -1
  91. package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
@@ -5,10 +5,12 @@ import BaseButton from './BaseButton.vue'
5
5
  import BaseInput from './BaseInput.vue'
6
6
  import AutoGroupModal from './AutoGroupModal.vue'
7
7
  import SampleSelectorSampleRow from './SampleSelectorSampleRow.vue'
8
+ import SampleSelectorGroupNode from './SampleSelectorGroupNode.vue'
9
+ import { collectNodeKeys, type SampleGroupTreeContext } from './SampleSelector.tree'
8
10
  import type { SampleGroup } from '../types'
9
11
  import type { AutoGroupResult } from '../types/auto-group'
10
12
  import { useTextSearch } from '../composables/useTextSearch'
11
- import { useSampleGroups, type SampleMajorGroup } from '../composables/useSampleGroups'
13
+ import { useSampleGroups } from '../composables/useSampleGroups'
12
14
  import { useExpansionSet } from '../composables/useExpansionSet'
13
15
  import { useExperimentSamples } from '../composables/useExperimentSamples'
14
16
  import { useSampleSelectorDrag } from './SampleSelector.drag'
@@ -21,8 +23,7 @@ import {
21
23
  } from './SampleSelector.colors'
22
24
  import {
23
25
  removeSampleFromGroup as removeSampleFromSampleGroup,
24
- removeSampleGroup,
25
- removeSampleMajorGroup,
26
+ removeSampleGroups,
26
27
  } from './SampleSelector.groups'
27
28
 
28
29
  interface Props {
@@ -105,8 +106,7 @@ const sampleGroups = useSampleGroups({
105
106
  samples: () => resolvedSamples.value,
106
107
  groups: internalGroups,
107
108
  })
108
- const hierarchicalGroups = sampleGroups.hierarchicalGroups
109
- const showHierarchy = sampleGroups.showHierarchy
109
+ const groupTree = sampleGroups.groupTree
110
110
 
111
111
  const groupingEnabled = computed(() => internalGroups.value.length > 0)
112
112
  const ungroupedSamples = sampleGroups.ungroupedSamples
@@ -123,16 +123,12 @@ const {
123
123
  isAllSelected,
124
124
  toggleSelectAll,
125
125
  toggleSample,
126
- toggleGroupSamples,
127
- toggleMajorGroupSamples,
128
- isGroupFullySelected,
129
- isGroupPartiallySelected,
130
- isMajorGroupFullySelected,
131
- isMajorGroupPartiallySelected,
126
+ toggleSamplesSelection,
127
+ isFullySelected,
128
+ isPartiallySelected,
132
129
  } = useSampleSelectorSelection({
133
130
  selected: () => props.modelValue,
134
131
  samples: () => resolvedSamples.value,
135
- findGroup: sampleGroups.findGroup,
136
132
  emitSelected: samples => emit('update:modelValue', samples),
137
133
  })
138
134
 
@@ -146,15 +142,7 @@ function isGroupExpanded(groupName: string): boolean {
146
142
  }
147
143
 
148
144
  function expandAllGroups() {
149
- const expanded: string[] = []
150
- for (const major of hierarchicalGroups.value) {
151
- expanded.push(`major:${major.name}`)
152
- for (const sub of major.subGroups) {
153
- expanded.push(sub.name)
154
- }
155
- }
156
- expanded.push('__ungrouped__')
157
- groupExpansion.setExpanded(expanded)
145
+ groupExpansion.setExpanded([...collectNodeKeys(groupTree.value), '__ungrouped__'])
158
146
  }
159
147
 
160
148
  function collapseAllGroups() {
@@ -177,12 +165,8 @@ function clearGroups() {
177
165
  internalGroups.value = []
178
166
  }
179
167
 
180
- function deleteMajorGroup(majorGroup: SampleMajorGroup) {
181
- internalGroups.value = removeSampleMajorGroup(internalGroups.value, majorGroup)
182
- }
183
-
184
- function deleteGroup(groupName: string) {
185
- internalGroups.value = removeSampleGroup(internalGroups.value, groupName)
168
+ function deleteGroups(groupNames: string[]) {
169
+ internalGroups.value = removeSampleGroups(internalGroups.value, groupNames)
186
170
  }
187
171
 
188
172
  function removeSampleFromGroup(sample: string, groupName: string) {
@@ -190,15 +174,11 @@ function removeSampleFromGroup(sample: string, groupName: string) {
190
174
  }
191
175
 
192
176
  // Color picker
193
- function openColorPicker(groupName: string, event: Event) {
177
+ function openColorPicker(groupNames: string[], event: Event) {
194
178
  event.stopPropagation()
195
- editingColor.value = { kind: 'single', name: groupName }
196
- colorPickerInput.value?.click()
197
- }
198
-
199
- function openMajorGroupColorPicker(majorGroup: SampleMajorGroup, event: Event) {
200
- event.stopPropagation()
201
- editingColor.value = { kind: 'family', names: majorGroup.subGroups.map(sg => sg.name) }
179
+ editingColor.value = groupNames.length === 1
180
+ ? { kind: 'single', name: groupNames[0] }
181
+ : { kind: 'family', names: groupNames }
202
182
  colorPickerInput.value?.click()
203
183
  }
204
184
 
@@ -219,6 +199,36 @@ const colorPickerSeed = computed(() => {
219
199
  return getSampleGroupColorEditSeed(editingColor.value, getGroupColor)
220
200
  })
221
201
 
202
+ // Bundle of handlers handed to every level of the recursive group tree.
203
+ const treeContext = computed<SampleGroupTreeContext>(() => ({
204
+ isExpanded: isGroupExpanded,
205
+ toggleExpanded: toggleGroupExpanded,
206
+ isSampleSelected: sample => props.modelValue.includes(sample),
207
+ isFullySelected,
208
+ isPartiallySelected,
209
+ toggleSamples: toggleSamplesSelection,
210
+ toggleSample,
211
+ removeGroups: deleteGroups,
212
+ removeSampleFromGroup,
213
+ openColorPicker,
214
+ draggingSample: () => draggingSample.value,
215
+ dragOverGroup: () => dragOverGroup.value,
216
+ draggingGroup: () => draggingGroup.value,
217
+ draggingGroupKind: () => draggingGroupKind.value,
218
+ reorderTarget: () => reorderTarget.value,
219
+ reorderPosition: () => reorderPosition.value,
220
+ onSampleDragStart: handleDragStart,
221
+ onSampleDragEnd: handleDragEnd,
222
+ onSampleDragOver: handleDragOver,
223
+ onSampleDragLeave: handleDragLeave,
224
+ onSampleDrop: handleDrop,
225
+ onGroupDragStart: handleGroupDragStart,
226
+ onGroupDragEnd: handleGroupDragEnd,
227
+ onGroupDragOver: handleGroupDragOver,
228
+ onGroupDragLeave: handleGroupDragLeave,
229
+ onGroupDrop: handleGroupDrop,
230
+ }))
231
+
222
232
  // New group
223
233
  function addNewGroup() {
224
234
  const newGroup = createSampleGroup(newGroupName.value, internalGroups.value)
@@ -301,291 +311,13 @@ defineExpose({ handleSmartGroupApply })
301
311
 
302
312
  <!-- Hierarchical Tree -->
303
313
  <div class="mint-sample-selector__tree">
304
- <!-- Major Groups (when hierarchy is meaningful) -->
305
- <template v-if="showHierarchy">
306
- <div
307
- v-for="majorGroup in hierarchicalGroups"
308
- :key="majorGroup.name"
309
- class="mint-sample-selector__major-group"
310
- >
311
- <!-- Major Group Header -->
312
- <div
313
- :class="[
314
- 'mint-sample-selector__major-header',
315
- draggingGroup === majorGroup.name && draggingGroupKind === 'major' ? 'mint-sample-selector__header--dragging' : '',
316
- reorderTarget === majorGroup.name && draggingGroupKind === 'major' && reorderPosition === 'before' ? 'mint-sample-selector__header--drag-over-before' : '',
317
- reorderTarget === majorGroup.name && draggingGroupKind === 'major' && reorderPosition === 'after' ? 'mint-sample-selector__header--drag-over-after' : '',
318
- ]"
319
- draggable="true"
320
- @click="toggleGroupExpanded(`major:${majorGroup.name}`)"
321
- @dragstart="handleGroupDragStart(majorGroup.name, 'major', $event)"
322
- @dragend="handleGroupDragEnd"
323
- @dragover="handleGroupDragOver(majorGroup.name, 'major', $event)"
324
- @dragleave="handleGroupDragLeave($event)"
325
- @drop="handleGroupDrop(majorGroup.name, 'major', $event)"
326
- >
327
- <svg
328
- :class="[
329
- 'mint-sample-selector__chevron',
330
- isGroupExpanded(`major:${majorGroup.name}`) ? 'mint-sample-selector__chevron--open' : '',
331
- ]"
332
- fill="none" stroke="currentColor" viewBox="0 0 24 24"
333
- >
334
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
335
- </svg>
336
-
337
- <input
338
- type="checkbox"
339
- :checked="isMajorGroupFullySelected(majorGroup)"
340
- :indeterminate="isMajorGroupPartiallySelected(majorGroup)"
341
- class="mint-sample-selector__checkbox"
342
- :style="{ accentColor: majorGroup.color }"
343
- @click.stop
344
- @change="toggleMajorGroupSamples(majorGroup)"
345
- />
346
-
347
- <button
348
- type="button"
349
- class="mint-sample-selector__color-dot mint-sample-selector__color-dot--large mint-sample-selector__color-dot--clickable"
350
- :style="{ backgroundColor: majorGroup.color }"
351
- @click.stop="openMajorGroupColorPicker(majorGroup, $event)"
352
- title="Click to change color family"
353
- />
354
-
355
- <span class="mint-sample-selector__major-name">{{ majorGroup.name }}</span>
356
-
357
- <span
358
- class="mint-sample-selector__count-badge"
359
- :style="{ backgroundColor: majorGroup.color + '20', color: majorGroup.color }"
360
- >
361
- {{ majorGroup.allSamples.length }}
362
- </span>
363
-
364
- <button
365
- type="button"
366
- class="mint-sample-selector__delete-btn mint-sample-selector__delete-btn--hidden"
367
- @click.stop="deleteMajorGroup(majorGroup)"
368
- title="Remove major group"
369
- >
370
- <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
371
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
372
- </svg>
373
- </button>
374
- </div>
375
-
376
- <!-- Sub Groups (collapsible) -->
377
- <Transition name="mint-collapse">
378
- <div
379
- v-if="isGroupExpanded(`major:${majorGroup.name}`)"
380
- class="mint-sample-selector__sub-groups"
381
- :style="{ borderColor: majorGroup.color + '30' }"
382
- >
383
- <div
384
- v-for="subGroup in majorGroup.subGroups"
385
- :key="subGroup.name"
386
- :class="[
387
- 'mint-sample-selector__sub-group',
388
- dragOverGroup === subGroup.name ? 'mint-sample-selector__sub-group--drag-over' : '',
389
- ]"
390
- @dragover="handleDragOver(subGroup.name, $event)"
391
- @dragleave="handleDragLeave"
392
- @drop="handleDrop(subGroup.name, $event)"
393
- >
394
- <!-- Sub Group Header -->
395
- <div
396
- :class="[
397
- 'mint-sample-selector__sub-header',
398
- draggingGroup === subGroup.name && draggingGroupKind === 'sub' ? 'mint-sample-selector__header--dragging' : '',
399
- reorderTarget === subGroup.name && draggingGroupKind === 'sub' && reorderPosition === 'before' ? 'mint-sample-selector__header--drag-over-before' : '',
400
- reorderTarget === subGroup.name && draggingGroupKind === 'sub' && reorderPosition === 'after' ? 'mint-sample-selector__header--drag-over-after' : '',
401
- ]"
402
- draggable="true"
403
- @click="toggleGroupExpanded(subGroup.name)"
404
- @dragstart="handleGroupDragStart(subGroup.name, 'sub', $event)"
405
- @dragend="handleGroupDragEnd"
406
- @dragover="handleGroupDragOver(subGroup.name, 'sub', $event)"
407
- @dragleave="handleGroupDragLeave($event)"
408
- @drop="handleGroupDrop(subGroup.name, 'sub', $event)"
409
- >
410
- <svg
411
- :class="[
412
- 'mint-sample-selector__chevron mint-sample-selector__chevron--small',
413
- isGroupExpanded(subGroup.name) ? 'mint-sample-selector__chevron--open' : '',
414
- ]"
415
- fill="none" stroke="currentColor" viewBox="0 0 24 24"
416
- >
417
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
418
- </svg>
419
-
420
- <input
421
- type="checkbox"
422
- :checked="isGroupFullySelected(subGroup.name)"
423
- :indeterminate="isGroupPartiallySelected(subGroup.name)"
424
- class="mint-sample-selector__checkbox mint-sample-selector__checkbox--small"
425
- :style="{ accentColor: subGroup.displayColor }"
426
- @click.stop
427
- @change="toggleGroupSamples(subGroup.name)"
428
- />
429
-
430
- <div
431
- class="mint-sample-selector__color-dot"
432
- :style="{ backgroundColor: subGroup.displayColor }"
433
- aria-hidden="true"
434
- />
435
-
436
- <span class="mint-sample-selector__sub-name">{{ subGroup.name }}</span>
437
-
438
- <span
439
- class="mint-sample-selector__count-badge mint-sample-selector__count-badge--small"
440
- :style="{ backgroundColor: subGroup.displayBg, color: subGroup.displayColor }"
441
- >
442
- {{ subGroup.samples.length }}
443
- </span>
444
-
445
- <button
446
- type="button"
447
- class="mint-sample-selector__delete-btn mint-sample-selector__delete-btn--hidden"
448
- @click.stop="deleteGroup(subGroup.name)"
449
- title="Remove sub group"
450
- >
451
- <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
452
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
453
- </svg>
454
- </button>
455
- </div>
456
-
457
- <!-- Samples (collapsible) -->
458
- <Transition name="mint-collapse">
459
- <div
460
- v-if="isGroupExpanded(subGroup.name)"
461
- class="mint-sample-selector__samples"
462
- :style="{ borderColor: subGroup.displayBorder }"
463
- >
464
- <SampleSelectorSampleRow
465
- v-for="sample in subGroup.samples"
466
- :key="sample"
467
- :sample="sample"
468
- :selected="modelValue.includes(sample)"
469
- :dragging="draggingSample === sample"
470
- :accent-color="subGroup.displayColor"
471
- checkbox-size="tiny"
472
- removable
473
- @dragstart="handleDragStart(sample, subGroup.name, $event)"
474
- @dragend="handleDragEnd"
475
- @toggle="toggleSample(sample)"
476
- @remove="removeSampleFromGroup(sample, subGroup.name)"
477
- />
478
- </div>
479
- </Transition>
480
- </div>
481
- </div>
482
- </Transition>
483
- </div>
484
- </template>
485
-
486
- <!-- Flat Groups (when no meaningful hierarchy - e.g., level 1) -->
487
- <template v-else>
488
- <div
489
- v-for="group in internalGroups"
490
- :key="group.name"
491
- :class="[
492
- 'mint-sample-selector__sub-group',
493
- dragOverGroup === group.name ? 'mint-sample-selector__sub-group--drag-over' : '',
494
- ]"
495
- @dragover="handleDragOver(group.name, $event)"
496
- @dragleave="handleDragLeave"
497
- @drop="handleDrop(group.name, $event)"
498
- >
499
- <!-- Group Header -->
500
- <div
501
- :class="[
502
- 'mint-sample-selector__major-header',
503
- draggingGroup === group.name && draggingGroupKind === 'flat' ? 'mint-sample-selector__header--dragging' : '',
504
- reorderTarget === group.name && draggingGroupKind === 'flat' && reorderPosition === 'before' ? 'mint-sample-selector__header--drag-over-before' : '',
505
- reorderTarget === group.name && draggingGroupKind === 'flat' && reorderPosition === 'after' ? 'mint-sample-selector__header--drag-over-after' : '',
506
- ]"
507
- draggable="true"
508
- @click="toggleGroupExpanded(group.name)"
509
- @dragstart="handleGroupDragStart(group.name, 'flat', $event)"
510
- @dragend="handleGroupDragEnd"
511
- @dragover="handleGroupDragOver(group.name, 'flat', $event)"
512
- @dragleave="handleGroupDragLeave($event)"
513
- @drop="handleGroupDrop(group.name, 'flat', $event)"
514
- >
515
- <svg
516
- :class="[
517
- 'mint-sample-selector__chevron',
518
- isGroupExpanded(group.name) ? 'mint-sample-selector__chevron--open' : '',
519
- ]"
520
- fill="none" stroke="currentColor" viewBox="0 0 24 24"
521
- >
522
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
523
- </svg>
524
-
525
- <input
526
- type="checkbox"
527
- :checked="isGroupFullySelected(group.name)"
528
- :indeterminate="isGroupPartiallySelected(group.name)"
529
- class="mint-sample-selector__checkbox"
530
- :style="{ accentColor: group.color }"
531
- @click.stop
532
- @change="toggleGroupSamples(group.name)"
533
- />
534
-
535
- <button
536
- type="button"
537
- class="mint-sample-selector__color-dot mint-sample-selector__color-dot--large mint-sample-selector__color-dot--clickable"
538
- :style="{ backgroundColor: group.color }"
539
- @click.stop="openColorPicker(group.name, $event)"
540
- title="Click to change color"
541
- />
542
-
543
- <span class="mint-sample-selector__major-name">{{ group.name }}</span>
544
-
545
- <span
546
- class="mint-sample-selector__count-badge"
547
- :style="{ backgroundColor: group.color + '20', color: group.color }"
548
- >
549
- {{ group.samples.length }}
550
- </span>
551
-
552
- <button
553
- type="button"
554
- class="mint-sample-selector__delete-btn mint-sample-selector__delete-btn--hidden"
555
- @click.stop="deleteGroup(group.name)"
556
- title="Remove group"
557
- >
558
- <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
559
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
560
- </svg>
561
- </button>
562
- </div>
563
-
564
- <!-- Samples (collapsible) -->
565
- <Transition name="mint-collapse">
566
- <div
567
- v-if="isGroupExpanded(group.name)"
568
- class="mint-sample-selector__samples"
569
- :style="{ borderColor: group.color + '40' }"
570
- >
571
- <SampleSelectorSampleRow
572
- v-for="sample in group.samples"
573
- :key="sample"
574
- :sample="sample"
575
- :selected="modelValue.includes(sample)"
576
- :dragging="draggingSample === sample"
577
- :accent-color="group.color"
578
- checkbox-size="tiny"
579
- removable
580
- @dragstart="handleDragStart(sample, group.name, $event)"
581
- @dragend="handleDragEnd"
582
- @toggle="toggleSample(sample)"
583
- @remove="removeSampleFromGroup(sample, group.name)"
584
- />
585
- </div>
586
- </Transition>
587
- </div>
588
- </template>
314
+ <SampleSelectorGroupNode
315
+ v-for="node in groupTree"
316
+ :key="node.key"
317
+ :node="node"
318
+ :depth="0"
319
+ :ctx="treeContext"
320
+ />
589
321
 
590
322
  <!-- Empty state -->
591
323
  <div v-if="internalGroups.length === 0" class="mint-sample-selector__empty">
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One row of the sample-selector group hierarchy, rendered recursively so a
4
+ * grouping of any depth (Tissue → Dose → Timepoint → samples) nests properly.
5
+ *
6
+ * Roots carry the family affordances (large dot, colour-family picker) and
7
+ * leaves carry the group affordances (sample drop target, sample rows); the
8
+ * levels in between are pure path nodes that aggregate their descendants.
9
+ */
10
+ import { computed } from 'vue'
11
+ import SampleSelectorSampleRow from './SampleSelectorSampleRow.vue'
12
+ import type { SampleGroupNode } from '../composables/useSampleGroups'
13
+ import { collectLeafGroupNames, type SampleGroupTreeContext } from './SampleSelector.tree'
14
+
15
+ interface Props {
16
+ node: SampleGroupNode
17
+ depth: number
18
+ ctx: SampleGroupTreeContext
19
+ }
20
+
21
+ const props = defineProps<Props>()
22
+
23
+ const isRoot = computed(() => props.depth === 0)
24
+ const isLeaf = computed(() => props.node.children.length === 0)
25
+ const expanded = computed(() => props.ctx.isExpanded(props.node.key))
26
+ const leafGroupNames = computed(() => collectLeafGroupNames(props.node))
27
+ /** Roots reorder as a family, leaves as individual groups; path nodes stay put. */
28
+ const dragKind = computed(() => (isRoot.value ? 'major' : isLeaf.value ? 'sub' : null))
29
+ /** Reorder drags address a family by its label but a group by its full name. */
30
+ const dragName = computed(() =>
31
+ isRoot.value ? props.node.label : (props.node.group?.name ?? props.node.label),
32
+ )
33
+ const isDragging = computed(
34
+ () => dragKind.value !== null
35
+ && props.ctx.draggingGroup() === dragName.value
36
+ && props.ctx.draggingGroupKind() === dragKind.value,
37
+ )
38
+ const reorderEdge = computed(() => {
39
+ if (dragKind.value === null) return null
40
+ if (props.ctx.reorderTarget() !== dragName.value) return null
41
+ if (props.ctx.draggingGroupKind() !== dragKind.value) return null
42
+ return props.ctx.reorderPosition()
43
+ })
44
+
45
+ function onHeaderDragStart(event: DragEvent) {
46
+ if (dragKind.value === null) return
47
+ props.ctx.onGroupDragStart(dragName.value, dragKind.value, event)
48
+ }
49
+ function onHeaderDragOver(event: DragEvent) {
50
+ if (dragKind.value === null) return
51
+ props.ctx.onGroupDragOver(dragName.value, dragKind.value, event)
52
+ }
53
+ function onHeaderDrop(event: DragEvent) {
54
+ if (dragKind.value === null) return
55
+ props.ctx.onGroupDrop(dragName.value, dragKind.value, event)
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <div
61
+ :class="[
62
+ isRoot ? 'mint-sample-selector__major-group' : 'mint-sample-selector__sub-group',
63
+ isLeaf && ctx.dragOverGroup() === node.group?.name
64
+ ? 'mint-sample-selector__sub-group--drag-over'
65
+ : '',
66
+ ]"
67
+ v-on="isLeaf && node.group
68
+ ? {
69
+ dragover: (e: DragEvent) => ctx.onSampleDragOver(node.group!.name, e),
70
+ dragleave: ctx.onSampleDragLeave,
71
+ drop: (e: DragEvent) => ctx.onSampleDrop(node.group!.name, e),
72
+ }
73
+ : {}"
74
+ >
75
+ <!-- Header -->
76
+ <div
77
+ :class="[
78
+ isRoot ? 'mint-sample-selector__major-header' : 'mint-sample-selector__sub-header',
79
+ isDragging ? 'mint-sample-selector__header--dragging' : '',
80
+ reorderEdge === 'before' ? 'mint-sample-selector__header--drag-over-before' : '',
81
+ reorderEdge === 'after' ? 'mint-sample-selector__header--drag-over-after' : '',
82
+ ]"
83
+ :draggable="dragKind !== null"
84
+ @click="ctx.toggleExpanded(node.key)"
85
+ @dragstart="onHeaderDragStart"
86
+ @dragend="ctx.onGroupDragEnd"
87
+ @dragover="onHeaderDragOver"
88
+ @dragleave="ctx.onGroupDragLeave($event)"
89
+ @drop="onHeaderDrop"
90
+ >
91
+ <svg
92
+ :class="[
93
+ 'mint-sample-selector__chevron',
94
+ isRoot ? '' : 'mint-sample-selector__chevron--small',
95
+ expanded ? 'mint-sample-selector__chevron--open' : '',
96
+ ]"
97
+ fill="none" stroke="currentColor" viewBox="0 0 24 24"
98
+ >
99
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
100
+ </svg>
101
+
102
+ <input
103
+ type="checkbox"
104
+ :checked="ctx.isFullySelected(node.allSamples)"
105
+ :indeterminate="ctx.isPartiallySelected(node.allSamples)"
106
+ :class="[
107
+ 'mint-sample-selector__checkbox',
108
+ isRoot ? '' : 'mint-sample-selector__checkbox--small',
109
+ ]"
110
+ :style="{ accentColor: node.color }"
111
+ :aria-label="`Select ${node.label}`"
112
+ @click.stop
113
+ @change="ctx.toggleSamples(node.allSamples)"
114
+ />
115
+
116
+ <button
117
+ v-if="isRoot"
118
+ type="button"
119
+ class="mint-sample-selector__color-dot mint-sample-selector__color-dot--large mint-sample-selector__color-dot--clickable"
120
+ :style="{ backgroundColor: node.color }"
121
+ :title="`Change ${node.label} color family`"
122
+ :aria-label="`Change ${node.label} color family`"
123
+ @click.stop="ctx.openColorPicker(leafGroupNames, $event)"
124
+ />
125
+ <div
126
+ v-else
127
+ class="mint-sample-selector__color-dot"
128
+ :style="{ backgroundColor: node.color }"
129
+ aria-hidden="true"
130
+ />
131
+
132
+ <span
133
+ :class="isRoot ? 'mint-sample-selector__major-name' : 'mint-sample-selector__sub-name'"
134
+ :title="node.group?.name ?? node.label"
135
+ >{{ node.label }}</span>
136
+
137
+ <span
138
+ :class="[
139
+ 'mint-sample-selector__count-badge',
140
+ isRoot ? '' : 'mint-sample-selector__count-badge--small',
141
+ ]"
142
+ :style="{ backgroundColor: node.displayBg, color: node.color }"
143
+ >
144
+ {{ node.allSamples.length }}
145
+ </span>
146
+
147
+ <button
148
+ type="button"
149
+ class="mint-sample-selector__delete-btn mint-sample-selector__delete-btn--hidden"
150
+ :title="`Remove ${node.label}`"
151
+ :aria-label="`Remove ${node.label}`"
152
+ @click.stop="ctx.removeGroups(leafGroupNames)"
153
+ >
154
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
155
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
156
+ </svg>
157
+ </button>
158
+ </div>
159
+
160
+ <!-- Children: nested nodes, or the sample rows on a leaf -->
161
+ <Transition name="mint-collapse">
162
+ <div
163
+ v-if="expanded && !isLeaf"
164
+ class="mint-sample-selector__sub-groups"
165
+ :style="{ borderColor: node.displayBorder }"
166
+ >
167
+ <SampleSelectorGroupNode
168
+ v-for="child in node.children"
169
+ :key="child.key"
170
+ :node="child"
171
+ :depth="depth + 1"
172
+ :ctx="ctx"
173
+ />
174
+ </div>
175
+ <div
176
+ v-else-if="expanded && node.group"
177
+ class="mint-sample-selector__samples"
178
+ :style="{ borderColor: node.displayBorder }"
179
+ >
180
+ <SampleSelectorSampleRow
181
+ v-for="sample in node.group.samples"
182
+ :key="sample"
183
+ :sample="sample"
184
+ :selected="ctx.isSampleSelected(sample)"
185
+ :dragging="ctx.draggingSample() === sample"
186
+ :accent-color="node.color"
187
+ checkbox-size="tiny"
188
+ removable
189
+ @dragstart="(e: DragEvent) => ctx.onSampleDragStart(sample, node.group!.name, e)"
190
+ @dragend="ctx.onSampleDragEnd"
191
+ @toggle="ctx.toggleSample(sample)"
192
+ @remove="ctx.removeSampleFromGroup(sample, node.group!.name)"
193
+ />
194
+ </div>
195
+ </Transition>
196
+ </div>
197
+ </template>
@@ -58,6 +58,11 @@ export interface SmartGroupFieldRecipeProps {
58
58
  // events instead of running its own fixture grouping logic.
59
59
  /** Controls the field toggles; when set, clicking a toggle emits `toggle`. */
60
60
  enabled?: boolean[]
61
+ /**
62
+ * Per-card 1-based hierarchy layer (1 = the sample selector's major group),
63
+ * `null` for cards not grouped by. When set, reorder clicks emit `move`.
64
+ */
65
+ layers?: (number | null)[]
61
66
  /** Pre-computed resulting groups; when set, the rail renders these directly. */
62
67
  groups?: FieldRecipeGroup[]
63
68
  /** QC summary chip to show in the rail; `null` hides it (e.g. when mixed). */
@@ -42,15 +42,19 @@ export interface FieldRecipeSummary {
42
42
 
43
43
  /**
44
44
  * Build the ordered group list from whichever fields are switched on.
45
- * Returns an empty list when no field is active.
45
+ * Returns an empty list when no field is active. `layerOrder` (field indices
46
+ * in layer order) overrides the default field-order joining of the group name.
46
47
  */
47
48
  export function computeFieldGroups(
48
49
  samples: SmartGroupSampleRecord[],
49
50
  fields: SmartGroupField[],
50
51
  on: boolean[],
51
52
  palette: string[],
53
+ layerOrder?: readonly number[],
52
54
  ): FieldRecipeGroup[] {
53
- const keys = fields.filter((_, i) => on[i]).map(field => field.key)
55
+ const keys = layerOrder
56
+ ? layerOrder.filter(i => on[i]).map(i => fields[i].key)
57
+ : fields.filter((_, i) => on[i]).map(field => field.key)
54
58
  if (keys.length === 0) return []
55
59
 
56
60
  const order: string[] = []