@morscherlab/mint-sdk 1.0.44 → 1.0.45

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 (49) hide show
  1. package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
  2. package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
  3. package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
  4. package/dist/components/AutoGroupModal.vue.d.ts +1 -105
  5. package/dist/components/SmartGroup.types.d.ts +71 -0
  6. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
  7. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
  8. package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
  9. package/dist/components/SmartGroupManual.vue.d.ts +17 -0
  10. package/dist/components/SmartGroupModal.vue.d.ts +27 -0
  11. package/dist/components/index.d.ts +3 -0
  12. package/dist/components/index.js +2 -2
  13. package/dist/{components-BGVwavdd.js → components-DjfatNJv.js} +2519 -2335
  14. package/dist/components-DjfatNJv.js.map +1 -0
  15. package/dist/composables/index.js +2 -2
  16. package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
  17. package/dist/composables-CpBhNKHf.js.map +1 -0
  18. package/dist/index.js +4 -4
  19. package/dist/install.js +1 -1
  20. package/dist/styles.css +12711 -12222
  21. package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
  22. package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
  25. package/src/__tests__/components/SampleSelector.test.ts +176 -16
  26. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
  27. package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
  28. package/src/components/AutoGroupModal.adapter.ts +147 -0
  29. package/src/components/AutoGroupModal.vue +176 -1321
  30. package/src/components/SampleSelector.vue +8 -23
  31. package/src/components/SmartGroup.types.ts +93 -0
  32. package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
  33. package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
  34. package/src/components/SmartGroupFieldRecipe.vue +427 -0
  35. package/src/components/SmartGroupManual.cohorts.ts +112 -0
  36. package/src/components/SmartGroupManual.story.vue +55 -0
  37. package/src/components/SmartGroupManual.vue +398 -0
  38. package/src/components/SmartGroupModal.story.vue +61 -0
  39. package/src/components/SmartGroupModal.vue +61 -0
  40. package/src/components/index.ts +3 -0
  41. package/src/styles/components/sample-selector.css +1 -5
  42. package/src/styles/components/smart-group.css +708 -0
  43. package/src/styles/index.css +1 -1
  44. package/dist/components-BGVwavdd.js.map +0 -1
  45. package/dist/composables-C_hPF0Gn.js.map +0 -1
  46. package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
  47. package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
  48. package/src/styles/components/auto-group-modal.css +0 -1336
  49. /package/dist/__tests__/components/{AutoGroupModal.preview.test.d.ts → AutoGroupModal.adapter.test.d.ts} +0 -0
@@ -1,24 +1,35 @@
1
1
  <script setup lang="ts">
2
- /** Wizard modal that auto-groups samples by parsing naming patterns or an uploaded CSV file. */
3
- import { ref, computed, watch, nextTick } from 'vue'
2
+ /**
3
+ * Smart Group modal. Parses sample naming patterns into candidate grouping
4
+ * fields (auto / "Field Recipe") or lets you assign cohorts by hand (manual),
5
+ * staying wired to the `useAutoGroup` engine. The UI is the Smart Group design
6
+ * (`SmartGroupFieldRecipe` / `SmartGroupManual`) driven in controlled mode; this
7
+ * component is the adapter that maps engine state ↔ that UI and emits the
8
+ * resulting `AutoGroupResult`. Public props/emits are unchanged so existing
9
+ * consumers (`SampleSelector`, template catalog) keep working.
10
+ */
11
+ import { ref, computed, watch } from 'vue'
4
12
  import BaseModal from './BaseModal.vue'
5
- import BaseButton from './BaseButton.vue'
6
- import BaseInput from './BaseInput.vue'
7
- import StepWizard from './StepWizard.vue'
8
- import LoadingSpinner from './LoadingSpinner.vue'
9
13
  import AlertBox from './AlertBox.vue'
10
- import SampleHierarchyTree from './SampleHierarchyTree.vue'
14
+ import SmartGroupFieldRecipe from './SmartGroupFieldRecipe.vue'
15
+ import SmartGroupManual from './SmartGroupManual.vue'
11
16
  import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
12
- import { classKey } from '../composables/autoGroup'
13
17
  import { useApi } from '../composables/useApi'
14
- import { useTextSearch } from '../composables/useTextSearch'
15
- import { useSampleGroups } from '../composables/useSampleGroups'
18
+ import { readFileAsText } from '../composables/useFileImport'
16
19
  import {
17
- createSampleGroup,
18
- SAMPLE_GROUP_COLOR_OPTIONS,
19
- } from './SampleSelector.colors'
20
- import type { WizardStep } from '../types/components'
21
- import type { AutoGroupResult, MergeSuggestion, ColumnRole } from '../types/auto-group'
20
+ candidateColumnIndices,
21
+ engineFieldsToCards,
22
+ engineGroupsToRail,
23
+ engineToQcRoute,
24
+ manualSamplesToResult,
25
+ qcRouteToEngine,
26
+ seedFromGroups,
27
+ } from './AutoGroupModal.adapter'
28
+ import { SAMPLE_GROUP_COLOR_OPTIONS } from './SampleSelector.colors'
29
+ import type { QcRoute } from './SmartGroupFieldRecipe.groups'
30
+ import type { ManualSample } from './SmartGroupManual.cohorts'
31
+ import type { ManualPaletteEntry } from './SmartGroup.types'
32
+ import type { AutoGroupResult } from '../types/auto-group'
22
33
  import type { SampleGroup } from '../types'
23
34
 
24
35
  type GroupingWorkflow = 'auto' | 'manual'
@@ -48,284 +59,20 @@ const emit = defineEmits<{
48
59
 
49
60
  const autoGroup = useAutoGroup()
50
61
  const api = useApi()
51
- const wizardRef = ref<InstanceType<typeof StepWizard> | null>(null)
52
- const currentStep = ref(0)
53
- const isDragOver = ref(false)
54
- const csvFileName = ref('')
55
- const experimentLoading = ref(false)
62
+ const activeWorkflow = ref<GroupingWorkflow>(props.initialMode)
56
63
  const experimentError = ref<string | null>(null)
57
- const csvError = ref<string | null>(null)
58
- const activeWorkflow = ref<GroupingWorkflow>('auto')
59
- const manualDraftGroups = ref<SampleGroup[]>([])
60
- const manualSearchQuery = ref('')
61
- const manualUngroupedOnly = ref(true)
62
- const manualSelectedSamples = ref<string[]>([])
63
- const manualGroupName = ref('')
64
- const manualSubGroupName = ref('')
65
- const manualColorOptions = SAMPLE_GROUP_COLOR_OPTIONS
66
- const manualSelectedColor = ref(manualColorOptions[0])
64
+ const experimentLoading = ref(false)
67
65
 
68
- // Workspace step refs
69
- const openPopoverIdx = ref<number | null>(null)
66
+ // QC routing — a 3-way state stored as two booleans so downstream engine
67
+ // dispositions stay stable; the UI presents it as overlay / exclude / mix.
70
68
  const qcAsOverlay = ref(true)
71
69
  const excludeQc = ref(false)
72
-
73
- function roleLabel(role?: ColumnRole): string {
74
- if (!role) return 'Factor'
75
- const labels: Record<ColumnRole, string> = {
76
- 'constant': 'Constant',
77
- 'factor': 'Factor',
78
- 'replicate': 'Replicate',
79
- 'run-order': 'Run',
80
- 'numeric': 'Numeric',
81
- 'class-tag': 'Class tag',
82
- 'ignore': 'Ignore',
83
- }
84
- return labels[role]
85
- }
86
-
87
- const activeSchemaLabel = computed(() => {
88
- const cls = autoGroup.classes.value.find(c => classKey(c) === autoGroup.activeClassKey.value)
89
- return cls?.label ?? '—'
90
- })
91
-
92
- const activeMemberCount = computed(() => {
93
- const cls = autoGroup.classes.value.find(c => classKey(c) === autoGroup.activeClassKey.value)
94
- return cls?.members.length ?? 0
95
- })
96
-
97
- const activeColumnCount = computed(() => autoGroup.activeSchema.value?.columns.length ?? 0)
98
-
99
- const groupKeyNames = computed<string[]>(() => {
100
- const schema = autoGroup.activeSchema.value
101
- if (!schema) return []
102
- return schema.groupBy
103
- .map(idx => {
104
- const col = schema.columns.find(c => c.index === idx)
105
- return col?.displayName ?? col?.name ?? ''
106
- })
107
- .filter((s): s is string => s.length > 0)
108
- })
109
-
110
- const totalGrouped = computed(() =>
111
- (autoGroup.result.value.experimentalGroups ?? []).reduce((acc, g) => acc + g.samples.length, 0),
112
- )
113
- const totalQc = computed(() =>
114
- (autoGroup.qcGroups.value ?? []).reduce((acc, g) => acc + g.samples.length, 0),
115
- )
116
-
117
- // Nested experimental hierarchy (class → groupBy layers → sample leaves) for the
118
- // collapsible Preview tree. Expand the class roots by default so the first
119
- // grouping level is visible without overwhelming the panel with every sample.
120
- const groupTree = computed(() => autoGroup.result.value.groupTree ?? [])
121
- const defaultExpandedTreeIds = computed(() => groupTree.value.map(n => n.id))
122
-
123
- function cloneGroups(groups: SampleGroup[]): SampleGroup[] {
124
- return groups.map(group => ({
125
- ...group,
126
- samples: [...group.samples],
127
- }))
128
- }
129
-
130
- function manualResetSelection() {
131
- manualSearchQuery.value = ''
132
- manualUngroupedOnly.value = true
133
- manualSelectedSamples.value = []
134
- manualGroupName.value = ''
135
- manualSubGroupName.value = ''
136
- manualSelectedColor.value = manualColorOptions[0]
137
- }
138
-
139
- function manualToggleSample(sample: string) {
140
- if (manualSelectedSet.value.has(sample)) {
141
- manualSelectedSamples.value = manualSelectedSamples.value.filter(item => item !== sample)
142
- } else {
143
- manualSelectedSamples.value = [...manualSelectedSamples.value, sample]
144
- }
145
- }
146
-
147
- function manualSelectVisibleSamples() {
148
- const next = new Set(manualSelectedSamples.value)
149
- for (const sample of manualFilteredSamples.value) {
150
- next.add(sample)
151
- }
152
- manualSelectedSamples.value = [...next]
153
- }
154
-
155
- function manualClearSamples() {
156
- manualSelectedSamples.value = []
157
- }
158
-
159
- function manualToggleVisibleSamples() {
160
- if (manualAllVisibleSelected.value) {
161
- manualSelectedSamples.value = manualSelectedSamples.value
162
- .filter(sample => !manualVisibleSet.value.has(sample))
163
- return
164
- }
165
-
166
- manualSelectVisibleSamples()
167
- }
168
-
169
- function manualGetSampleGroup(sample: string): SampleGroup | undefined {
170
- return manualDraftGroups.value.find(group => group.samples.includes(sample))
171
- }
172
-
173
- function manualGetSampleStatus(sample: string): string {
174
- return manualGetSampleGroup(sample)?.name.replace('/', ' · ') || 'unassigned'
175
- }
176
-
177
- function manualGetGroupPrimaryName(name: string): string {
178
- return name.split('/')[0]
179
- }
180
-
181
- function manualGetGroupSecondaryName(name: string): string | null {
182
- const separatorIndex = name.indexOf('/')
183
- return separatorIndex === -1 ? null : name.slice(separatorIndex + 1)
184
- }
185
-
186
- function manualChooseColor(color: string) {
187
- manualSelectedColor.value = color
188
- }
189
-
190
- function manualUseExistingGroupAsTarget(name: string) {
191
- const existingGroup = manualDraftGroups.value.find(group => group.name === name)
192
- if (existingGroup) {
193
- manualSelectedColor.value = existingGroup.color
194
- }
195
-
196
- const separatorIndex = name.indexOf('/')
197
- if (separatorIndex === -1) {
198
- manualGroupName.value = name
199
- manualSubGroupName.value = ''
200
- return
201
- }
202
-
203
- manualGroupName.value = name.slice(0, separatorIndex)
204
- manualSubGroupName.value = name.slice(separatorIndex + 1)
205
- }
206
-
207
- function manualAssignSamplesToGroup() {
208
- const target = manualTargetGroupName.value
209
- if (!manualCanAssign.value || !target) return
210
-
211
- const selected = manualAssignableSamples.value
212
- const selectedSet = new Set(selected)
213
- if (selectedSet.size === 0) return
214
-
215
- let nextGroups = manualDraftGroups.value.map(group => ({
216
- ...group,
217
- samples: group.samples.filter(sample => !selectedSet.has(sample)),
218
- }))
219
-
220
- const targetIndex = nextGroups.findIndex(group => group.name === target)
221
- if (targetIndex === -1) {
222
- const newGroup = createSampleGroup(target, nextGroups, manualSelectedColor.value)
223
- if (!newGroup) return
224
- nextGroups = [...nextGroups, { ...newGroup, samples: selected }]
225
- } else {
226
- const group = nextGroups[targetIndex]
227
- nextGroups[targetIndex] = {
228
- ...group,
229
- color: manualSelectedColor.value,
230
- samples: [...group.samples, ...selected.filter(sample => !group.samples.includes(sample))],
231
- }
232
- }
233
-
234
- manualDraftGroups.value = nextGroups
235
- manualSelectedSamples.value = []
236
- }
237
-
238
- const manualSamples = computed(() =>
239
- props.samples.length > 0 ? props.samples : autoGroup.samples.value,
240
- )
241
-
242
- const manualGroupsModel = computed({
243
- get: () => manualDraftGroups.value,
244
- set: (value: SampleGroup[]) => {
245
- manualDraftGroups.value = value
246
- },
247
- })
248
-
249
- const manualSampleGroups = useSampleGroups({
250
- samples: () => manualSamples.value,
251
- groups: manualGroupsModel,
252
- })
253
-
254
- const manualHierarchicalGroups = manualSampleGroups.hierarchicalGroups
255
- const manualShowHierarchy = manualSampleGroups.showHierarchy
256
- const manualUngroupedSamples = manualSampleGroups.ungroupedSamples
257
- const manualSamplePool = computed(() =>
258
- manualUngroupedOnly.value ? manualUngroupedSamples.value : manualSamples.value,
259
- )
260
- const manualSampleSearch = useTextSearch({
261
- items: () => manualSamplePool.value,
262
- query: manualSearchQuery,
263
- getText: sample => sample,
264
- })
265
- const manualFilteredSamples = manualSampleSearch.filteredItems
266
- const manualSelectedSet = computed(() => new Set(manualSelectedSamples.value))
267
- const manualVisibleSet = computed(() => new Set(manualFilteredSamples.value))
268
- const manualAllVisibleSelected = computed(() =>
269
- manualFilteredSamples.value.length > 0
270
- && manualFilteredSamples.value.every(sample => manualSelectedSet.value.has(sample)),
271
- )
272
- const manualSomeVisibleSelected = computed(() =>
273
- manualFilteredSamples.value.some(sample => manualSelectedSet.value.has(sample))
274
- && !manualAllVisibleSelected.value,
275
- )
276
- const manualAssignableSamples = computed(() =>
277
- manualSelectedSamples.value.filter(sample => manualSamples.value.includes(sample)),
278
- )
279
- const manualTargetGroupName = computed(() => {
280
- const major = manualGroupName.value.trim()
281
- const sub = manualSubGroupName.value.trim()
282
- if (!major) return ''
283
- return sub ? `${major}/${sub}` : major
284
- })
285
- const manualCanAssign = computed(() =>
286
- manualAssignableSamples.value.length > 0 && manualTargetGroupName.value.length > 0,
287
- )
288
- const manualGroupedSamples = computed(() => {
289
- const validSamples = new Set(manualSamples.value)
290
- return new Set(
291
- manualDraftGroups.value
292
- .flatMap(group => group.samples)
293
- .filter(sample => validSamples.has(sample)),
294
- )
295
- })
296
- const manualGroupedCount = computed(() => manualGroupedSamples.value.size)
297
- const manualUngroupedCount = computed(() =>
298
- Math.max(manualSamples.value.length - manualGroupedCount.value, 0),
299
- )
300
- const manualResult = computed<AutoGroupResult>(() => {
301
- const groups = cloneGroups(manualDraftGroups.value)
302
- return {
303
- groups,
304
- experimentalGroups: groups,
305
- qcGroups: [],
306
- metadata: [],
307
- excludedSamples: [],
308
- }
309
- })
310
-
311
- function acceptSuggestion(s: MergeSuggestion) {
312
- autoGroup.mergeColumns(autoGroup.activeClassKey.value, s.columnIndices)
313
- }
314
-
315
- function dismissSuggestion(_: MergeSuggestion) {
316
- // Suggestions are derived; "dismiss" is a no-op in MVP (it will re-appear on schema reset).
317
- }
318
-
319
- // QC routing: a 3-way state replaces the two former booleans. Internally we
320
- // still store qcAsOverlay + excludeQc (so downstream watchers stay stable),
321
- // but the UI presents the choice as one radio group: overlay / exclude / mix.
322
70
  type QcRouting = 'overlay' | 'exclude' | 'mix'
323
71
  const qcRouting = computed<QcRouting>(() => {
324
72
  if (excludeQc.value) return 'exclude'
325
73
  if (qcAsOverlay.value) return 'overlay'
326
74
  return 'mix'
327
75
  })
328
-
329
76
  function setQcRouting(value: QcRouting) {
330
77
  qcAsOverlay.value = value === 'overlay'
331
78
  excludeQc.value = value === 'exclude'
@@ -335,219 +82,119 @@ function setQcRouting(value: QcRouting) {
335
82
  )
336
83
  }
337
84
 
338
- // Group statistics for the 3-stat strip — scoped to the ACTIVE class so the
339
- // numbers correspond to what the user sees in the schema panel below.
340
- // (The Preview step shows global totals across all classes.)
341
- const groupStats = computed(() => {
342
- const cls = autoGroup.classes.value.find(c => classKey(c) === autoGroup.activeClassKey.value)
343
- if (!cls) return { count: 0, samples: 0, avg: 0, min: 0, max: 0 }
344
- const schema = autoGroup.activeSchema.value
345
- const total = cls.members.length
346
- const buckets = new Map<string, number>()
347
- if (!schema || schema.groupBy.length === 0) {
348
- // No factor columns selected one group containing everyone.
349
- if (total > 0) buckets.set('__all__', total)
350
- } else {
351
- for (const m of cls.members) {
352
- const tokens = autoGroup.tokenized.value[m]
353
- const key = schema.groupBy.map(idx => tokens[idx] ?? '').join(' / ')
354
- buckets.set(key, (buckets.get(key) ?? 0) + 1)
355
- }
356
- }
357
- const sizes = [...buckets.values()]
358
- return {
359
- count: sizes.length,
360
- samples: total,
361
- avg: sizes.length > 0 ? Math.round((total / sizes.length) * 10) / 10 : 0,
362
- min: sizes.length > 0 ? Math.min(...sizes) : 0,
363
- max: sizes.length > 0 ? Math.max(...sizes) : 0,
364
- }
365
- })
366
-
367
- const activeColumn = computed(() => {
368
- if (openPopoverIdx.value === null) return null
369
- return autoGroup.activeSchema.value?.columns.find(c => c.index === openPopoverIdx.value) ?? null
370
- })
371
-
372
- function renameActive(name: string) {
373
- const col = activeColumn.value
374
- if (!col) return
375
- autoGroup.setColumnName(autoGroup.activeClassKey.value, col.index, name)
376
- }
85
+ // ── Auto mode (Field Recipe) ─────────────────────────────────────────
86
+ const fieldCards = computed(() => engineFieldsToCards(autoGroup.fields.value, autoGroup.enabledFields.value))
87
+ const cardColumnIndices = computed(() =>
88
+ candidateColumnIndices(autoGroup.fields.value, autoGroup.enabledFields.value),
89
+ )
90
+ const railGroups = computed(() =>
91
+ engineGroupsToRail(autoGroup.result.value.experimentalGroups ?? autoGroup.groups.value),
92
+ )
93
+ const qcSampleCount = computed(() =>
94
+ autoGroup.classes.value
95
+ .filter(c => c.kind !== 'biological' && c.kind !== 'unknown')
96
+ .reduce((acc, c) => acc + c.members.length, 0),
97
+ )
98
+ const qcChip = computed(() =>
99
+ qcRouting.value !== 'mix' && qcSampleCount.value > 0
100
+ ? { name: 'QC_Pool', count: qcSampleCount.value }
101
+ : null,
102
+ )
103
+ const fieldRecipeRoute = computed<QcRoute>(() => engineToQcRoute(qcRouting.value))
104
+ const headerCount = computed(() => autoGroup.samples.value.length)
377
105
 
378
- function setAlias(src: string, dst: string) {
379
- const col = activeColumn.value
380
- if (!col) return
381
- const next = { ...(col.ops?.alias ?? {}) }
382
- if (dst === src) delete next[src]
383
- else next[src] = dst
384
- autoGroup.setValueOps(autoGroup.activeClassKey.value, col.index, {
385
- ...col.ops,
386
- alias: next,
387
- })
106
+ function onToggleField(cardIndex: number) {
107
+ const columnIndex = cardColumnIndices.value[cardIndex]
108
+ if (columnIndex === undefined) return
109
+ autoGroup.toggleGroupBy(autoGroup.activeClassKey.value, columnIndex)
388
110
  }
389
-
390
- function toggleExclude(value: string) {
391
- const col = activeColumn.value
392
- if (!col) return
393
- const list = new Set(col.ops?.exclude ?? [])
394
- if (list.has(value)) list.delete(value)
395
- else list.add(value)
396
- autoGroup.setValueOps(autoGroup.activeClassKey.value, col.index, {
397
- ...col.ops,
398
- exclude: [...list],
399
- })
111
+ function onRouteChange(route: QcRoute) {
112
+ setQcRouting(qcRouteToEngine(route))
400
113
  }
401
-
402
- function mergeWithNext() {
403
- const col = activeColumn.value
404
- if (!col) return
405
- const schema = autoGroup.activeSchema.value
406
- const next = schema?.columns.find(c => c.index === col.index + 1)
407
- if (!next) return
408
- autoGroup.mergeColumns(autoGroup.activeClassKey.value, [col.index, next.index])
409
- openPopoverIdx.value = null
114
+ function applyAuto() {
115
+ emit('apply', autoGroup.result.value)
116
+ close()
410
117
  }
411
118
 
412
- // Pre-fill from props.
413
- // When samples or experiment data are already known we auto-skip the Input
414
- // step entirely and land on Workspace there's nothing for the user to
415
- // paste or upload. The wizard becomes a clean 2-step (Workspace Preview)
416
- // in the common case. Input still exists for cold opens (no samples, no
417
- // experiment) where users paste their own sample list or upload a CSV.
418
- watch(() => props.modelValue, (open) => {
419
- if (open) {
420
- activeWorkflow.value = props.initialMode
421
- manualDraftGroups.value = cloneGroups(props.groups)
422
- manualResetSelection()
423
- autoGroup.reset()
424
- currentStep.value = 0
425
- csvFileName.value = ''
426
- experimentError.value = null
427
- csvError.value = null
428
- if (props.experimentId || props.designData) {
429
- fetchExperimentData()
430
- currentStep.value = 1
431
- } else if (props.samples.length > 0) {
432
- autoGroup.rawText.value = props.samples.join('\n')
433
- autoGroup.inputMode.value = 'paste'
434
- autoGroup.parseInput()
435
- currentStep.value = 1
436
- } else {
437
- // Cold open: default to Paste so users can type their own sample
438
- // list immediately. CSV upload is one click away in the mode toggle.
439
- autoGroup.inputMode.value = 'paste'
440
- }
441
- }
442
- }, { immediate: true })
443
-
444
- watch(manualSamples, (samples) => {
445
- manualSelectedSamples.value = manualSelectedSamples.value.filter(sample => samples.includes(sample))
446
- })
447
-
448
- watch(manualTargetGroupName, (target) => {
449
- if (!target) return
450
- const existingGroup = manualDraftGroups.value.find(group => group.name === target)
451
- if (!existingGroup) return
452
- manualSelectedColor.value = existingGroup.color
453
- })
454
-
455
- // Wizard steps: three steps, unconditional
456
- const allSteps: WizardStep[] = [
457
- { id: 'input', label: 'Input' },
458
- { id: 'workspace', label: 'Workspace' },
459
- { id: 'preview', label: 'Preview' },
460
- ]
461
-
462
- const dynamicSteps = computed<WizardStep[]>(() => allSteps)
463
-
464
- // Map current step index to step ID
465
- const currentStepId = computed(() => {
466
- const step = dynamicSteps.value[currentStep.value]
467
- return step?.id ?? 'input'
468
- })
469
-
470
- // Clamp step when dynamic steps change
471
- watch(() => dynamicSteps.value.length, (newLen) => {
472
- if (currentStep.value >= newLen) {
473
- currentStep.value = newLen - 1
474
- }
475
- })
476
-
477
- // Step validity
478
- const inputValid = computed(() => autoGroup.samples.value.length > 0)
479
- const workspaceValid = computed(() => true) // empty groupBy is allowed; warning is shown in Preview.
480
-
481
- watch([inputValid, workspaceValid, dynamicSteps], () => {
482
- if (!wizardRef.value) return
483
- const steps = dynamicSteps.value
484
- for (let i = 0; i < steps.length; i++) {
485
- const id = steps[i].id
486
- if (id === 'input') wizardRef.value.setStepValid(i, inputValid.value)
487
- else if (id === 'workspace') wizardRef.value.setStepValid(i, workspaceValid.value)
488
- else wizardRef.value.setStepValid(i, true)
489
- }
490
- }, { immediate: true })
491
-
492
- // Navigation
493
- function handleNext() {
494
- if (currentStepId.value === 'input') {
119
+ // CSV upload from the Field Recipe header: parse the file into the engine's CSV
120
+ // path. `samples` derives reactively from csvData and `parseInput()` builds the
121
+ // classes/fields/groups; re-apply the current QC routing like the open watcher.
122
+ async function onCsvFile(file: File) {
123
+ experimentError.value = null
124
+ try {
125
+ // Parse BEFORE mutating engine state so a malformed/empty/header-only CSV
126
+ // (parseCSV throws) leaves the engine untouched instead of half-set.
127
+ const text = await readFileAsText(file)
128
+ const parsed = parseCSV(text)
129
+ autoGroup.csvData.value = parsed
130
+ autoGroup.inputMode.value = 'csv'
495
131
  autoGroup.parseInput()
132
+ setQcRouting(qcRouting.value)
133
+ } catch (e) {
134
+ experimentError.value = e instanceof Error ? e.message : 'Could not read that CSV file.'
496
135
  }
497
- // StepWizard handles the actual navigation via v-model
498
- }
499
-
500
- function handleApply() {
501
- emit('apply', autoGroup.result.value)
502
- emit('update:modelValue', false)
503
136
  }
504
137
 
505
- function handleManualApply() {
506
- emit('apply', manualResult.value)
507
- emit('update:modelValue', false)
138
+ // Template downloads via the engine. `downloadTemplate(mode, format)` defaults to
139
+ // ('prefilled', 'csv'); pass the explicit mode for each menu action.
140
+ function onDownloadTemplate() {
141
+ autoGroup.downloadTemplate('blank', 'csv')
142
+ }
143
+ function onDownloadPrefilled() {
144
+ autoGroup.downloadTemplate('prefilled', 'csv')
145
+ }
146
+
147
+ // ── Manual mode ──────────────────────────────────────────────────────
148
+ // Hex swatches (the SDK's existing manual palette) rather than SmartGroupManual's
149
+ // var(--grp-*) story default: SampleSelector tints group colours via hex8 string
150
+ // concatenation (`group.color + '20'`), so an emitted cohort colour must be a
151
+ // concrete hex value — matching the colours the previous manual modal produced.
152
+ const MANUAL_SWATCH_LABELS = ['Blue', 'Green', 'Amber', 'Red', 'Violet']
153
+ const manualPalette: ManualPaletteEntry[] = SAMPLE_GROUP_COLOR_OPTIONS.map((c, i) => ({
154
+ c,
155
+ k: MANUAL_SWATCH_LABELS[i] ?? c,
156
+ }))
157
+ // Source from the engine once input is parsed (paste of props.samples, an
158
+ // experiment fetch, OR an uploaded CSV) so CSV-loaded samples reach manual mode
159
+ // too; fall back to the raw prop before the engine has parsed anything.
160
+ const manualSampleNames = computed(() =>
161
+ autoGroup.samples.value.length > 0 ? autoGroup.samples.value : props.samples,
162
+ )
163
+ const manualSeed = computed(() => seedFromGroups(props.groups))
164
+ // Remount the manual builder when the sample set changes so it re-seeds from
165
+ // freshly-loaded experiment data rather than a stale snapshot.
166
+ const manualKey = computed(() => manualSampleNames.value.join('\n'))
167
+ function applyManual(payload: { samples: ManualSample[] }) {
168
+ emit('apply', manualSamplesToResult(payload.samples, props.groups))
169
+ close()
508
170
  }
509
171
 
510
- function handleCancel() {
172
+ function close() {
511
173
  emit('update:modelValue', false)
512
174
  }
513
175
 
514
- // Experiment data fetching
515
176
  async function fetchExperimentData() {
516
177
  if (!props.experimentId && !props.designData) return
517
-
518
178
  autoGroup.inputMode.value = 'experiment'
519
- experimentLoading.value = true
520
179
  experimentError.value = null
521
-
180
+ experimentLoading.value = true
522
181
  try {
523
182
  let pluginData: Record<string, unknown> | undefined
524
-
525
183
  if (props.designData) {
526
184
  pluginData = props.designData
527
185
  } else {
528
- const result = await api.get<Record<string, unknown>>(
529
- `/experiments/${props.experimentId}/data`,
530
- )
186
+ const result = await api.get<Record<string, unknown>>(`/experiments/${props.experimentId}/data`)
531
187
  pluginData = result.data as Record<string, unknown> | undefined
532
188
  }
533
-
534
189
  if (!pluginData || typeof pluginData !== 'object') {
535
190
  experimentError.value = 'No design data found for this experiment'
536
191
  return
537
192
  }
538
-
539
- const success = autoGroup.loadExperimentData(pluginData, { samples: props.samples })
540
- if (!success) {
193
+ if (!autoGroup.loadExperimentData(pluginData, { samples: props.samples })) {
541
194
  experimentError.value = 'No sample metadata found in this experiment'
542
195
  return
543
196
  }
544
-
545
- // Auto-advance to Workspace step
546
- await nextTick()
547
- const workspaceIdx = dynamicSteps.value.findIndex(s => s.id === 'workspace')
548
- if (workspaceIdx >= 0) {
549
- currentStep.value = workspaceIdx
550
- }
197
+ setQcRouting(qcRouting.value)
551
198
  } catch (e) {
552
199
  experimentError.value = e instanceof Error ? e.message : 'Failed to fetch experiment data'
553
200
  } finally {
@@ -555,875 +202,83 @@ async function fetchExperimentData() {
555
202
  }
556
203
  }
557
204
 
558
- // CSV file handling
559
- function handleFileDrop(event: DragEvent) {
560
- event.preventDefault()
561
- isDragOver.value = false
562
- const files = event.dataTransfer?.files
563
- if (files && files.length > 0) {
564
- processFile(files[0])
565
- }
566
- }
567
-
568
- function handleFileInput(event: Event) {
569
- const target = event.target as HTMLInputElement
570
- if (target.files && target.files.length > 0) {
571
- processFile(target.files[0])
572
- }
573
- }
574
-
575
- async function processFile(file: File) {
576
- if (!file.name.endsWith('.csv') && !file.name.endsWith('.tsv')) return
577
-
578
- csvError.value = null
579
- try {
580
- const text = await file.text()
581
- const parsed = parseCSV(text)
582
- autoGroup.inputMode.value = 'csv'
583
- autoGroup.csvData.value = parsed
584
- csvFileName.value = file.name
585
- } catch (e) {
586
- csvError.value = e instanceof Error ? e.message : 'Failed to parse file'
587
- }
588
- }
589
-
590
- function clearCsvFile() {
591
- autoGroup.csvData.value = null
592
- // Stay in CSV mode after a clear so the user can pick another file
593
- // without re-clicking the CSV tab. They can switch to Paste manually.
594
- autoGroup.inputMode.value = 'csv'
595
- csvFileName.value = ''
596
- }
597
-
598
- function switchToPasteMode() {
599
- autoGroup.inputMode.value = 'paste'
600
- autoGroup.csvData.value = null
601
- autoGroup.sampleTypeHints.value = []
602
- csvFileName.value = ''
603
- csvError.value = null
604
- }
605
-
606
- // Experiment metadata field names (for display in experiment mode)
607
- const experimentFieldNames = computed(() =>
608
- autoGroup.csvData.value?.columns.filter(c => c !== 'sample_name') ?? [],
205
+ // Populate the engine from props when the modal opens. Sample ingestion still
206
+ // comes from the `samples` / `designData` / `experimentId` props exactly as
207
+ // before — the redesign just drops the multi-step input/schema/merge UI.
208
+ watch(
209
+ () => props.modelValue,
210
+ (open) => {
211
+ if (!open) return
212
+ activeWorkflow.value = props.initialMode
213
+ experimentError.value = null
214
+ qcAsOverlay.value = true
215
+ excludeQc.value = false
216
+ autoGroup.reset()
217
+ if (props.experimentId || props.designData) {
218
+ fetchExperimentData()
219
+ } else if (props.samples.length > 0) {
220
+ autoGroup.rawText.value = props.samples.join('\n')
221
+ autoGroup.inputMode.value = 'paste'
222
+ autoGroup.parseInput()
223
+ setQcRouting(qcRouting.value)
224
+ } else {
225
+ autoGroup.inputMode.value = 'paste'
226
+ }
227
+ },
228
+ { immediate: true },
609
229
  )
610
-
611
- // Last step check
612
- const isLastStep = computed(() => currentStep.value === dynamicSteps.value.length - 1)
613
- const isFirstStep = computed(() => currentStep.value === 0)
614
230
  </script>
615
231
 
616
232
  <template>
617
233
  <BaseModal
234
+ class="sg-host"
618
235
  :model-value="modelValue"
619
- title="Group Samples"
620
- :size="activeWorkflow === 'manual' ? 'xl' : 'lg'"
236
+ :closable="false"
621
237
  :close-on-overlay="false"
622
238
  :close-on-escape="false"
239
+ size="lg"
623
240
  @update:model-value="emit('update:modelValue', $event)"
624
- @close="handleCancel"
625
241
  >
626
- <div class="mint-auto-group">
627
- <div
628
- :class="[
629
- 'mint-auto-group__workflow-bar',
630
- activeWorkflow === 'manual' ? 'mint-auto-group__workflow-bar--manual' : '',
631
- ]"
632
- >
633
- <div class="mint-auto-group__workflow-tabs" role="tablist" aria-label="Grouping workflow">
634
- <button
635
- type="button"
636
- :class="[
637
- 'mint-auto-group__workflow-tab',
638
- activeWorkflow === 'auto' ? 'mint-auto-group__workflow-tab--active' : '',
639
- ]"
640
- role="tab"
641
- :aria-selected="activeWorkflow === 'auto'"
642
- @click="activeWorkflow = 'auto'"
643
- >
644
- Auto
645
- </button>
646
- <button
647
- type="button"
648
- :class="[
649
- 'mint-auto-group__workflow-tab',
650
- activeWorkflow === 'manual' ? 'mint-auto-group__workflow-tab--active' : '',
651
- ]"
652
- role="tab"
653
- :aria-selected="activeWorkflow === 'manual'"
654
- @click="activeWorkflow = 'manual'"
655
- >
656
- Manual
657
- </button>
658
- </div>
659
-
660
- <div v-if="activeWorkflow === 'manual'" class="mint-auto-group__manual-summary">
661
- <div class="mint-auto-group__manual-summary-text">
662
- <span class="mint-auto-group__manual-eyebrow">Manual workflow</span>
663
- <strong>Assign selected samples to cohorts</strong>
664
- </div>
665
- <div class="mint-auto-group__manual-stats" aria-label="Manual grouping status">
666
- <span><strong>{{ manualGroupedCount }}</strong> grouped</span>
667
- <span><strong>{{ manualUngroupedCount }}</strong> unassigned</span>
668
- <span><strong>{{ manualDraftGroups.length }}</strong> cohorts</span>
669
- </div>
670
- </div>
671
- </div>
672
-
673
- <StepWizard
674
- v-if="activeWorkflow === 'auto'"
675
- ref="wizardRef"
676
- v-model="currentStep"
677
- :steps="dynamicSteps"
678
- :linear="true"
679
- size="sm"
680
- >
681
- <!-- Step: Input -->
682
- <template #step-input>
683
- <div class="mint-auto-group__input-step">
684
- <!-- Mode toggle. Paste is the self-designed path (type your
685
- own sample list); CSV is the structured-upload path; the
686
- Experiment tab only appears when the parent component
687
- owns an experimentId / designData. -->
688
- <div class="mint-auto-group__mode-toggle">
689
- <BaseButton
690
- v-if="experimentId || designData"
691
- :variant="autoGroup.inputMode.value === 'experiment' ? 'primary' : 'secondary'"
692
- size="sm"
693
- :disabled="experimentLoading"
694
- @click="fetchExperimentData"
695
- >
696
- Experiment
697
- </BaseButton>
698
- <BaseButton
699
- :variant="autoGroup.inputMode.value === 'paste' ? 'primary' : 'secondary'"
700
- size="sm"
701
- @click="switchToPasteMode"
702
- >
703
- Paste
704
- </BaseButton>
705
- <BaseButton
706
- :variant="autoGroup.inputMode.value === 'csv' ? 'primary' : 'secondary'"
707
- size="sm"
708
- @click="autoGroup.inputMode.value = 'csv'"
709
- >
710
- CSV
711
- </BaseButton>
712
- </div>
713
-
714
- <!-- Experiment mode -->
715
- <div v-if="autoGroup.inputMode.value === 'experiment'" class="mint-auto-group__experiment">
716
- <div v-if="experimentLoading" class="mint-auto-group__experiment-loading">
717
- <LoadingSpinner size="sm" />
718
- <span>Loading experiment metadata…</span>
719
- </div>
720
- <div v-else-if="experimentError" class="mint-auto-group__experiment-error">
721
- <p>{{ experimentError }}</p>
722
- <p>
723
- Switch to <button type="button" class="mint-auto-group__link-btn" @click="switchToPasteMode">Paste</button>
724
- or <button type="button" class="mint-auto-group__link-btn" @click="autoGroup.inputMode.value = 'csv'">CSV</button> mode instead.
725
- </p>
726
- </div>
727
- <div v-else-if="autoGroup.csvData.value" class="mint-auto-group__experiment-loaded">
728
- <div class="mint-auto-group__experiment-summary">
729
- <span class="mint-auto-group__experiment-stat">
730
- <strong>{{ autoGroup.csvData.value.rows.length }}</strong> samples
731
- </span>
732
- <span class="mint-auto-group__experiment-stat">
733
- <strong>{{ experimentFieldNames.length }}</strong> metadata fields
734
- </span>
735
- </div>
736
- <div class="mint-auto-group__experiment-fields">
737
- <span
738
- v-for="col in experimentFieldNames"
739
- :key="col"
740
- class="mint-auto-group__field-value"
741
- >{{ col }}</span>
742
- </div>
743
- </div>
744
- </div>
745
-
746
- <!-- Paste mode: free-form textarea for users to type or paste
747
- their own sample list (the "self-designed data" path). -->
748
- <div v-if="autoGroup.inputMode.value === 'paste'" class="mint-auto-group__paste">
749
- <textarea
750
- v-model="autoGroup.rawText.value"
751
- class="mint-auto-group__textarea"
752
- rows="12"
753
- placeholder="Paste sample names, one per line..."
754
- aria-label="Sample names"
755
- />
756
- <div v-if="autoGroup.samples.value.length > 0" class="mint-auto-group__sample-count">
757
- {{ autoGroup.samples.value.length }} samples
758
- </div>
759
- </div>
760
-
761
- <!-- CSV mode -->
762
- <div v-if="autoGroup.inputMode.value === 'csv'" class="mint-auto-group__csv">
763
- <div v-if="!autoGroup.csvData.value"
764
- :class="[
765
- 'mint-auto-group__dropzone',
766
- isDragOver ? 'mint-auto-group__dropzone--dragover' : '',
767
- ]"
768
- @drop="handleFileDrop"
769
- @dragover.prevent="isDragOver = true"
770
- @dragleave="isDragOver = false"
771
- >
772
- <input
773
- type="file"
774
- accept=".csv,.tsv"
775
- class="mint-auto-group__file-input"
776
- aria-label="Upload CSV or TSV file"
777
- @change="handleFileInput"
778
- />
779
- <svg class="mint-auto-group__upload-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
780
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
781
- </svg>
782
- <p class="mint-auto-group__upload-text">
783
- <span class="mint-auto-group__upload-highlight">Click to upload</span>
784
- or drag and drop a CSV file
785
- </p>
786
- <p class="mint-auto-group__upload-hint">
787
- CSV or TSV with a header row. First column or column named
788
- "Sample" / "File Name" is used as sample identifier.
789
- Other columns become grouping fields.
790
- </p>
791
- </div>
792
-
793
- <div v-if="csvError && !autoGroup.csvData.value" class="mint-auto-group__csv-error">
794
- {{ csvError }}
795
- </div>
796
-
797
- <div v-if="autoGroup.csvData.value" class="mint-auto-group__file-info">
798
- <svg class="mint-auto-group__file-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
799
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
800
- </svg>
801
- <span class="mint-auto-group__file-name">{{ csvFileName }}</span>
802
- <span class="mint-auto-group__file-rows">{{ autoGroup.csvData.value.rows.length }} rows</span>
803
- <button type="button" class="mint-auto-group__file-clear" @click="clearCsvFile">
804
- Change file
805
- </button>
806
- </div>
807
- </div>
808
-
809
- <div class="mint-auto-group__template" v-if="autoGroup.canDownloadTemplate.value">
810
- <div class="mint-auto-group__template-label">Or hand-edit in Excel:</div>
811
- <div class="mint-auto-group__template-actions">
812
- <BaseButton size="sm" variant="ghost" @click="autoGroup.downloadTemplate('prefilled', 'csv')">
813
- Download pre-filled CSV
814
- </BaseButton>
815
- <BaseButton size="sm" variant="ghost" @click="autoGroup.downloadTemplate('blank', 'csv')">
816
- Download blank CSV
817
- </BaseButton>
818
- </div>
819
- </div>
820
- </div>
821
- </template>
822
-
823
- <!-- Step: Workspace -->
824
- <template #step-workspace>
825
- <div class="mint-auto-group__workspace">
826
- <aside class="mint-auto-group__classes" aria-label="Detected sample classes">
827
- <h3>Detected classes</h3>
828
- <button
829
- v-for="cls in autoGroup.classes.value"
830
- :key="classKey(cls)"
831
- type="button"
832
- :class="[
833
- 'mint-auto-group__class',
834
- autoGroup.activeClassKey.value === classKey(cls) ? 'mint-auto-group__class--active' : '',
835
- cls.kind !== 'biological' && cls.kind !== 'unknown' ? 'mint-auto-group__class--qc' : '',
836
- ]"
837
- @click="autoGroup.activeClassKey.value = classKey(cls)"
838
- >
839
- <span class="mint-auto-group__class-dot" />
840
- <span class="mint-auto-group__class-label">{{ cls.label }}</span>
841
- <span class="mint-auto-group__class-count">{{ cls.members.length }}</span>
842
- </button>
843
-
844
- <div class="mint-auto-group__routing">
845
- <h4 class="mint-auto-group__routing-head">QC routing</h4>
846
- <label class="mint-auto-group__routing-radio">
847
- <input
848
- type="radio"
849
- name="qc-routing"
850
- :checked="qcRouting === 'overlay'"
851
- @change="setQcRouting('overlay')"
852
- />
853
- <span>Overlay <small>preview only</small></span>
854
- </label>
855
- <label class="mint-auto-group__routing-radio">
856
- <input
857
- type="radio"
858
- name="qc-routing"
859
- :checked="qcRouting === 'exclude'"
860
- @change="setQcRouting('exclude')"
861
- />
862
- <span>Exclude entirely</span>
863
- </label>
864
- <label class="mint-auto-group__routing-radio">
865
- <input
866
- type="radio"
867
- name="qc-routing"
868
- :checked="qcRouting === 'mix'"
869
- @change="setQcRouting('mix')"
870
- />
871
- <span>Mix into groups</span>
872
- </label>
873
- </div>
874
- </aside>
875
-
876
- <section class="mint-auto-group__schema" aria-label="Schema editor">
877
- <div class="mint-auto-group__stats">
878
- <div class="mint-auto-group__stat mint-auto-group__stat--primary">
879
- <div class="mint-auto-group__stat-v">{{ groupStats.count }}</div>
880
- <div class="mint-auto-group__stat-l">Groups</div>
881
- <div class="mint-auto-group__stat-sub">
882
- <span v-if="groupKeyNames.length === 0" class="mint-auto-group__stat-empty">Toggle <strong>Use</strong> on a column to start splitting</span>
883
- <template v-else>
884
- <span
885
- v-for="name in groupKeyNames"
886
- :key="name"
887
- class="mint-auto-group__group-chip"
888
- >{{ name }}</span>
889
- </template>
890
- </div>
891
- </div>
892
- <div class="mint-auto-group__stat">
893
- <div class="mint-auto-group__stat-v">{{ activeMemberCount }}</div>
894
- <div class="mint-auto-group__stat-l">Samples</div>
895
- <div class="mint-auto-group__stat-sub mint-auto-group__stat-sub--muted">{{ activeSchemaLabel }} · {{ activeColumnCount }} token positions</div>
896
- </div>
897
- <div class="mint-auto-group__stat">
898
- <div class="mint-auto-group__stat-v">{{ groupStats.avg }}</div>
899
- <div class="mint-auto-group__stat-l">Avg / group</div>
900
- <div class="mint-auto-group__stat-sub mint-auto-group__stat-sub--muted">
901
- <template v-if="groupStats.count > 0">Min {{ groupStats.min }} · Max {{ groupStats.max }}</template>
902
- <template v-else>—</template>
903
- </div>
904
- </div>
905
- </div>
906
-
907
- <div v-if="autoGroup.suggestions.value.length > 0" class="mint-auto-group__suggest">
908
- <strong>Suggested merge:</strong>
909
- columns {{ autoGroup.suggestions.value[0].columnIndices.map((i: number) => i + 1).join(' + ') }}
910
- always co-occur — combine into <code>{{ autoGroup.suggestions.value[0].proposedName }}</code>?
911
- <button type="button" class="mint-auto-group__suggest-btn" @click="acceptSuggestion(autoGroup.suggestions.value[0])">
912
- Accept · merge
913
- </button>
914
- <button type="button" class="mint-auto-group__suggest-btn mint-auto-group__suggest-btn--ghost" @click="dismissSuggestion(autoGroup.suggestions.value[0])">
915
- Dismiss
916
- </button>
917
- </div>
918
-
919
- <div class="mint-auto-group__token-table" role="table" aria-label="Token positions">
920
- <div class="mint-auto-group__token-row mint-auto-group__token-row--head" role="row">
921
- <span role="columnheader" aria-label="Token number">#</span>
922
- <span role="columnheader">Name</span>
923
- <span role="columnheader">Role</span>
924
- <span role="columnheader" class="mint-auto-group__token-num" aria-label="Unique value count">Unique</span>
925
- <span role="columnheader">Preview</span>
926
- <span role="columnheader" class="mint-auto-group__token-use-head">Use</span>
927
- <span role="columnheader" aria-label="More actions" />
928
- </div>
929
- <button
930
- v-for="col in (autoGroup.activeSchema.value?.columns ?? [])"
931
- :key="col.index"
932
- type="button"
933
- role="row"
934
- :aria-expanded="openPopoverIdx === col.index"
935
- aria-haspopup="dialog"
936
- :class="[
937
- 'mint-auto-group__token-row',
938
- openPopoverIdx === col.index ? 'mint-auto-group__token-row--active' : '',
939
- ]"
940
- @click="openPopoverIdx = col.index"
941
- >
942
- <span class="mint-auto-group__token-ix" role="cell">{{ col.index + 1 }}</span>
943
- <span class="mint-auto-group__token-nm" role="cell">{{ col.displayName ?? col.name }}</span>
944
- <span
945
- :class="['mint-auto-group__role', `mint-auto-group__role--${col.role}`]"
946
- role="cell"
947
- >{{ roleLabel(col.role) }}</span>
948
- <span class="mint-auto-group__token-unique" role="cell">{{ col.cardinality }}</span>
949
- <span class="mint-auto-group__token-preview" role="cell">
950
- {{ col.uniqueValues.slice(0, 4).join(', ') }}<span
951
- v-if="col.uniqueValues.length > 4"
952
- class="mint-auto-group__token-more"
953
- > +{{ col.uniqueValues.length - 4 }} more</span>
954
- </span>
955
- <label
956
- class="mint-auto-group__token-use"
957
- role="cell"
958
- @click.stop
959
- >
960
- <input
961
- type="checkbox"
962
- :checked="(autoGroup.activeSchema.value?.groupBy ?? []).includes(col.index)"
963
- @change="autoGroup.toggleGroupBy(autoGroup.activeClassKey.value, col.index)"
964
- />
965
- </label>
966
- <span class="mint-auto-group__token-trigger" role="cell" aria-hidden="true">⋯</span>
967
- </button>
968
- </div>
969
- </section>
970
-
971
- <div
972
- v-if="openPopoverIdx !== null && activeColumn"
973
- class="mint-auto-group__popover"
974
- role="dialog"
975
- :aria-label="`${activeColumn.displayName ?? activeColumn.name} column options`"
976
- @click.stop
977
- >
978
- <div class="mint-auto-group__popover-head">
979
- <strong>{{ activeColumn.displayName ?? activeColumn.name }}</strong>
980
- <span class="mono">{{ activeColumn.cardinality }} unique</span>
981
- <button type="button" class="mint-auto-group__popover-close" aria-label="Close" @click="openPopoverIdx = null">×</button>
982
- </div>
983
-
984
- <div class="mint-auto-group__popover-section">
985
- <div class="mint-auto-group__popover-label">Role</div>
986
- <div class="mint-auto-group__pills">
987
- <button
988
- v-for="r in (['factor','replicate','run-order','numeric','constant','ignore'] as ColumnRole[])"
989
- :key="r"
990
- type="button"
991
- :class="['mint-auto-group__pill-btn', activeColumn.role === r ? 'mint-auto-group__pill-btn--on' : '']"
992
- @click="autoGroup.setRole(autoGroup.activeClassKey.value, activeColumn.index, r)"
993
- >{{ roleLabel(r) }}</button>
994
- </div>
995
- </div>
996
-
997
- <div class="mint-auto-group__popover-section">
998
- <div class="mint-auto-group__popover-label">Rename</div>
999
- <BaseInput
1000
- :model-value="activeColumn.displayName ?? activeColumn.name"
1001
- @update:model-value="renameActive(String($event ?? ''))"
1002
- />
1003
- </div>
1004
-
1005
- <div class="mint-auto-group__popover-section">
1006
- <div class="mint-auto-group__popover-label">Values ({{ activeColumn.uniqueValues.length }})</div>
1007
- <div class="mint-auto-group__values-list">
1008
- <div
1009
- v-for="v in activeColumn.uniqueValues"
1010
- :key="v"
1011
- class="mint-auto-group__value-row"
1012
- >
1013
- <span class="mint-auto-group__value-src">{{ v }} →</span>
1014
- <input
1015
- class="mint-auto-group__value-input"
1016
- :value="activeColumn.ops?.alias?.[v] ?? v"
1017
- @change="setAlias(v, ($event.target as HTMLInputElement).value)"
1018
- />
1019
- <label class="mint-auto-group__value-excl">
1020
- <input
1021
- type="checkbox"
1022
- :checked="activeColumn.ops?.exclude?.includes(v) ?? false"
1023
- @change="toggleExclude(v)"
1024
- />
1025
- excl
1026
- </label>
1027
- </div>
1028
- </div>
1029
- </div>
1030
-
1031
- <div class="mint-auto-group__popover-section">
1032
- <button type="button" class="mint-auto-group__link" @click="mergeWithNext">▸ Merge with next column</button>
1033
- </div>
1034
- </div>
1035
- </div>
1036
- </template>
1037
-
1038
- <!-- Step: Preview -->
1039
- <template #step-preview>
1040
- <div class="mint-auto-group__preview">
1041
- <div class="mint-auto-group__preview-summary">
1042
- <strong>{{ autoGroup.groups.value.length }}</strong> groups ·
1043
- <strong>{{ totalGrouped }}</strong> samples ·
1044
- <strong>{{ totalQc }}</strong> QC kept aside ·
1045
- <strong>{{ autoGroup.excludedSamples.value.length }}</strong> excluded
1046
- </div>
1047
-
1048
- <AlertBox
1049
- v-if="autoGroup.allSingletons.value"
1050
- type="warning"
1051
- title="Each group has only one sample"
1052
- >
1053
- Group key produced a unique key per row. Go back and disable any column whose
1054
- values are unique per sample.
1055
- </AlertBox>
1056
-
1057
- <div class="mint-auto-group__preview-grid">
1058
- <div class="mint-auto-group__preview-panel">
1059
- <h4>Experimental groups</h4>
1060
- <SampleHierarchyTree
1061
- v-if="groupTree.length"
1062
- :nodes="groupTree"
1063
- :default-expanded-ids="defaultExpandedTreeIds"
1064
- :show-icons="false"
1065
- size="sm"
1066
- class="mint-auto-group__preview-tree"
1067
- />
1068
- <p v-else class="mint-auto-group__preview-empty">No experimental groups.</p>
1069
- </div>
1070
-
1071
- <div class="mint-auto-group__preview-panel">
1072
- <h4>QC kept aside (overlay)</h4>
1073
- <div class="mint-auto-group__qc-chips">
1074
- <span
1075
- v-for="g in (autoGroup.qcGroups.value ?? [])"
1076
- :key="g.name"
1077
- class="mint-auto-group__qc-chip"
1078
- >
1079
- {{ g.name }} <code>×{{ g.samples.length }}</code>
1080
- </span>
1081
- </div>
1082
- <details class="mint-auto-group__fingerprint">
1083
- <summary>Schema fingerprint</summary>
1084
- <pre>{{ JSON.stringify(autoGroup.fingerprint.value, null, 2) }}</pre>
1085
- </details>
1086
- </div>
1087
- </div>
1088
- </div>
1089
- </template>
1090
-
1091
- <!-- Custom navigation -->
1092
- <template #navigation="{ goBack, goNext, canProceed }">
1093
- <div class="mint-auto-group__nav">
1094
- <BaseButton variant="secondary" @click="handleCancel">
1095
- Cancel
1096
- </BaseButton>
1097
- <div class="mint-auto-group__nav-spacer" />
1098
- <BaseButton
1099
- v-if="!isFirstStep"
1100
- variant="secondary"
1101
- @click="goBack"
1102
- >
1103
- Back
1104
- </BaseButton>
1105
- <BaseButton
1106
- v-if="!isLastStep"
1107
- variant="primary"
1108
- :disabled="!canProceed"
1109
- @click="() => { handleNext(); goNext() }"
1110
- >
1111
- Next
1112
- </BaseButton>
1113
- <BaseButton
1114
- v-if="isLastStep"
1115
- variant="primary"
1116
- @click="handleApply"
1117
- >
1118
- Apply
1119
- </BaseButton>
1120
- </div>
1121
- </template>
1122
- </StepWizard>
1123
-
1124
- <div v-else class="mint-auto-group__manual-mode">
1125
- <div class="mint-auto-group__manual-grid">
1126
- <section class="mint-auto-group__manual-panel" aria-label="Manual sample selection">
1127
- <div class="mint-auto-group__manual-panel-head">
1128
- <div>
1129
- <span class="mint-auto-group__manual-step">1</span>
1130
- <h3>Samples</h3>
1131
- </div>
1132
- <div class="mint-auto-group__manual-panel-meta">
1133
- <span>{{ manualFilteredSamples.length }} of {{ manualSamplePool.length }}</span>
1134
- <label class="mint-auto-group__manual-filter">
1135
- <input
1136
- v-model="manualUngroupedOnly"
1137
- type="checkbox"
1138
- class="mint-auto-group__manual-checkbox"
1139
- />
1140
- <span>Ungrouped only</span>
1141
- </label>
1142
- </div>
1143
- </div>
1144
-
1145
- <div class="mint-auto-group__manual-search">
1146
- <svg class="mint-auto-group__manual-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1147
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
1148
- </svg>
1149
- <input
1150
- v-model="manualSearchQuery"
1151
- type="text"
1152
- aria-label="Search samples for manual grouping"
1153
- placeholder="Search sample keyword..."
1154
- class="mint-auto-group__manual-search-input"
1155
- />
1156
- </div>
1157
-
1158
- <label class="mint-auto-group__manual-selectbar">
1159
- <input
1160
- type="checkbox"
1161
- :checked="manualAllVisibleSelected"
1162
- :indeterminate="manualSomeVisibleSelected"
1163
- :disabled="manualFilteredSamples.length === 0"
1164
- class="mint-auto-group__manual-checkbox"
1165
- @change="manualToggleVisibleSamples"
1166
- />
1167
- <span>Select all matching</span>
1168
- <span class="mint-auto-group__manual-selectbar-count">
1169
- {{ manualFilteredSamples.length }} shown
1170
- </span>
1171
- </label>
1172
-
1173
- <div class="mint-auto-group__manual-list">
1174
- <label
1175
- v-for="sample in manualFilteredSamples"
1176
- :key="sample"
1177
- :class="[
1178
- 'mint-auto-group__manual-sample',
1179
- manualSelectedSet.has(sample) ? 'mint-auto-group__manual-sample--selected' : '',
1180
- ]"
1181
- :title="sample"
1182
- >
1183
- <input
1184
- type="checkbox"
1185
- :checked="manualSelectedSet.has(sample)"
1186
- class="mint-auto-group__manual-checkbox"
1187
- @change="manualToggleSample(sample)"
1188
- />
1189
- <span class="mint-auto-group__manual-sample-name">{{ sample }}</span>
1190
- <span
1191
- :class="[
1192
- 'mint-auto-group__manual-status',
1193
- manualGetSampleGroup(sample) ? '' : 'mint-auto-group__manual-status--empty',
1194
- ]"
1195
- >
1196
- <span
1197
- v-if="manualGetSampleGroup(sample)"
1198
- class="mint-auto-group__manual-dot"
1199
- :style="{ backgroundColor: manualGetSampleGroup(sample)?.color }"
1200
- aria-hidden="true"
1201
- />
1202
- {{ manualGetSampleStatus(sample) }}
1203
- </span>
1204
- <span class="mint-auto-group__manual-tooltip">{{ sample }}</span>
1205
- </label>
1206
-
1207
- <div v-if="manualFilteredSamples.length === 0" class="mint-auto-group__manual-empty">
1208
- No samples
1209
- </div>
1210
- </div>
1211
-
1212
- <div class="mint-auto-group__manual-selection-footer">
1213
- <span><strong>{{ manualSelectedSamples.length }}</strong> selected</span>
1214
- <button
1215
- type="button"
1216
- class="mint-auto-group__manual-link"
1217
- :disabled="manualSelectedSamples.length === 0"
1218
- @click="manualClearSamples"
1219
- >
1220
- Clear
1221
- </button>
1222
- </div>
1223
- </section>
1224
-
1225
- <section class="mint-auto-group__manual-panel" aria-label="Manual cohort assignment">
1226
- <div class="mint-auto-group__manual-panel-head">
1227
- <div>
1228
- <span class="mint-auto-group__manual-step">2</span>
1229
- <h3>Cohort target</h3>
1230
- </div>
1231
- </div>
1232
-
1233
- <div
1234
- :class="[
1235
- 'mint-auto-group__manual-assignment',
1236
- manualSelectedSamples.length === 0 ? 'mint-auto-group__manual-assignment--empty' : '',
1237
- ]"
1238
- >
1239
- <div class="mint-auto-group__manual-selected-summary">
1240
- <span
1241
- class="mint-auto-group__manual-dot"
1242
- :style="{ backgroundColor: manualSelectedColor }"
1243
- aria-hidden="true"
1244
- />
1245
- <span>
1246
- {{ manualSelectedSamples.length > 0
1247
- ? `${manualSelectedSamples.length} selected`
1248
- : 'No sample selected' }}
1249
- </span>
1250
- </div>
1251
-
1252
- <div class="mint-auto-group__manual-fields">
1253
- <label class="mint-auto-group__manual-field">
1254
- <span>Group</span>
1255
- <div class="mint-auto-group__manual-combo">
1256
- <span
1257
- class="mint-auto-group__manual-dot"
1258
- :style="{ backgroundColor: manualSelectedColor }"
1259
- aria-hidden="true"
1260
- />
1261
- <BaseInput
1262
- v-model="manualGroupName"
1263
- list="mint-auto-group-manual-groups"
1264
- placeholder="Type or pick..."
1265
- size="sm"
1266
- class="mint-auto-group__manual-combo-input"
1267
- />
1268
- <svg class="mint-auto-group__manual-combo-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1269
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 9l6 6 6-6" />
1270
- </svg>
1271
- <datalist id="mint-auto-group-manual-groups">
1272
- <option
1273
- v-for="group in manualDraftGroups"
1274
- :key="`manual-group-option-${group.name}`"
1275
- :value="manualGetGroupPrimaryName(group.name)"
1276
- />
1277
- </datalist>
1278
- </div>
1279
- </label>
1280
-
1281
- <label class="mint-auto-group__manual-field">
1282
- <span>Subgroup <small>optional</small></span>
1283
- <div class="mint-auto-group__manual-combo">
1284
- <BaseInput
1285
- v-model="manualSubGroupName"
1286
- list="mint-auto-group-manual-subgroups"
1287
- placeholder="e.g. Day 7"
1288
- size="sm"
1289
- class="mint-auto-group__manual-combo-input"
1290
- />
1291
- <svg class="mint-auto-group__manual-combo-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1292
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 9l6 6 6-6" />
1293
- </svg>
1294
- <datalist id="mint-auto-group-manual-subgroups">
1295
- <option
1296
- v-for="group in manualDraftGroups"
1297
- :key="`manual-subgroup-option-${group.name}`"
1298
- :value="manualGetGroupSecondaryName(group.name) || group.name"
1299
- />
1300
- </datalist>
1301
- </div>
1302
- </label>
1303
- </div>
1304
-
1305
- <div class="mint-auto-group__manual-field">
1306
- <span>Color</span>
1307
- <div class="mint-auto-group__manual-swatches">
1308
- <button
1309
- v-for="color in manualColorOptions"
1310
- :key="color"
1311
- type="button"
1312
- :class="[
1313
- 'mint-auto-group__manual-swatch',
1314
- manualSelectedColor === color ? 'mint-auto-group__manual-swatch--active' : '',
1315
- ]"
1316
- :style="{ backgroundColor: color }"
1317
- :aria-label="`Use color ${color}`"
1318
- @click="manualChooseColor(color)"
1319
- />
1320
- </div>
1321
- </div>
1322
-
1323
- <div class="mint-auto-group__manual-target">
1324
- <span>Target cohort</span>
1325
- <code>{{ manualTargetGroupName || 'Group/Subgroup' }}</code>
1326
- </div>
1327
-
1328
- <BaseButton
1329
- variant="primary"
1330
- size="sm"
1331
- class="mint-auto-group__manual-assign"
1332
- :disabled="!manualCanAssign"
1333
- @click="manualAssignSamplesToGroup"
1334
- >
1335
- <svg class="mint-auto-group__manual-btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1336
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6-6 6 6-6 6" />
1337
- </svg>
1338
- Assign
1339
- </BaseButton>
1340
- </div>
1341
-
1342
- <div class="mint-auto-group__manual-cohorts">
1343
- <div class="mint-auto-group__manual-cohorts-head">
1344
- <span>Cohorts so far</span>
1345
- <span>{{ manualDraftGroups.length }} groups</span>
1346
- </div>
1347
-
1348
- <template v-if="manualShowHierarchy">
1349
- <template
1350
- v-for="majorGroup in manualHierarchicalGroups"
1351
- :key="`manual-major-${majorGroup.name}`"
1352
- >
1353
- <button
1354
- type="button"
1355
- class="mint-auto-group__manual-chip mint-auto-group__manual-chip--major"
1356
- :title="majorGroup.name"
1357
- @click="manualUseExistingGroupAsTarget(majorGroup.subGroups[0]?.name || majorGroup.name)"
1358
- >
1359
- <span
1360
- class="mint-auto-group__manual-chip-dot"
1361
- :style="{ backgroundColor: majorGroup.color }"
1362
- aria-hidden="true"
1363
- />
1364
- <span class="mint-auto-group__manual-chip-main">{{ majorGroup.name }}</span>
1365
- <span class="mint-auto-group__manual-chip-count">{{ majorGroup.allSamples.length }}</span>
1366
- </button>
1367
-
1368
- <button
1369
- v-for="subGroup in majorGroup.subGroups"
1370
- :key="`manual-sub-${subGroup.name}`"
1371
- type="button"
1372
- class="mint-auto-group__manual-chip mint-auto-group__manual-chip--sub"
1373
- :title="subGroup.name"
1374
- @click="manualUseExistingGroupAsTarget(subGroup.name)"
1375
- >
1376
- <span class="mint-auto-group__manual-chip-sub">
1377
- {{ manualGetGroupSecondaryName(subGroup.name) || subGroup.name }}
1378
- </span>
1379
- <span class="mint-auto-group__manual-chip-count">{{ subGroup.samples.length }}</span>
1380
- </button>
1381
- </template>
1382
- </template>
1383
-
1384
- <template v-else>
1385
- <button
1386
- v-for="group in manualDraftGroups"
1387
- :key="group.name"
1388
- type="button"
1389
- class="mint-auto-group__manual-chip"
1390
- :title="group.name"
1391
- @click="manualUseExistingGroupAsTarget(group.name)"
1392
- >
1393
- <span
1394
- class="mint-auto-group__manual-chip-dot"
1395
- :style="{ backgroundColor: group.color }"
1396
- aria-hidden="true"
1397
- />
1398
- <span class="mint-auto-group__manual-chip-main">{{ group.name }}</span>
1399
- <span class="mint-auto-group__manual-chip-count">{{ group.samples.length }}</span>
1400
- </button>
1401
- </template>
1402
-
1403
- <div v-if="manualDraftGroups.length === 0" class="mint-auto-group__manual-empty">
1404
- No cohorts yet
1405
- </div>
1406
- </div>
1407
- </section>
1408
- </div>
1409
-
1410
- </div>
1411
- </div>
1412
-
1413
- <template v-if="activeWorkflow === 'manual'" #footer>
1414
- <div class="mint-auto-group__nav mint-auto-group__nav--manual">
1415
- <BaseButton variant="secondary" @click="handleCancel">
1416
- Cancel
1417
- </BaseButton>
1418
- <div class="mint-auto-group__nav-spacer" />
1419
- <BaseButton variant="primary" @click="handleManualApply">
1420
- Apply
1421
- </BaseButton>
1422
- </div>
1423
- </template>
242
+ <AlertBox v-if="experimentError" type="error" class="sg-host__error">
243
+ {{ experimentError }}
244
+ </AlertBox>
245
+
246
+ <SmartGroupFieldRecipe
247
+ v-if="activeWorkflow === 'auto'"
248
+ :fields="fieldCards.fields"
249
+ :enabled="fieldCards.enabled"
250
+ :groups="railGroups"
251
+ :qc-chip="qcChip"
252
+ :route="fieldRecipeRoute"
253
+ :header-count="headerCount"
254
+ :qc-count="qcSampleCount"
255
+ :can-download-template="autoGroup.canDownloadTemplate.value"
256
+ :loading="experimentLoading"
257
+ experiment-label=""
258
+ @toggle="onToggleField"
259
+ @update:route="onRouteChange"
260
+ @csv-file="onCsvFile"
261
+ @download-template="onDownloadTemplate"
262
+ @download-prefilled="onDownloadPrefilled"
263
+ @apply="applyAuto"
264
+ @manual="activeWorkflow = 'manual'"
265
+ @cancel="close"
266
+ @close="close"
267
+ />
268
+ <SmartGroupManual
269
+ v-else
270
+ :key="manualKey"
271
+ :sample-names="manualSampleNames"
272
+ :seed="manualSeed"
273
+ :palette="manualPalette"
274
+ @done="applyManual"
275
+ @auto="activeWorkflow = 'auto'"
276
+ @cancel="close"
277
+ @close="close"
278
+ />
1424
279
  </BaseModal>
1425
280
  </template>
1426
281
 
1427
282
  <style>
1428
- @import '../styles/components/auto-group-modal.css';
283
+ @import '../styles/components/smart-group.css';
1429
284
  </style>