@morscherlab/mint-sdk 1.0.43 → 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.
- package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
- package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -105
- package/dist/components/SmartGroup.types.d.ts +71 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
- package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
- package/dist/components/SmartGroupManual.vue.d.ts +17 -0
- package/dist/components/SmartGroupModal.vue.d.ts +27 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-BGVwavdd.js → components-DjfatNJv.js} +2519 -2335
- package/dist/components-DjfatNJv.js.map +1 -0
- package/dist/composables/index.js +2 -2
- package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
- package/dist/composables-CpBhNKHf.js.map +1 -0
- package/dist/index.js +4 -4
- package/dist/install.js +1 -1
- package/dist/styles.css +12711 -12222
- package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
- package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
- package/src/__tests__/components/SampleSelector.test.ts +176 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
- package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
- package/src/components/AutoGroupModal.adapter.ts +147 -0
- package/src/components/AutoGroupModal.vue +176 -1321
- package/src/components/SampleSelector.vue +8 -23
- package/src/components/SmartGroup.types.ts +93 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
- package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
- package/src/components/SmartGroupFieldRecipe.vue +427 -0
- package/src/components/SmartGroupManual.cohorts.ts +112 -0
- package/src/components/SmartGroupManual.story.vue +55 -0
- package/src/components/SmartGroupManual.vue +398 -0
- package/src/components/SmartGroupModal.story.vue +61 -0
- package/src/components/SmartGroupModal.vue +61 -0
- package/src/components/index.ts +3 -0
- package/src/styles/components/sample-selector.css +1 -5
- package/src/styles/components/smart-group.css +708 -0
- package/src/styles/index.css +1 -1
- package/dist/components-BGVwavdd.js.map +0 -1
- package/dist/composables-C_hPF0Gn.js.map +0 -1
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
- package/src/styles/components/auto-group-modal.css +0 -1336
- /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
|
-
/**
|
|
3
|
-
|
|
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
|
|
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 {
|
|
15
|
-
import { useSampleGroups } from '../composables/useSampleGroups'
|
|
18
|
+
import { readFileAsText } from '../composables/useFileImport'
|
|
16
19
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
69
|
-
|
|
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
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
autoGroup.
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
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
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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/
|
|
283
|
+
@import '../styles/components/smart-group.css';
|
|
1429
284
|
</style>
|