@morscherlab/mint-sdk 1.0.44 → 1.0.46
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/DataFrame.vue.d.ts +15 -0
- 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-C7UFkNIp.js} +3036 -2751
- package/dist/components-C7UFkNIp.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 +12824 -12195
- 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/DataFrame.test.ts +100 -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/DataFrame.vue +97 -5
- 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/dataframe.css +79 -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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
assignSamples,
|
|
4
|
+
buildCohortTree,
|
|
5
|
+
filterSamples,
|
|
6
|
+
type ManualSample,
|
|
7
|
+
} from '../../components/SmartGroupManual.cohorts'
|
|
8
|
+
|
|
9
|
+
/** Mirrors the design prototype's seeded default sample list. */
|
|
10
|
+
function seededSamples(): ManualSample[] {
|
|
11
|
+
const blank = { group: null, sub: null, sub2: null, color: null } as const
|
|
12
|
+
const seed: Record<string, Partial<ManualSample>> = {
|
|
13
|
+
Pt001_TumorA_d7_rep1: { group: 'Responder', sub: 'Day 7', sub2: 'Batch 1', color: 'c-teal' },
|
|
14
|
+
Pt001_TumorA_d14_rep1: { group: 'Responder', sub: 'Day 14', color: 'c-teal' },
|
|
15
|
+
Pt002_TumorB_d7_rep1: { group: 'Non-responder', sub: 'Day 7', sub2: 'Batch 2', color: 'c-rose' },
|
|
16
|
+
}
|
|
17
|
+
return ['Pt001_TumorA_d7_rep1', 'Pt001_TumorA_d14_rep1', 'Pt002_TumorB_d7_rep1', 'QC_pool_01'].map(
|
|
18
|
+
name => ({ name, ...blank, ...(seed[name] ?? {}) }),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildCohortTree', () => {
|
|
23
|
+
const tree = buildCohortTree(seededSamples())
|
|
24
|
+
|
|
25
|
+
it('should ignore unassigned samples', () => {
|
|
26
|
+
expect(tree.size).toBe(2)
|
|
27
|
+
expect(tree.has('Responder')).toBe(true)
|
|
28
|
+
expect(tree.has('Non-responder')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should total Responder across its two subgroups', () => {
|
|
32
|
+
const responder = tree.get('Responder')!
|
|
33
|
+
expect(responder.total).toBe(2)
|
|
34
|
+
expect(responder.subs.get('Day 7')!.total).toBe(1)
|
|
35
|
+
expect(responder.subs.get('Day 7')!.subs2.get('Batch 1')).toBe(1)
|
|
36
|
+
expect(responder.subs.get('Day 14')!.total).toBe(1)
|
|
37
|
+
expect(responder.subs.get('Day 14')!.subs2.get('—')).toBe(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should nest Non-responder under Day 7 / Batch 2', () => {
|
|
41
|
+
const nonResponder = tree.get('Non-responder')!
|
|
42
|
+
expect(nonResponder.total).toBe(1)
|
|
43
|
+
expect(nonResponder.subs.get('Day 7')!.subs2.get('Batch 2')).toBe(1)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('filterSamples', () => {
|
|
48
|
+
const samples = seededSamples()
|
|
49
|
+
|
|
50
|
+
it('should match names case-insensitively on a substring query', () => {
|
|
51
|
+
const matches = filterSamples(samples, 'tumorb', 'all')
|
|
52
|
+
expect(matches.map(m => m.sample.name)).toEqual(['Pt002_TumorB_d7_rep1'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should preserve the original index for each match', () => {
|
|
56
|
+
const matches = filterSamples(samples, 'QC', 'all')
|
|
57
|
+
expect(matches).toEqual([{ sample: samples[3], index: 3 }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should show only unassigned samples when filtered to unassigned', () => {
|
|
61
|
+
const matches = filterSamples(samples, '', 'unassigned')
|
|
62
|
+
expect(matches.map(m => m.sample.name)).toEqual(['QC_pool_01'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should show only assigned samples when filtered to assigned', () => {
|
|
66
|
+
const matches = filterSamples(samples, '', 'assigned')
|
|
67
|
+
expect(matches).toHaveLength(3)
|
|
68
|
+
expect(matches.every(m => !!m.sample.group)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('assignSamples', () => {
|
|
73
|
+
const samples = seededSamples()
|
|
74
|
+
|
|
75
|
+
it('should assign group and colour to the selected indices only', () => {
|
|
76
|
+
const next = assignSamples(samples, new Set([3]), {
|
|
77
|
+
grp: 'Pooled',
|
|
78
|
+
sub: '',
|
|
79
|
+
sub2: '',
|
|
80
|
+
color: 'c-slate',
|
|
81
|
+
})
|
|
82
|
+
expect(next[3]).toMatchObject({ group: 'Pooled', color: 'c-slate' })
|
|
83
|
+
expect(next[0]).toEqual(samples[0])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should null out blank subgroup and sub-subgroup values', () => {
|
|
87
|
+
const next = assignSamples(samples, new Set([3]), {
|
|
88
|
+
grp: ' Pooled ',
|
|
89
|
+
sub: ' ',
|
|
90
|
+
sub2: '',
|
|
91
|
+
color: 'c-slate',
|
|
92
|
+
})
|
|
93
|
+
expect(next[3].group).toBe('Pooled')
|
|
94
|
+
expect(next[3].sub).toBeNull()
|
|
95
|
+
expect(next[3].sub2).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure adapters mapping the `useAutoGroup` engine state to the controlled
|
|
3
|
+
* Smart Group UI (`SmartGroupFieldRecipe` / `SmartGroupManual`) and back to an
|
|
4
|
+
* `AutoGroupResult`. Framework-free so the mapping is unit-testable in isolation
|
|
5
|
+
* from `AutoGroupModal.vue`.
|
|
6
|
+
*/
|
|
7
|
+
import type { AutoGroupResult, ColumnInfo, ColumnRole } from '../types/auto-group'
|
|
8
|
+
import type { SampleGroup } from '../types'
|
|
9
|
+
import type { FieldRecipeGroup, QcRoute, SmartGroupField } from './SmartGroupFieldRecipe.groups'
|
|
10
|
+
import type { ManualSample } from './SmartGroupManual.cohorts'
|
|
11
|
+
import type { SmartGroupSeed } from './SmartGroup.types'
|
|
12
|
+
|
|
13
|
+
const ROLE_LABELS: Record<ColumnRole, string> = {
|
|
14
|
+
'constant': 'Constant',
|
|
15
|
+
'factor': 'Factor',
|
|
16
|
+
'replicate': 'Replicate',
|
|
17
|
+
'run-order': 'Run',
|
|
18
|
+
'numeric': 'Numeric',
|
|
19
|
+
'class-tag': 'Class tag',
|
|
20
|
+
'ignore': 'Ignore',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Human label for a column role; defaults to `Factor` when unset. */
|
|
24
|
+
export function roleLabel(role?: ColumnRole): string {
|
|
25
|
+
return role ? ROLE_LABELS[role] : 'Factor'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Columns worth offering as grouping toggles: factors and replicates, plus any
|
|
30
|
+
* column already enabled in `groupBy` (so a manual group-by choice still shows).
|
|
31
|
+
* Preserves the engine's column order.
|
|
32
|
+
*/
|
|
33
|
+
export function candidateColumns(columns: ColumnInfo[], enabled: ReadonlySet<number>): ColumnInfo[] {
|
|
34
|
+
return columns.filter(c => c.role === 'factor' || c.role === 'replicate' || enabled.has(c.index))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The candidate column indices in display order, so card index → engine column. */
|
|
38
|
+
export function candidateColumnIndices(columns: ColumnInfo[], enabled: ReadonlySet<number>): number[] {
|
|
39
|
+
return candidateColumns(columns, enabled).map(c => c.index)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map engine columns to Field Recipe cards + their on/off state (same order). */
|
|
43
|
+
export function engineFieldsToCards(
|
|
44
|
+
columns: ColumnInfo[],
|
|
45
|
+
enabled: ReadonlySet<number>,
|
|
46
|
+
): { fields: SmartGroupField[]; enabled: boolean[] } {
|
|
47
|
+
const candidates = candidateColumns(columns, enabled)
|
|
48
|
+
return {
|
|
49
|
+
fields: candidates.map(c => ({
|
|
50
|
+
key: c.displayName ?? c.name,
|
|
51
|
+
role: roleLabel(c.role),
|
|
52
|
+
vals: c.uniqueValues.join(', '),
|
|
53
|
+
rep: c.role === 'replicate',
|
|
54
|
+
})),
|
|
55
|
+
enabled: candidates.map(c => enabled.has(c.index)),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Map engine sample groups to the live preview rail shape. */
|
|
60
|
+
export function engineGroupsToRail(groups: SampleGroup[]): FieldRecipeGroup[] {
|
|
61
|
+
return groups.map(g => ({ name: g.name, count: g.samples.length, color: g.color }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ROUTE_TO_ENGINE: Record<QcRoute, 'overlay' | 'exclude' | 'mix'> = {
|
|
65
|
+
'Overlay only': 'overlay',
|
|
66
|
+
'Exclude': 'exclude',
|
|
67
|
+
'Mix into groups': 'mix',
|
|
68
|
+
}
|
|
69
|
+
const ENGINE_TO_ROUTE: Record<'overlay' | 'exclude' | 'mix', QcRoute> = {
|
|
70
|
+
overlay: 'Overlay only',
|
|
71
|
+
exclude: 'Exclude',
|
|
72
|
+
mix: 'Mix into groups',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Map the Field Recipe QC label to the engine's class disposition routing. */
|
|
76
|
+
export function qcRouteToEngine(route: QcRoute): 'overlay' | 'exclude' | 'mix' {
|
|
77
|
+
return ROUTE_TO_ENGINE[route]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Map the engine's QC routing back to the Field Recipe label. */
|
|
81
|
+
export function engineToQcRoute(routing: 'overlay' | 'exclude' | 'mix'): QcRoute {
|
|
82
|
+
return ENGINE_TO_ROUTE[routing]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Seed the manual cohort builder from existing groups. The hierarchical group
|
|
87
|
+
* name (`group/sub/sub2`) splits into the three cohort levels; colour carries
|
|
88
|
+
* over. One seed entry per sample. Names with more than three `/`-segments keep
|
|
89
|
+
* the remainder in `sub2` (rejoined with `/`) so the round-trip is lossless —
|
|
90
|
+
* `manualSamplesToResult` re-joins on `/`.
|
|
91
|
+
*/
|
|
92
|
+
export function seedFromGroups(groups: SampleGroup[]): Record<string, SmartGroupSeed> {
|
|
93
|
+
const seed: Record<string, SmartGroupSeed> = {}
|
|
94
|
+
for (const group of groups) {
|
|
95
|
+
const parts = group.name.split('/')
|
|
96
|
+
const grp = parts[0]
|
|
97
|
+
const sub = parts[1]
|
|
98
|
+
const sub2 = parts.length > 3 ? parts.slice(2).join('/') : parts[2]
|
|
99
|
+
for (const sample of group.samples) {
|
|
100
|
+
seed[sample] = {
|
|
101
|
+
group: grp,
|
|
102
|
+
sub: sub || undefined,
|
|
103
|
+
sub2: sub2 || undefined,
|
|
104
|
+
color: group.color,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return seed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert the manual cohort assignments back into an `AutoGroupResult`.
|
|
113
|
+
* `baseGroups` (the modal's incoming `groups` prop) are preserved in their
|
|
114
|
+
* original order and colour — including any that end up empty — so re-opening
|
|
115
|
+
* the modal and moving samples between cohorts is non-destructive. Newly created
|
|
116
|
+
* cohorts are appended in first-seen order.
|
|
117
|
+
*/
|
|
118
|
+
export function manualSamplesToResult(
|
|
119
|
+
samples: ManualSample[],
|
|
120
|
+
baseGroups: SampleGroup[] = [],
|
|
121
|
+
): AutoGroupResult {
|
|
122
|
+
const assigned = new Map<string, { color: string; samples: string[] }>()
|
|
123
|
+
const order: string[] = []
|
|
124
|
+
for (const sample of samples) {
|
|
125
|
+
if (!sample.group) continue
|
|
126
|
+
const name = [sample.group, sample.sub, sample.sub2].filter(Boolean).join('/')
|
|
127
|
+
if (!assigned.has(name)) {
|
|
128
|
+
assigned.set(name, { color: sample.color ?? '', samples: [] })
|
|
129
|
+
order.push(name)
|
|
130
|
+
}
|
|
131
|
+
assigned.get(name)!.samples.push(sample.name)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const groups: SampleGroup[] = []
|
|
135
|
+
const consumed = new Set<string>()
|
|
136
|
+
for (const base of baseGroups) {
|
|
137
|
+
consumed.add(base.name)
|
|
138
|
+
groups.push({ name: base.name, color: base.color, samples: assigned.get(base.name)?.samples ?? [] })
|
|
139
|
+
}
|
|
140
|
+
for (const name of order) {
|
|
141
|
+
if (consumed.has(name)) continue
|
|
142
|
+
const entry = assigned.get(name)!
|
|
143
|
+
groups.push({ name, color: entry.color, samples: entry.samples })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { groups, experimentalGroups: groups, qcGroups: [], metadata: [], excludedSamples: [] }
|
|
147
|
+
}
|