@morscherlab/mint-sdk 1.3.0-beta.9 → 1.3.1
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/AutoGroupModal.anatomy.test.d.ts +1 -0
- package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
- package/dist/components/SmartGroup.types.d.ts +22 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-GDDvof8D.js → components-bbanLd1u.js} +510 -287
- package/dist/components-bbanLd1u.js.map +1 -0
- package/dist/composables/autoGroup/classify.d.ts +7 -1
- package/dist/composables/autoGroup/columns.d.ts +16 -1
- package/dist/composables/autoGroup/index.d.ts +5 -7
- package/dist/composables/autoGroup/tokenize.d.ts +36 -1
- package/dist/composables/index.js +2 -2
- package/dist/composables/useAutoGroup.d.ts +13 -2
- package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
- package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/permissions.js +2 -2
- package/dist/permissions.js.map +1 -1
- package/dist/styles.css +129 -96
- package/dist/types/auto-group.d.ts +1 -1
- package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
- package/dist/validation-CukfLjy-.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
- package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
- package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
- package/src/__tests__/components/SampleSelector.test.ts +14 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
- package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
- package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
- package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
- package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
- package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
- package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
- package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
- package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
- package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
- package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
- package/src/__tests__/utils/permissions.test.ts +15 -0
- package/src/components/AutoGroupModal.adapter.ts +80 -9
- package/src/components/AutoGroupModal.vue +41 -8
- package/src/components/SmartGroup.types.ts +23 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
- package/src/components/SmartGroupFieldRecipe.vue +289 -169
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
- package/src/composables/autoGroup/classify.ts +26 -7
- package/src/composables/autoGroup/columns.ts +144 -36
- package/src/composables/autoGroup/compose.ts +8 -10
- package/src/composables/autoGroup/fingerprint.ts +1 -1
- package/src/composables/autoGroup/index.ts +5 -12
- package/src/composables/autoGroup/tokenLength.ts +8 -0
- package/src/composables/autoGroup/tokenize.ts +148 -1
- package/src/composables/autoGroup/vocab.json +5 -11
- package/src/composables/useAutoGroup.ts +92 -79
- package/src/permissions.ts +3 -2
- package/src/styles/components/app-page-selector.css +58 -14
- package/src/styles/components/smart-group.css +71 -82
- package/src/types/auto-group.ts +1 -0
- package/dist/components-GDDvof8D.js.map +0 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
- package/dist/validation-Dmp9Q5W0.js.map +0 -1
- package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
|
@@ -33,7 +33,7 @@ const DEFAULT_PALETTE = [...SAMPLE_GROUP_TOKENS]
|
|
|
33
33
|
</script>
|
|
34
34
|
|
|
35
35
|
<script setup lang="ts">
|
|
36
|
-
import { computed, h,
|
|
36
|
+
import { computed, h, nextTick, ref, type FunctionalComponent } from 'vue'
|
|
37
37
|
import {
|
|
38
38
|
appendQcGroup,
|
|
39
39
|
computeFieldGroups,
|
|
@@ -43,10 +43,14 @@ import {
|
|
|
43
43
|
type QcRoute,
|
|
44
44
|
} from './SmartGroupFieldRecipe.groups'
|
|
45
45
|
import { sampleTypeMarker } from '../utils/sampleTypeMarker'
|
|
46
|
-
import {
|
|
46
|
+
import { useListReorder } from '../composables/useListReorder'
|
|
47
|
+
import ActionMenu from './ActionMenu.vue'
|
|
47
48
|
import BaseButton from './BaseButton.vue'
|
|
49
|
+
import BaseToggle from './BaseToggle.vue'
|
|
48
50
|
import LoadingSpinner from './LoadingSpinner.vue'
|
|
49
|
-
import
|
|
51
|
+
import SegmentedControl from './SegmentedControl.vue'
|
|
52
|
+
import type { ActionMenuItem } from '../types/components'
|
|
53
|
+
import type { FieldRecipeApplyPayload, FieldRecipeQcChip, SmartGroupFieldRecipeProps } from './SmartGroup.types'
|
|
50
54
|
|
|
51
55
|
const props = withDefaults(defineProps<SmartGroupFieldRecipeProps>(), {
|
|
52
56
|
samples: () => DEFAULT_SAMPLES,
|
|
@@ -58,6 +62,7 @@ const props = withDefaults(defineProps<SmartGroupFieldRecipeProps>(), {
|
|
|
58
62
|
qcColor: 'var(--mint-sample-9)',
|
|
59
63
|
canDownloadTemplate: true,
|
|
60
64
|
loading: false,
|
|
65
|
+
renamable: false,
|
|
61
66
|
})
|
|
62
67
|
|
|
63
68
|
const emit = defineEmits<{
|
|
@@ -67,8 +72,10 @@ const emit = defineEmits<{
|
|
|
67
72
|
close: []
|
|
68
73
|
/** Controlled mode: a field toggle was clicked (card index). */
|
|
69
74
|
toggle: [index: number]
|
|
70
|
-
/**
|
|
75
|
+
/** A field moved one layer (card index). A drag over several layers emits one event for each step. */
|
|
71
76
|
move: [index: number, direction: 'up' | 'down']
|
|
77
|
+
/** The user gave a field a new name (card index). */
|
|
78
|
+
rename: [index: number, name: string]
|
|
72
79
|
/** Controlled mode: the QC routing select changed. */
|
|
73
80
|
'update:route': [route: QcRoute]
|
|
74
81
|
/** A CSV/TSV file was chosen (upload button) or dropped; parent parses it. */
|
|
@@ -102,9 +109,9 @@ const ICON = {
|
|
|
102
109
|
check: 'M20 6L9 17l-5-5',
|
|
103
110
|
close: 'M6 18L18 6M6 6l12 12',
|
|
104
111
|
upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
|
|
105
|
-
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
|
|
106
112
|
chev: 'M6 9l6 6 6-6',
|
|
107
|
-
|
|
113
|
+
grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
|
|
114
|
+
pencil: 'M12 20h9M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z',
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
const MAX_BAR = 44
|
|
@@ -131,7 +138,9 @@ function onFileChange(event: Event) {
|
|
|
131
138
|
if (file) emit('csv-file', file)
|
|
132
139
|
input.value = '' // allow re-selecting the same file
|
|
133
140
|
}
|
|
134
|
-
function onDragEnter() {
|
|
141
|
+
function onDragEnter(event: DragEvent) {
|
|
142
|
+
// A field row dragged to a new layer is not a file drop.
|
|
143
|
+
if (!event.dataTransfer?.types.includes('Files')) return
|
|
135
144
|
dragDepth += 1
|
|
136
145
|
dragOver.value = true
|
|
137
146
|
}
|
|
@@ -146,39 +155,35 @@ function onDrop(event: DragEvent) {
|
|
|
146
155
|
if (file && isCsvLike(file)) emit('csv-file', file)
|
|
147
156
|
}
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
triggerRef: useTemplateRef<HTMLElement>('csvMenuTrigger'),
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
function chooseUpload() {
|
|
162
|
-
closeCsvMenu()
|
|
163
|
-
pickCsv()
|
|
164
|
-
}
|
|
165
|
-
function chooseDownloadTemplate() {
|
|
166
|
-
if (!props.canDownloadTemplate) return
|
|
167
|
-
closeCsvMenu()
|
|
168
|
-
emit('download-template')
|
|
169
|
-
}
|
|
170
|
-
function chooseDownloadPrefilled() {
|
|
171
|
-
if (!props.canDownloadTemplate) return
|
|
172
|
-
closeCsvMenu()
|
|
173
|
-
emit('download-prefilled')
|
|
158
|
+
const csvMenuItems = computed<ActionMenuItem[]>(() => [
|
|
159
|
+
{ id: 'upload', label: 'Upload CSV' },
|
|
160
|
+
{ id: 'template', label: 'Download template', disabled: !props.canDownloadTemplate },
|
|
161
|
+
{ id: 'prefilled', label: 'Download pre-filled', disabled: !props.canDownloadTemplate },
|
|
162
|
+
])
|
|
163
|
+
function onCsvMenuSelect(item: ActionMenuItem) {
|
|
164
|
+
if (item.id === 'upload') pickCsv()
|
|
165
|
+
else if (item.id === 'template') emit('download-template')
|
|
166
|
+
else emit('download-prefilled')
|
|
174
167
|
}
|
|
175
168
|
|
|
169
|
+
const QC_ROUTE_OPTIONS: { value: QcRoute; label: string }[] = [
|
|
170
|
+
{ value: 'Overlay only', label: 'Overlay' },
|
|
171
|
+
{ value: 'Exclude', label: 'Exclude' },
|
|
172
|
+
{ value: 'Mix into groups', label: 'Mix in' },
|
|
173
|
+
]
|
|
174
|
+
|
|
176
175
|
// Uncontrolled fallback state (used by the standalone story). In controlled
|
|
177
176
|
// mode AutoGroupModal supplies `enabled` / `layers` / `groups` / `route` +
|
|
178
177
|
// `qcChip` and the `toggle` / `move` / `update:route` events drive the engine
|
|
179
178
|
// instead of this local state. `orderInner` tracks the uncontrolled layer
|
|
180
179
|
// order: field indices in toggle order, first entry = hierarchy layer 1.
|
|
181
|
-
|
|
180
|
+
// `layers` without `enabled`: the parent owns the order, the toggles are local.
|
|
181
|
+
const startLayers = props.enabled === undefined ? props.layers : undefined
|
|
182
|
+
const onInner = ref<boolean[]>(
|
|
183
|
+
props.initialOn
|
|
184
|
+
? [...props.initialOn]
|
|
185
|
+
: startLayers?.map(layer => layer !== null) ?? props.fields.map(field => !field.rep),
|
|
186
|
+
)
|
|
182
187
|
const orderInner = ref<number[]>(onInner.value.flatMap((isOn, i) => (isOn ? [i] : [])))
|
|
183
188
|
const routeInner = ref<QcRoute>(props.initialRoute)
|
|
184
189
|
|
|
@@ -192,7 +197,13 @@ const currentRoute = computed(() => props.route ?? routeInner.value)
|
|
|
192
197
|
// passes `layers` from the engine; controlled-without-layers keeps the legacy
|
|
193
198
|
// field-order numbering; uncontrolled mode derives from the local toggle order.
|
|
194
199
|
const layers = computed<(number | null)[]>(() => {
|
|
195
|
-
if (props.layers !== undefined) return props.layers
|
|
200
|
+
if (props.layers !== undefined && controlledEnabled.value) return props.layers
|
|
201
|
+
if (props.layers !== undefined) {
|
|
202
|
+
// Local toggles over the parent's order: a field that is switched off
|
|
203
|
+
// leaves its layer, and one that is switched on goes last.
|
|
204
|
+
let last = Math.max(0, ...props.layers.map(layer => layer ?? 0))
|
|
205
|
+
return props.layers.map((layer, i) => (on.value[i] ? layer ?? ++last : null))
|
|
206
|
+
}
|
|
196
207
|
if (controlledEnabled.value) {
|
|
197
208
|
let layer = 0
|
|
198
209
|
return props.fields.map((_, i) => (on.value[i] ? ++layer : null))
|
|
@@ -210,6 +221,14 @@ const layerOrder = computed<number[]>(() =>
|
|
|
210
221
|
.map(entry => entry.i),
|
|
211
222
|
)
|
|
212
223
|
|
|
224
|
+
// The two lists of the layout: grouped fields in layer order, then the rest.
|
|
225
|
+
// `index` is the card index that the `toggle` / `move` / `rename` events carry.
|
|
226
|
+
interface FieldCard { index: number; field: SmartGroupField }
|
|
227
|
+
const onCards = computed<FieldCard[]>(() => layerOrder.value.map(index => ({ index, field: props.fields[index] })))
|
|
228
|
+
const offCards = computed<FieldCard[]>(() =>
|
|
229
|
+
props.fields.flatMap((field, index) => (layers.value[index] === null ? [{ index, field }] : [])),
|
|
230
|
+
)
|
|
231
|
+
|
|
213
232
|
const computedBase = computed(() =>
|
|
214
233
|
computeFieldGroups(props.samples, props.fields, on.value, props.palette, layerOrder.value),
|
|
215
234
|
)
|
|
@@ -218,13 +237,8 @@ const groups = computed(() => props.groups ?? appendQcGroup(computedBase.value,
|
|
|
218
237
|
const activeFactors = computed(() => layerOrder.value.map(i => props.fields[i].key))
|
|
219
238
|
const counts = computed(() => baseGroups.value.map(group => group.count))
|
|
220
239
|
const uniform = computed(() => counts.value.length > 0 && counts.value.every(count => count === counts.value[0]))
|
|
221
|
-
const
|
|
222
|
-
const repOn = computed(() =>
|
|
223
|
-
const repRange = computed(() => {
|
|
224
|
-
const parts = (repField.value?.vals ?? '').split(',').map(part => part.trim()).filter(Boolean)
|
|
225
|
-
if (parts.length === 0) return ''
|
|
226
|
-
return parts.length > 1 ? `${parts[0]}–${parts[parts.length - 1]}` : parts[0]
|
|
227
|
-
})
|
|
240
|
+
const repOnField = computed(() => props.fields.find((field, i) => field.rep && on.value[i]))
|
|
241
|
+
const repOn = computed(() => repOnField.value !== undefined)
|
|
228
242
|
|
|
229
243
|
const summary = computed(() =>
|
|
230
244
|
fieldRecipeSummary({ activeFactors: activeFactors.value, repOn: repOn.value, baseGroups: baseGroups.value }),
|
|
@@ -234,13 +248,11 @@ const maxCount = computed(() => Math.max(...groups.value.map(group => group.coun
|
|
|
234
248
|
const qcCount = computed(() => props.qcCount ?? Math.max(0, props.headerCount - props.samples.length))
|
|
235
249
|
const realCount = computed(() => Math.max(0, props.headerCount - qcCount.value))
|
|
236
250
|
const sampleTotal = computed(() => sampleTotalFor(currentRoute.value, realCount.value, qcCount.value))
|
|
237
|
-
const
|
|
238
|
-
props.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
: null,
|
|
243
|
-
)
|
|
251
|
+
const qcChips = computed<FieldRecipeQcChip[]>(() => {
|
|
252
|
+
if (props.qcChips !== undefined) return props.qcChips
|
|
253
|
+
if (props.qcChip !== undefined) return props.qcChip ? [props.qcChip] : []
|
|
254
|
+
return currentRoute.value !== 'Mix into groups' ? [{ name: 'QC_Pool', count: qcCount.value }] : []
|
|
255
|
+
})
|
|
244
256
|
const footHint = computed(() =>
|
|
245
257
|
currentRoute.value === 'Exclude'
|
|
246
258
|
? ` · ${qcCount.value} QC excluded`
|
|
@@ -249,7 +261,19 @@ const footHint = computed(() =>
|
|
|
249
261
|
: ` · ${qcCount.value} QC overlaid`,
|
|
250
262
|
)
|
|
251
263
|
|
|
264
|
+
// A toggle moves the row to the other list, a keyboard move reorders the rows
|
|
265
|
+
// and a rename replaces the row. Each time the focused control is re-created,
|
|
266
|
+
// so focus goes back to the same control of the same field.
|
|
267
|
+
const root = ref<HTMLElement | null>(null)
|
|
268
|
+
const controlOf = (index: number, selector: string): HTMLElement | null =>
|
|
269
|
+
root.value?.querySelector<HTMLElement>(`[data-field="${index}"] ${selector}`) ?? null
|
|
270
|
+
async function refocus(index: number, selector: string) {
|
|
271
|
+
await nextTick()
|
|
272
|
+
controlOf(index, selector)?.focus()
|
|
273
|
+
}
|
|
274
|
+
|
|
252
275
|
function toggle(index: number) {
|
|
276
|
+
void refocus(index, '[role="switch"]')
|
|
253
277
|
if (controlledEnabled.value) {
|
|
254
278
|
emit('toggle', index)
|
|
255
279
|
return
|
|
@@ -263,7 +287,7 @@ function toggle(index: number) {
|
|
|
263
287
|
}
|
|
264
288
|
|
|
265
289
|
function moveLayer(index: number, direction: 'up' | 'down') {
|
|
266
|
-
if (controlledEnabled.value) {
|
|
290
|
+
if (controlledEnabled.value || props.layers !== undefined) {
|
|
267
291
|
emit('move', index, direction)
|
|
268
292
|
return
|
|
269
293
|
}
|
|
@@ -276,6 +300,55 @@ function moveLayer(index: number, direction: 'up' | 'down') {
|
|
|
276
300
|
orderInner.value = next
|
|
277
301
|
}
|
|
278
302
|
|
|
303
|
+
// Drag a grouped field to another layer, or press Alt + arrow on its handle.
|
|
304
|
+
const reorder = useListReorder<FieldCard>({
|
|
305
|
+
items: () => onCards.value,
|
|
306
|
+
getId: card => card.field.key,
|
|
307
|
+
getLabel: card => card.field.key,
|
|
308
|
+
itemNoun: 'layer',
|
|
309
|
+
getElement: id => {
|
|
310
|
+
const card = onCards.value.find(candidate => candidate.field.key === id)
|
|
311
|
+
return card ? controlOf(card.index, '.fr-row__grip') : null
|
|
312
|
+
},
|
|
313
|
+
onReorder: ({ fromIndex, toIndex }) => {
|
|
314
|
+
const { index } = onCards.value[fromIndex]
|
|
315
|
+
const direction = toIndex < fromIndex ? 'up' : 'down'
|
|
316
|
+
for (let step = 0; step < Math.abs(toIndex - fromIndex); step++) moveLayer(index, direction)
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Inline rename of one field at a time. The name is the identity of a field,
|
|
321
|
+
// so a name that another field has is refused and the input stays open.
|
|
322
|
+
const renaming = ref<number | null>(null)
|
|
323
|
+
const draftName = ref('')
|
|
324
|
+
const renameTaken = ref(false)
|
|
325
|
+
const renameInput = ref<HTMLInputElement[]>([])
|
|
326
|
+
async function startRename(card: FieldCard) {
|
|
327
|
+
renaming.value = card.index
|
|
328
|
+
renameTaken.value = false
|
|
329
|
+
draftName.value = card.field.key
|
|
330
|
+
await nextTick()
|
|
331
|
+
renameInput.value[0]?.select()
|
|
332
|
+
}
|
|
333
|
+
function endRename(card: FieldCard, restoreFocus: boolean) {
|
|
334
|
+
renaming.value = null
|
|
335
|
+
renameTaken.value = false
|
|
336
|
+
if (restoreFocus) void refocus(card.index, '.fr-row__rename')
|
|
337
|
+
}
|
|
338
|
+
function commitRename(card: FieldCard, fromBlur = false) {
|
|
339
|
+
if (renaming.value !== card.index) return
|
|
340
|
+
const name = draftName.value.trim()
|
|
341
|
+
const taken = name !== card.field.key
|
|
342
|
+
&& (props.fields.some(field => field.key === name) || (props.reservedNames?.includes(name) ?? false))
|
|
343
|
+
if (taken && !fromBlur) {
|
|
344
|
+
renameTaken.value = true
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
// Leaving the input drops a name that is in use; focus stays where the user put it.
|
|
348
|
+
endRename(card, !fromBlur)
|
|
349
|
+
if (name && name !== card.field.key && !taken) emit('rename', card.index, name)
|
|
350
|
+
}
|
|
351
|
+
|
|
279
352
|
function onRouteChange(value: QcRoute) {
|
|
280
353
|
if (controlledRoute.value) {
|
|
281
354
|
emit('update:route', value)
|
|
@@ -296,59 +369,34 @@ function onApply() {
|
|
|
296
369
|
</script>
|
|
297
370
|
|
|
298
371
|
<template>
|
|
299
|
-
<div class="sg-modal fr">
|
|
372
|
+
<div ref="root" class="sg-modal fr">
|
|
300
373
|
<!-- header -->
|
|
301
374
|
<div class="sg-head">
|
|
302
375
|
<Icon :d="ICON.bolt" :size="18" />
|
|
303
376
|
<span class="sg-head__title">Group samples</span>
|
|
304
377
|
<span class="sg-head__sub">· {{ headerCount }} samples<template v-if="experimentLabel"> from {{ experimentLabel }}</template></span>
|
|
305
378
|
<div class="sg-head__spacer" />
|
|
306
|
-
<
|
|
307
|
-
<
|
|
308
|
-
ref="csvMenuTrigger"
|
|
309
|
-
type="button"
|
|
310
|
-
class="fr-upload"
|
|
311
|
-
aria-haspopup="menu"
|
|
312
|
-
:aria-expanded="csvMenuOpen"
|
|
313
|
-
@click="toggleCsvMenu"
|
|
314
|
-
>
|
|
315
|
-
<Icon :d="ICON.upload" :size="14" /> CSV <Icon :d="ICON.chev" :size="13" />
|
|
316
|
-
</button>
|
|
317
|
-
<div v-if="csvMenuOpen" class="fr-csv-menu" role="menu">
|
|
318
|
-
<button type="button" class="fr-csv-item" role="menuitem" @click="chooseUpload">
|
|
319
|
-
<Icon :d="ICON.upload" :size="14" /> Upload CSV
|
|
320
|
-
</button>
|
|
321
|
-
<button
|
|
322
|
-
type="button"
|
|
323
|
-
class="fr-csv-item"
|
|
324
|
-
role="menuitem"
|
|
325
|
-
:disabled="!canDownloadTemplate"
|
|
326
|
-
:aria-disabled="!canDownloadTemplate"
|
|
327
|
-
:title="canDownloadTemplate ? undefined : 'Load samples first'"
|
|
328
|
-
@click="chooseDownloadTemplate"
|
|
329
|
-
>
|
|
330
|
-
<Icon :d="ICON.download" :size="14" /> Download template
|
|
331
|
-
</button>
|
|
379
|
+
<ActionMenu label="CSV" :items="csvMenuItems" @select="onCsvMenuSelect">
|
|
380
|
+
<template #trigger="{ open, toggle: toggleMenu, keydown }">
|
|
332
381
|
<button
|
|
333
382
|
type="button"
|
|
334
|
-
class="fr-
|
|
335
|
-
|
|
336
|
-
:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
@click="chooseDownloadPrefilled"
|
|
383
|
+
class="fr-upload"
|
|
384
|
+
aria-haspopup="menu"
|
|
385
|
+
:aria-expanded="open"
|
|
386
|
+
@click="toggleMenu"
|
|
387
|
+
@keydown="keydown"
|
|
340
388
|
>
|
|
341
|
-
<Icon :d="ICON.
|
|
389
|
+
<Icon :d="ICON.upload" :size="14" /> CSV <Icon :d="ICON.chev" :size="13" />
|
|
342
390
|
</button>
|
|
343
|
-
</
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
391
|
+
</template>
|
|
392
|
+
</ActionMenu>
|
|
393
|
+
<input
|
|
394
|
+
ref="fileInput"
|
|
395
|
+
type="file"
|
|
396
|
+
accept=".csv,.tsv,text/csv,text/tab-separated-values"
|
|
397
|
+
hidden
|
|
398
|
+
@change="onFileChange"
|
|
399
|
+
/>
|
|
352
400
|
<div class="sg-mode" role="tablist" aria-label="Grouping mode">
|
|
353
401
|
<button type="button" class="sg-mode__btn sg-mode__btn--on" role="tab" :aria-selected="true">Auto</button>
|
|
354
402
|
<button type="button" class="sg-mode__btn" role="tab" :aria-selected="false" @click="emit('manual')">Manual</button>
|
|
@@ -375,80 +423,149 @@ function onApply() {
|
|
|
375
423
|
<LoadingSpinner size="md" label="Loading experiment metadata" />
|
|
376
424
|
<span>Loading experiment metadata…</span>
|
|
377
425
|
</div>
|
|
378
|
-
<div class="
|
|
379
|
-
<span class="sg-lead__q">
|
|
426
|
+
<div v-if="fields.length === 0 && headerCount === 0 && !loading" class="fr-blank" data-test="recipe-empty">
|
|
427
|
+
<span class="sg-lead__q">No samples to group</span>
|
|
380
428
|
<span class="sg-lead__hint">
|
|
381
|
-
|
|
382
|
-
|
|
429
|
+
Upload a CSV with one sample column and one column for each field, or close this dialog and select
|
|
430
|
+
samples first.
|
|
383
431
|
</span>
|
|
432
|
+
<BaseButton variant="secondary" size="sm" @click="pickCsv">
|
|
433
|
+
<template #icon-left><Icon :d="ICON.upload" :size="15" /></template>
|
|
434
|
+
Upload CSV
|
|
435
|
+
</BaseButton>
|
|
384
436
|
</div>
|
|
437
|
+
<template v-else>
|
|
438
|
+
<div v-if="anatomy && anatomy.length > 0" class="fr-anatomy">
|
|
439
|
+
<span class="fr-label">How MINT read your names</span>
|
|
440
|
+
<div class="fr-anatomy__tokens">
|
|
441
|
+
<span v-for="(token, t) in anatomy" :key="t" class="fr-token" :class="'fr-token--' + token.state">
|
|
442
|
+
<span class="fr-token__text">{{ token.text }}</span>
|
|
443
|
+
<span class="fr-token__caption">{{ token.caption }}</span>
|
|
444
|
+
</span>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
385
447
|
|
|
386
|
-
|
|
387
|
-
<div
|
|
388
|
-
v-
|
|
389
|
-
|
|
390
|
-
class="sg-field"
|
|
391
|
-
:class="{ 'sg-field--on': on[i], 'fr-field--warn': field.rep && on[i] }"
|
|
392
|
-
>
|
|
393
|
-
<div class="fr-field__head">
|
|
394
|
-
<span class="sg-field__name">{{ field.key }}</span>
|
|
395
|
-
<span class="sg-field__role" :class="{ 'sg-field__role--rep': field.rep }">{{ field.role }}</span>
|
|
448
|
+
<span class="fr-label">Group by</span>
|
|
449
|
+
<div class="sg-recipe">
|
|
450
|
+
<div v-if="onCards.length === 0" class="fr-empty">
|
|
451
|
+
{{ fields.length === 0 ? 'These sample names have no field to group by.' : 'No field is on. Switch on a field below.' }}
|
|
396
452
|
</div>
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
:
|
|
400
|
-
|
|
401
|
-
:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
<Icon :d="ICON.chevUp" :size="12" />
|
|
416
|
-
</button>
|
|
453
|
+
<div
|
|
454
|
+
v-for="(card, position) in onCards"
|
|
455
|
+
:key="card.field.key"
|
|
456
|
+
class="sg-field sg-field--on fr-row"
|
|
457
|
+
:class="{
|
|
458
|
+
'fr-field--warn': card.field.rep,
|
|
459
|
+
'fr-row--dragging': reorder.isDragging(card.field.key),
|
|
460
|
+
'fr-row--over': reorder.isDragOver(card.field.key),
|
|
461
|
+
}"
|
|
462
|
+
data-test="field-on"
|
|
463
|
+
:data-field="card.index"
|
|
464
|
+
:draggable="renaming !== card.index"
|
|
465
|
+
@dragstart="reorder.onDragStart($event, card, position)"
|
|
466
|
+
@dragover="reorder.onDragOver($event, card, position)"
|
|
467
|
+
@dragleave="reorder.onDragLeave"
|
|
468
|
+
@drop="reorder.onDrop($event, card, position)"
|
|
469
|
+
@dragend="reorder.onDragEnd"
|
|
470
|
+
>
|
|
417
471
|
<button
|
|
418
472
|
type="button"
|
|
419
|
-
class="
|
|
420
|
-
:
|
|
421
|
-
|
|
422
|
-
title="Move down a layer"
|
|
423
|
-
@click.stop="moveLayer(i, 'down')"
|
|
473
|
+
class="fr-row__grip"
|
|
474
|
+
:aria-label="`Move ${card.field.key}, layer ${position + 1} of ${onCards.length}. Press Alt and an arrow key.`"
|
|
475
|
+
@keydown="reorder.onKeydown($event, card, position)"
|
|
424
476
|
>
|
|
425
|
-
<Icon :d="ICON.
|
|
477
|
+
<Icon :d="ICON.grip" :size="14" />
|
|
426
478
|
</button>
|
|
479
|
+
<span class="fr-row__layer">{{ position + 1 }}</span>
|
|
480
|
+
<input
|
|
481
|
+
v-if="renaming === card.index"
|
|
482
|
+
ref="renameInput"
|
|
483
|
+
v-model="draftName"
|
|
484
|
+
class="fr-row__name-input"
|
|
485
|
+
:aria-label="'New name for ' + card.field.key"
|
|
486
|
+
:aria-invalid="renameTaken || undefined"
|
|
487
|
+
@input="renameTaken = false"
|
|
488
|
+
@keydown.enter.prevent="commitRename(card)"
|
|
489
|
+
@keydown.esc.stop="endRename(card, true)"
|
|
490
|
+
@blur="commitRename(card, true)"
|
|
491
|
+
/>
|
|
492
|
+
<span v-if="renaming === card.index && renameTaken" class="fr-row__error" role="alert">
|
|
493
|
+
Another field has this name
|
|
494
|
+
</span>
|
|
495
|
+
<template v-else>
|
|
496
|
+
<span class="sg-field__name">{{ card.field.key }}</span>
|
|
497
|
+
<button
|
|
498
|
+
v-if="renamable"
|
|
499
|
+
type="button"
|
|
500
|
+
class="fr-row__rename"
|
|
501
|
+
:aria-label="'Rename ' + card.field.key"
|
|
502
|
+
@click="startRename(card)"
|
|
503
|
+
>
|
|
504
|
+
<Icon :d="ICON.pencil" :size="12" />
|
|
505
|
+
</button>
|
|
506
|
+
</template>
|
|
507
|
+
<span class="sg-field__vals">{{ card.field.vals }}</span>
|
|
508
|
+
<span v-if="hints?.[card.index]" class="fr-row__hint">{{ hints[card.index] }}</span>
|
|
509
|
+
<BaseToggle
|
|
510
|
+
size="sm"
|
|
511
|
+
:model-value="true"
|
|
512
|
+
:aria-label="'Group by ' + card.field.key"
|
|
513
|
+
@update:model-value="toggle(card.index)"
|
|
514
|
+
/>
|
|
427
515
|
</div>
|
|
428
|
-
<div class="sg-field__vals">{{ field.vals }}</div>
|
|
429
516
|
</div>
|
|
430
|
-
|
|
517
|
+
<span :style="reorder.announcerStyle" aria-live="polite">{{ reorder.announcement.value }}</span>
|
|
431
518
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
519
|
+
<template v-if="offCards.length > 0">
|
|
520
|
+
<span class="fr-label">Left off</span>
|
|
521
|
+
<div class="sg-recipe">
|
|
522
|
+
<div
|
|
523
|
+
v-for="card in offCards"
|
|
524
|
+
:key="card.field.key"
|
|
525
|
+
class="sg-field fr-row"
|
|
526
|
+
data-test="field-off"
|
|
527
|
+
:data-field="card.index"
|
|
528
|
+
>
|
|
529
|
+
<span class="sg-field__name">{{ card.field.key }}</span>
|
|
530
|
+
<span class="sg-field__role" :class="{ 'sg-field__role--rep': card.field.rep }">{{ card.field.role }}</span>
|
|
531
|
+
<span class="sg-field__vals">{{ card.field.vals }}</span>
|
|
532
|
+
<BaseToggle
|
|
533
|
+
size="sm"
|
|
534
|
+
:model-value="false"
|
|
535
|
+
:aria-label="'Group by ' + card.field.key"
|
|
536
|
+
@update:model-value="toggle(card.index)"
|
|
537
|
+
/>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</template>
|
|
541
|
+
|
|
542
|
+
<!-- inline plain-english summary -->
|
|
543
|
+
<div v-if="summary.kind === 'mute'" class="sg-summary fr-summary--mute">
|
|
544
|
+
<span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
|
|
545
|
+
<span>Turn on at least one field to form groups.</span>
|
|
546
|
+
</div>
|
|
547
|
+
<div v-else-if="summary.kind === 'warn'" class="sg-summary sg-summary--warn">
|
|
548
|
+
<span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
|
|
549
|
+
<span>
|
|
550
|
+
Including <b>{{ repOnField?.key }}</b> makes every sample unique —
|
|
551
|
+
<b>{{ baseGroups.length }} groups of 1</b>. Switch it off to keep replicates together.
|
|
552
|
+
</span>
|
|
553
|
+
</div>
|
|
554
|
+
<div v-else-if="summary.kind === 'singletons'" class="sg-summary sg-summary--warn">
|
|
555
|
+
<span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
|
|
556
|
+
<span>
|
|
557
|
+
<b>{{ counts.filter(count => count === 1).length }} of {{ baseGroups.length }} groups</b> have one sample.
|
|
558
|
+
Switch off a field to form larger groups.
|
|
559
|
+
</span>
|
|
560
|
+
</div>
|
|
561
|
+
<div v-else class="sg-summary">
|
|
562
|
+
<span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
|
|
563
|
+
<span>
|
|
564
|
+
Grouping by <b>{{ activeFactors.join(' × ') }}</b> →
|
|
565
|
+
<b>{{ baseGroups.length }} groups<template v-if="uniform"> of {{ counts[0] }}</template></b>.
|
|
566
|
+
</span>
|
|
567
|
+
</div>
|
|
568
|
+
</template>
|
|
452
569
|
</div>
|
|
453
570
|
|
|
454
571
|
<!-- rail -->
|
|
@@ -473,23 +590,26 @@ function onApply() {
|
|
|
473
590
|
<div class="sg-qc">
|
|
474
591
|
<div class="sg-qc__head">
|
|
475
592
|
<span class="sg-qc__title">Quality control</span>
|
|
476
|
-
<
|
|
477
|
-
class="
|
|
478
|
-
|
|
479
|
-
|
|
593
|
+
<SegmentedControl
|
|
594
|
+
class="fr-qc__route"
|
|
595
|
+
size="sm"
|
|
596
|
+
variant="card"
|
|
597
|
+
aria-label="Quality control routing"
|
|
598
|
+
:model-value="currentRoute"
|
|
599
|
+
:options="QC_ROUTE_OPTIONS"
|
|
600
|
+
@update:model-value="onRouteChange($event as QcRoute)"
|
|
601
|
+
/>
|
|
602
|
+
</div>
|
|
603
|
+
<div class="fr-qc__chips">
|
|
604
|
+
<span
|
|
605
|
+
v-for="chip in qcChips"
|
|
606
|
+
:key="chip.name"
|
|
607
|
+
class="sg-qc__chip"
|
|
608
|
+
:class="{ 'fr-qc__chip--off': currentRoute === 'Exclude' }"
|
|
480
609
|
>
|
|
481
|
-
<
|
|
482
|
-
|
|
483
|
-
<option>Mix into groups</option>
|
|
484
|
-
</select>
|
|
610
|
+
<span class="sg-dot" :style="QC_DOT_STYLE" />{{ chip.name }} ×{{ chip.count }}
|
|
611
|
+
</span>
|
|
485
612
|
</div>
|
|
486
|
-
<span
|
|
487
|
-
v-if="qcChip"
|
|
488
|
-
class="sg-qc__chip"
|
|
489
|
-
:class="{ 'fr-qc__chip--off': currentRoute === 'Exclude' }"
|
|
490
|
-
>
|
|
491
|
-
<span class="sg-dot" :style="QC_DOT_STYLE" />{{ qcChip.name }} ×{{ qcChip.count }}
|
|
492
|
-
</span>
|
|
493
613
|
</div>
|
|
494
614
|
</div>
|
|
495
615
|
</div>
|
|
@@ -89,7 +89,7 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
89
89
|
</span>
|
|
90
90
|
</button>
|
|
91
91
|
|
|
92
|
-
<div v-if="isOpen" class="mint-
|
|
92
|
+
<div v-if="isOpen" class="mint-page-selector__menu" role="menu">
|
|
93
93
|
<div class="mint-page-selector__menu-title">Go to</div>
|
|
94
94
|
<ActionItemInternal
|
|
95
95
|
v-for="page in normalizedPages"
|
|
@@ -98,8 +98,8 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
98
98
|
:to="page.to"
|
|
99
99
|
:disabled="page.disabled"
|
|
100
100
|
:class="[
|
|
101
|
-
'mint-
|
|
102
|
-
{ 'mint-
|
|
101
|
+
'mint-page-selector__item',
|
|
102
|
+
{ 'mint-page-selector__item--active': page.id === current?.id },
|
|
103
103
|
{ 'mint-page-selector__item--disabled': page.disabled },
|
|
104
104
|
]"
|
|
105
105
|
role="menuitem"
|
|
@@ -121,9 +121,6 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
121
121
|
</slot>
|
|
122
122
|
</span>
|
|
123
123
|
<span class="mint-page-selector__item-label">{{ page.label }}</span>
|
|
124
|
-
<svg class="mint-menu-item__check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
125
|
-
<path d="M3.5 8.5l3 3 6-7" />
|
|
126
|
-
</svg>
|
|
127
124
|
</ActionItemInternal>
|
|
128
125
|
</div>
|
|
129
126
|
</div>
|