@morscherlab/mint-sdk 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/PlateEditor.vue.d.ts +2 -0
  7. package/dist/components/SmartGroup.types.d.ts +22 -0
  8. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  9. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  10. package/dist/components/index.js +1 -1
  11. package/dist/{components-vHfxV2PB.js → components-CEePoD3W.js} +502 -264
  12. package/dist/components-CEePoD3W.js.map +1 -0
  13. package/dist/composables/autoGroup/classify.d.ts +7 -1
  14. package/dist/composables/autoGroup/columns.d.ts +16 -1
  15. package/dist/composables/autoGroup/index.d.ts +5 -7
  16. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  17. package/dist/composables/index.js +2 -2
  18. package/dist/composables/useAutoGroup.d.ts +13 -2
  19. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  20. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  21. package/dist/index.js +3 -3
  22. package/dist/install.js +1 -1
  23. package/dist/permissions.js +2 -2
  24. package/dist/permissions.js.map +1 -1
  25. package/dist/styles.css +71 -82
  26. package/dist/types/auto-group.d.ts +1 -1
  27. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  28. package/dist/validation-CukfLjy-.js.map +1 -0
  29. package/package.json +2 -2
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/PlateEditor.test.ts +10 -0
  33. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  34. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  35. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  36. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  37. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  38. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  39. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  40. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  41. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  42. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  43. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  44. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  47. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  48. package/src/__tests__/utils/permissions.test.ts +15 -0
  49. package/src/components/AutoGroupModal.adapter.ts +80 -9
  50. package/src/components/AutoGroupModal.vue +41 -8
  51. package/src/components/PlateEditor.vue +3 -0
  52. package/src/components/SmartGroup.types.ts +23 -0
  53. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  54. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  55. package/src/composables/autoGroup/classify.ts +26 -7
  56. package/src/composables/autoGroup/columns.ts +144 -36
  57. package/src/composables/autoGroup/compose.ts +8 -10
  58. package/src/composables/autoGroup/fingerprint.ts +1 -1
  59. package/src/composables/autoGroup/index.ts +5 -12
  60. package/src/composables/autoGroup/tokenLength.ts +8 -0
  61. package/src/composables/autoGroup/tokenize.ts +148 -1
  62. package/src/composables/autoGroup/vocab.json +5 -11
  63. package/src/composables/useAutoGroup.ts +92 -79
  64. package/src/permissions.ts +3 -2
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-vHfxV2PB.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -0,0 +1,123 @@
1
+ exp114_250730_YY_HEK_DFMO_Put_blank_01
2
+ exp114_250730_YY_HEK_DFMO_Put_blank_05
3
+ exp114_250730_YY_HEK_DFMO_Put_blank_05_b
4
+ exp114_250730_YY_HEK_DFMO_Put_blank_119
5
+ exp114_250730_YY_HEK_DFMO_Put_blank_120
6
+ exp114_250730_YY_HEK_DFMO_Put_blank_33
7
+ exp114_250730_YY_HEK_DFMO_Put_blank_58b_postCrash
8
+ exp114_250730_YY_HEK_DFMO_Put_blank_61
9
+ exp114_250730_YY_HEK_DFMO_Put_blank_90
10
+ exp114_250730_YY_HEK_DFMO_Put_DigestControl_91
11
+ exp114_250730_YY_HEK_DFMO_Put_HMW_1-1_35
12
+ exp114_250730_YY_HEK_DFMO_Put_HMW_1-2_44
13
+ exp114_250730_YY_HEK_DFMO_Put_HMW_1-3_52
14
+ exp114_250730_YY_HEK_DFMO_Put_HMW_2-1_36
15
+ exp114_250730_YY_HEK_DFMO_Put_HMW_2-2_45
16
+ exp114_250730_YY_HEK_DFMO_Put_HMW_2-3_53
17
+ exp114_250730_YY_HEK_DFMO_Put_HMW_3-1_37
18
+ exp114_250730_YY_HEK_DFMO_Put_HMW_3-2_46
19
+ exp114_250730_YY_HEK_DFMO_Put_HMW_3-3_54
20
+ exp114_250730_YY_HEK_DFMO_Put_HMW_4-1_09
21
+ exp114_250730_YY_HEK_DFMO_Put_HMW_4-1_38
22
+ exp114_250730_YY_HEK_DFMO_Put_HMW_4-2_47
23
+ exp114_250730_YY_HEK_DFMO_Put_HMW_4-3_55
24
+ exp114_250730_YY_HEK_DFMO_Put_HMW_5-1_39
25
+ exp114_250730_YY_HEK_DFMO_Put_HMW_5-2_48
26
+ exp114_250730_YY_HEK_DFMO_Put_HMW_5-3_56
27
+ exp114_250730_YY_HEK_DFMO_Put_HMW_6-1_40
28
+ exp114_250730_YY_HEK_DFMO_Put_HMW_6-2_49
29
+ exp114_250730_YY_HEK_DFMO_Put_HMW_6-3_57
30
+ exp114_250730_YY_HEK_DFMO_Put_HMW_7-1_41
31
+ exp114_250730_YY_HEK_DFMO_Put_HMW_7-2_50
32
+ exp114_250730_YY_HEK_DFMO_Put_HMW_7-3_rep2_89
33
+ exp114_250730_YY_HEK_DFMO_Put_HMW_8-1_42
34
+ exp114_250730_YY_HEK_DFMO_Put_HMW_8-3_59
35
+ exp114_250730_YY_HEK_DFMO_Put_HMW_8-3_59_20250731221931
36
+ exp114_250730_YY_HEK_DFMO_Put_HMW_9-1_43
37
+ exp114_250730_YY_HEK_DFMO_Put_HMW_9-2_51
38
+ exp114_250730_YY_HEK_DFMO_Put_HMW_9-3_60
39
+ exp114_250730_YY_HEK_DFMO_Put_HMW_DigestControl_34
40
+ exp114_250730_YY_HEK_DFMO_Put_MMW_1-1_63
41
+ exp114_250730_YY_HEK_DFMO_Put_MMW_1-2_72
42
+ exp114_250730_YY_HEK_DFMO_Put_MMW_1-3_80
43
+ exp114_250730_YY_HEK_DFMO_Put_MMW_2-1_64
44
+ exp114_250730_YY_HEK_DFMO_Put_MMW_2-2_73
45
+ exp114_250730_YY_HEK_DFMO_Put_MMW_2-3_81
46
+ exp114_250730_YY_HEK_DFMO_Put_MMW_3-1_65
47
+ exp114_250730_YY_HEK_DFMO_Put_MMW_3-2_74
48
+ exp114_250730_YY_HEK_DFMO_Put_MMW_3-3_82
49
+ exp114_250730_YY_HEK_DFMO_Put_MMW_4-1_66
50
+ exp114_250730_YY_HEK_DFMO_Put_MMW_4-3_83
51
+ exp114_250730_YY_HEK_DFMO_Put_MMW_5-1_67
52
+ exp114_250730_YY_HEK_DFMO_Put_MMW_5-2_75
53
+ exp114_250730_YY_HEK_DFMO_Put_MMW_5-3_84
54
+ exp114_250730_YY_HEK_DFMO_Put_MMW_6-1_68
55
+ exp114_250730_YY_HEK_DFMO_Put_MMW_6-2_76
56
+ exp114_250730_YY_HEK_DFMO_Put_MMW_6-3_85
57
+ exp114_250730_YY_HEK_DFMO_Put_MMW_7-1_69
58
+ exp114_250730_YY_HEK_DFMO_Put_MMW_7-2_77
59
+ exp114_250730_YY_HEK_DFMO_Put_MMW_7-3_86
60
+ exp114_250730_YY_HEK_DFMO_Put_MMW_8-1_70
61
+ exp114_250730_YY_HEK_DFMO_Put_MMW_8-2_78
62
+ exp114_250730_YY_HEK_DFMO_Put_MMW_8-3_87
63
+ exp114_250730_YY_HEK_DFMO_Put_MMW_9-1_71
64
+ exp114_250730_YY_HEK_DFMO_Put_MMW_9-2_79
65
+ exp114_250730_YY_HEK_DFMO_Put_MMW_9-3_88
66
+ exp114_250730_YY_HEK_DFMO_Put_MMW_DigestControl_62
67
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-1_06
68
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-1_250ng_03
69
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-1_500ng_04
70
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-1_92
71
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-2_101
72
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-2_15
73
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-3_110
74
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_1-3_24
75
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-1_07
76
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-1_93
77
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-2_102
78
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-2_16
79
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-3_111
80
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_2-3_25
81
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-1_08
82
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-1_94
83
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-2_103
84
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-2_17
85
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-3_112
86
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_3-3_26
87
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-1_09
88
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-1_95
89
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-2_104
90
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-2_18
91
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-3_113
92
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_4-3_27
93
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-1_10
94
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-1_96
95
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-2_105
96
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-2_19
97
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-3_114
98
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_5-3_28
99
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-1_11
100
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-1_97
101
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-2_106
102
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-2_20
103
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-3_115
104
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_6-3_29
105
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-1_12
106
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-1_98
107
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-2_107
108
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-2_21
109
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-3_116
110
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_7-3_30
111
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-1_13
112
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-1_99
113
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-2_108
114
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-2_22
115
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-3_117
116
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_8-3_31
117
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-1_100
118
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-1_14
119
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-2_109
120
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-2_23
121
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-3_118
122
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_9-3_32
123
+ exp114_250730_YY_HEK_DFMO_Put_tRNA_DigControl_02
@@ -0,0 +1,63 @@
1
+ exp214_251220_SK_RNA_blank_002
2
+ exp214_251220_SK_RNA_blank_002_B
3
+ exp214_251220_SK_RNA_blank_015
4
+ exp214_251220_SK_RNA_blank_029
5
+ exp214_251220_SK_RNA_blank_043
6
+ exp214_251220_SK_RNA_blank_057
7
+ exp214_251220_SK_RNA_NB1_AML_D6_C1_019
8
+ exp214_251220_SK_RNA_NB1_AML_D6_C2_061
9
+ exp214_251220_SK_RNA_NB1_AML_D6_C3_014
10
+ exp214_251220_SK_RNA_NB1_AML_D6_H1_034
11
+ exp214_251220_SK_RNA_NB1_AML_D6_H2_056
12
+ exp214_251220_SK_RNA_NB1_AML_D6_H3_038
13
+ exp214_251220_SK_RNA_NB1_AML_D6_L1_036
14
+ exp214_251220_SK_RNA_NB1_AML_D6_L2_005
15
+ exp214_251220_SK_RNA_NB1_AML_D6_L3_050
16
+ exp214_251220_SK_RNA_NB1_D1_A1_053
17
+ exp214_251220_SK_RNA_NB1_D1_A2_052
18
+ exp214_251220_SK_RNA_NB1_D1_A3_054
19
+ exp214_251220_SK_RNA_NB1_D1_AS1_011
20
+ exp214_251220_SK_RNA_NB1_D1_AS2_007
21
+ exp214_251220_SK_RNA_NB1_D1_AS3_033
22
+ exp214_251220_SK_RNA_NB1_D1_C1_030
23
+ exp214_251220_SK_RNA_NB1_D1_C2_055
24
+ exp214_251220_SK_RNA_NB1_D1_C3_031
25
+ exp214_251220_SK_RNA_NB1_D1_S1_003
26
+ exp214_251220_SK_RNA_NB1_D1_S2_044
27
+ exp214_251220_SK_RNA_NB1_D1_S3_035
28
+ exp214_251220_SK_RNA_NB1_D3_A1_021
29
+ exp214_251220_SK_RNA_NB1_D3_A2_028
30
+ exp214_251220_SK_RNA_NB1_D3_A3_013
31
+ exp214_251220_SK_RNA_NB1_D3_AS1_027
32
+ exp214_251220_SK_RNA_NB1_D3_AS2_049
33
+ exp214_251220_SK_RNA_NB1_D3_AS3_025
34
+ exp214_251220_SK_RNA_NB1_D3_C1_045
35
+ exp214_251220_SK_RNA_NB1_D3_C2_023
36
+ exp214_251220_SK_RNA_NB1_D3_C3_059
37
+ exp214_251220_SK_RNA_NB1_D3_S1_032
38
+ exp214_251220_SK_RNA_NB1_D3_S2_008
39
+ exp214_251220_SK_RNA_NB1_D3_S3_016
40
+ exp214_251220_SK_RNA_NB1_D6_A1_037
41
+ exp214_251220_SK_RNA_NB1_D6_A2_017
42
+ exp214_251220_SK_RNA_NB1_D6_A3_012
43
+ exp214_251220_SK_RNA_NB1_D6_AS1_024
44
+ exp214_251220_SK_RNA_NB1_D6_AS2_060
45
+ exp214_251220_SK_RNA_NB1_D6_AS3_048
46
+ exp214_251220_SK_RNA_NB1_D6_AzaC1_004
47
+ exp214_251220_SK_RNA_NB1_D6_AzaC2_010
48
+ exp214_251220_SK_RNA_NB1_D6_AzaC3_051
49
+ exp214_251220_SK_RNA_NB1_D6_C1_039
50
+ exp214_251220_SK_RNA_NB1_D6_C2_022
51
+ exp214_251220_SK_RNA_NB1_D6_C3_046
52
+ exp214_251220_SK_RNA_NB1_D6_Ona1_058
53
+ exp214_251220_SK_RNA_NB1_D6_Ona2_020
54
+ exp214_251220_SK_RNA_NB1_D6_Ona3_042
55
+ exp214_251220_SK_RNA_NB1_D6_S1_041
56
+ exp214_251220_SK_RNA_NB1_D6_S2_026
57
+ exp214_251220_SK_RNA_NB1_D6_S3_006
58
+ exp214_251220_SK_RNA_NB1_D6_STC1_018
59
+ exp214_251220_SK_RNA_NB1_D6_STC2_009
60
+ exp214_251220_SK_RNA_NB1_D6_STC3_040
61
+ exp214_251220_SK_RNA_NB1_DigControl_047
62
+ exp214_251220_SK_RNA_NucleosideMix_10nM_001
63
+ exp214_251220_SK_RNA_NucleosideMix_10nM_062
@@ -39,6 +39,21 @@ describe('permission helpers', () => {
39
39
  expect(canAccessPlugin(member, 'reports')).toBe(false)
40
40
  })
41
41
 
42
+ it.each(['none', '', {}, { dose: true }, 42])(
43
+ 'should deny every plugin when plugin_access is malformed (%j)',
44
+ (access) => {
45
+ const user = { role: 'member', role_obj: { slug: 'member', plugin_access: access as never } }
46
+
47
+ expect(canAccessPlugin(user, 'dose')).toBe(false)
48
+ },
49
+ )
50
+
51
+ it('should allow every plugin when the user is an administrator', () => {
52
+ const admin = { role: 'admin', role_obj: { slug: 'admin', plugin_access: ['dose'] } }
53
+
54
+ expect(canAccessPlugin(admin, 'reports')).toBe(true)
55
+ })
56
+
42
57
  it('allows notice publishers to enter the admin panel', () => {
43
58
  const publisher = {
44
59
  role: 'notice-publisher',
@@ -4,17 +4,85 @@
4
4
  * `AutoGroupResult`. Framework-free so the mapping is unit-testable in isolation
5
5
  * from `AutoGroupModal.vue`.
6
6
  */
7
- import type { AutoGroupResult, ClassSchema, ColumnInfo, ColumnRole } from '../types/auto-group'
7
+ import type { AutoGroupResult, ClassSchema, ColumnInfo, ColumnRole, SampleClass } from '../types/auto-group'
8
8
  import type { SampleGroup } from '../types'
9
9
  import type { FieldRecipeGroup, QcRoute, SmartGroupField } from './SmartGroupFieldRecipe.groups'
10
10
  import type { ManualSample } from './SmartGroupManual.cohorts'
11
- import type { SmartGroupSeed } from './SmartGroup.types'
11
+ import type { FieldRecipeAnatomyToken, FieldRecipeQcChip, SmartGroupSeed } from './SmartGroup.types'
12
+ import { GROUPING_FIELD_ROLES, classKey, isExperimentalKind } from '../composables/autoGroup'
13
+ import type { ExperimentHeader } from '../composables/autoGroup'
14
+
15
+ const PREVIEW_VALUES = 3
16
+
17
+ /** Value preview of a field card: the first three values, then the count of the rest. */
18
+ export function previewValues(values: string[]): string {
19
+ const shown = values.slice(0, PREVIEW_VALUES).join(', ')
20
+ return values.length > PREVIEW_VALUES ? `${shown} … +${values.length - PREVIEW_VALUES}` : shown
21
+ }
22
+
23
+ /** Experiment line of the modal header: `exp157 · 2025-10-13 · LT`. */
24
+ export function experimentLabel(header: ExperimentHeader): string {
25
+ return [header.code, header.date, header.operator].filter(Boolean).join(' · ')
26
+ }
27
+
28
+ /**
29
+ * Names of all columns of the experimental classes. A field cannot take one
30
+ * of them: the engine merges fields by name.
31
+ */
32
+ export function engineColumnNames(schemas: Record<string, ClassSchema>): string[] {
33
+ const names = Object.values(schemas)
34
+ .filter(schema => isExperimentalKind(schema.classKind))
35
+ .flatMap(schema => schema.columns.map(column => column.displayName ?? column.name))
36
+ return [...new Set(names)]
37
+ }
38
+
39
+ /** One chip for each QC-type class, with its sample count. */
40
+ export function engineQcChips(classes: SampleClass[]): FieldRecipeQcChip[] {
41
+ return classes
42
+ .filter(c => !isExperimentalKind(c.kind))
43
+ .map(c => ({ name: c.label, count: c.members.length }))
44
+ }
45
+
46
+ /**
47
+ * The tokens of one study sample with the role the engine gave each of them,
48
+ * so the user sees how the names were read. The sample is the first member of
49
+ * the largest experimental class. `cards` supplies the on/off state and the
50
+ * layer of the tokens that are grouping fields.
51
+ */
52
+ export function engineAnatomy(
53
+ classes: SampleClass[],
54
+ schemas: Record<string, ClassSchema>,
55
+ tokenized: string[][],
56
+ cards: { fields: SmartGroupField[]; layers: (number | null)[] },
57
+ ): FieldRecipeAnatomyToken[] {
58
+ const cls = classes
59
+ .filter(c => isExperimentalKind(c.kind) && schemas[classKey(c)])
60
+ .sort((a, b) => b.members.length - a.members.length)[0]
61
+ if (!cls) return []
62
+ const tokens = tokenized[cls.members[0]] ?? []
63
+ return schemas[classKey(cls)].columns.flatMap((column): FieldRecipeAnatomyToken[] => {
64
+ const text = column.sourceIndices.map(index => tokens[index] ?? '').join('_')
65
+ if (!text) return []
66
+ const name = column.displayName ?? column.name
67
+ const card = cards.fields.findIndex(field => field.key === name)
68
+ if (card === -1) {
69
+ // A token that is not a field shows an inferred name only when the
70
+ // name says something (Date, Injection #); `Username` or `Matrix 2` on a
71
+ // constant token does not.
72
+ const caption = /^(Field \d+|Username.*|.* \d+)$/.test(name) ? roleLabel(column.role).toLowerCase() : name
73
+ return [{ text, caption, state: 'fixed' }]
74
+ }
75
+ const layer = cards.layers[card]
76
+ return [layer === null ? { text, caption: name, state: 'off' } : { text, caption: String(layer), state: 'on' }]
77
+ })
78
+ }
12
79
 
13
80
  const ROLE_LABELS: Record<ColumnRole, string> = {
14
81
  'constant': 'Constant',
15
82
  'factor': 'Factor',
16
83
  'replicate': 'Replicate',
17
84
  'run-order': 'Run',
85
+ 'id': 'Sample ID',
18
86
  'numeric': 'Numeric',
19
87
  'class-tag': 'Class tag',
20
88
  'ignore': 'Ignore',
@@ -26,14 +94,15 @@ export function roleLabel(role?: ColumnRole): string {
26
94
  }
27
95
 
28
96
  /**
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).
97
+ * Columns worth offering as grouping toggles: factors, numerics, replicates and
98
+ * sample IDs (the last two are off by default), plus any column already enabled
99
+ * in `groupBy` (so a manual group-by choice still shows).
31
100
  * Preserves the engine's column order. `groupBy` is the schema's ORDERED layer
32
101
  * list — entry N is hierarchy layer N of the composed group names.
33
102
  */
34
103
  export function candidateColumns(columns: ColumnInfo[], groupBy: readonly number[]): ColumnInfo[] {
35
104
  const enabled = new Set(groupBy)
36
- return columns.filter(c => c.role === 'factor' || c.role === 'replicate' || enabled.has(c.index))
105
+ return columns.filter(c => GROUPING_FIELD_ROLES.has(c.role) || enabled.has(c.index))
37
106
  }
38
107
 
39
108
  /**
@@ -50,8 +119,8 @@ export function engineFieldsToCards(
50
119
  fields: candidates.map(c => ({
51
120
  key: c.displayName ?? c.name,
52
121
  role: roleLabel(c.role),
53
- vals: c.uniqueValues.join(', '),
54
- rep: c.role === 'replicate',
122
+ vals: previewValues(c.uniqueValues),
123
+ rep: c.role === 'replicate' || c.role === 'id',
55
124
  })),
56
125
  enabled: candidates.map(c => groupBy.includes(c.index)),
57
126
  layers: candidates.map(c => {
@@ -81,6 +150,8 @@ export function engineSchemasToCards(
81
150
  const enabled = new Set<string>()
82
151
 
83
152
  for (const [, schema] of ordered) {
153
+ // An overlay class (QC, blank, standard) is one group; its columns are not fields.
154
+ if (!isExperimentalKind(schema.classKind)) continue
84
155
  for (const index of schema.groupBy) {
85
156
  const column = schema.columns.find(candidate => candidate.index === index)
86
157
  if (!column) continue
@@ -101,8 +172,8 @@ export function engineSchemasToCards(
101
172
  fields: cards.map(([key, column]) => ({
102
173
  key,
103
174
  role: roleLabel(column.role),
104
- vals: column.uniqueValues.join(', '),
105
- rep: column.role === 'replicate',
175
+ vals: previewValues(column.uniqueValues),
176
+ rep: column.role === 'replicate' || column.role === 'id',
106
177
  })),
107
178
  enabled: cards.map(([key]) => enabled.has(key)),
108
179
  layers: cards.map(([key]) => {
@@ -17,15 +17,19 @@ import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
17
17
  import { useApi } from '../composables/useApi'
18
18
  import { readFileAsText } from '../composables/useFileImport'
19
19
  import {
20
+ engineAnatomy,
21
+ engineColumnNames,
20
22
  engineGroupsToRail,
23
+ engineQcChips,
21
24
  engineSchemasToCards,
22
25
  engineToQcRoute,
26
+ experimentLabel,
23
27
  manualSamplesToResult,
24
28
  qcRouteToEngine,
25
29
  seedFromGroups,
26
30
  } from './AutoGroupModal.adapter'
27
31
  import { SAMPLE_GROUP_COLOR_OPTIONS } from './SampleSelector.colors'
28
- import type { QcRoute } from './SmartGroupFieldRecipe.groups'
32
+ import { mostlySingletons, type QcRoute } from './SmartGroupFieldRecipe.groups'
29
33
  import type { ManualSample } from './SmartGroupManual.cohorts'
30
34
  import type { ManualPaletteEntry } from './SmartGroup.types'
31
35
  import type { AutoGroupResult } from '../types/auto-group'
@@ -95,13 +99,32 @@ const qcSampleCount = computed(() =>
95
99
  .filter(c => c.kind !== 'biological' && c.kind !== 'unknown')
96
100
  .reduce((acc, c) => acc + c.members.length, 0),
97
101
  )
98
- const qcChip = computed(() =>
99
- qcRouting.value !== 'mix' && qcSampleCount.value > 0
100
- ? { name: 'QC_Pool', count: qcSampleCount.value }
101
- : null,
102
- )
102
+ const qcChips = computed(() => (qcRouting.value === 'mix' ? [] : engineQcChips(autoGroup.classes.value)))
103
103
  const fieldRecipeRoute = computed<QcRoute>(() => engineToQcRoute(qcRouting.value))
104
104
  const headerCount = computed(() => autoGroup.samples.value.length)
105
+ const headerLabel = computed(() => experimentLabel(autoGroup.experiment.value))
106
+ const reservedNames = computed(() => engineColumnNames(autoGroup.schemas.value))
107
+ // The example name is shown for pasted names only: CSV and experiment input
108
+ // have columns, not name tokens.
109
+ const anatomy = computed(() =>
110
+ autoGroup.inputMode.value === 'paste'
111
+ ? engineAnatomy(autoGroup.classes.value, autoGroup.schemas.value, autoGroup.tokenized.value, fieldCards.value)
112
+ : [],
113
+ )
114
+ // A design with no replicate in the names gives groups of one sample. Each
115
+ // field that is on then shows how many groups remain when the user switches
116
+ // it off, so the user can find the field that is not part of the condition.
117
+ // The rule is the one of the summary callout, so the two show together.
118
+ const fieldHints = computed(() => {
119
+ const needsHelp = mostlySingletons(railGroups.value)
120
+ // ponytail: one full composeGroups for each field that is on (a few ms for
121
+ // 200 samples). Count distinct group keys instead if a large batch is slow.
122
+ return fieldCards.value.fields.map((field, index) => {
123
+ if (!needsHelp || !fieldCards.value.enabled[index]) return null
124
+ const remaining = autoGroup.groupCountWithout(field.key)
125
+ return `off → ${remaining} ${remaining === 1 ? 'group' : 'groups'}`
126
+ })
127
+ })
105
128
 
106
129
  // The cards describe the batch, not one class, so edits fan out to every class
107
130
  // exposing the field — otherwise a multi-class batch (e.g. a Liver class and a
@@ -116,6 +139,11 @@ function onMoveField(cardIndex: number, direction: 'up' | 'down') {
116
139
  if (!field) return
117
140
  autoGroup.moveGroupByField(field.key, direction)
118
141
  }
142
+ function onRenameField(cardIndex: number, name: string) {
143
+ const field = fieldCards.value.fields[cardIndex]
144
+ if (!field) return
145
+ autoGroup.renameGroupByField(field.key, name)
146
+ }
119
147
  function onRouteChange(route: QcRoute) {
120
148
  setQcRouting(qcRouteToEngine(route))
121
149
  }
@@ -257,15 +285,20 @@ watch(
257
285
  :enabled="fieldCards.enabled"
258
286
  :layers="fieldCards.layers"
259
287
  :groups="railGroups"
260
- :qc-chip="qcChip"
288
+ :qc-chips="qcChips"
261
289
  :route="fieldRecipeRoute"
262
290
  :header-count="headerCount"
263
291
  :qc-count="qcSampleCount"
264
292
  :can-download-template="autoGroup.canDownloadTemplate.value"
265
293
  :loading="experimentLoading"
266
- experiment-label=""
294
+ :experiment-label="headerLabel"
295
+ :anatomy="anatomy"
296
+ :hints="fieldHints"
297
+ :reserved-names="reservedNames"
298
+ renamable
267
299
  @toggle="onToggleField"
268
300
  @move="onMoveField"
301
+ @rename="onRenameField"
269
302
  @update:route="onRouteChange"
270
303
  @csv-file="onCsvFile"
271
304
  @download-template="onDownloadTemplate"
@@ -92,6 +92,8 @@ interface Props {
92
92
  /** No editing controls; well clicks still emit `well-click`. */
93
93
  readonly?: boolean
94
94
  size?: WellPlateSize
95
+ /** Footer text while nothing is selected; replaces the built-in selection hint. */
96
+ hint?: string
95
97
  }
96
98
 
97
99
  const props = withDefaults(defineProps<Props>(), {
@@ -446,6 +448,7 @@ function assign(groupId: string | undefined) {
446
448
 
447
449
  const hint = computed(() => {
448
450
  if (dragMode.value) return 'Drag a filled well onto another to move or swap it.'
451
+ if (props.hint) return props.hint
449
452
  if (!props.groups) return ''
450
453
  if (railOn.value) {
451
454
  return inspectorOn.value
@@ -35,6 +35,16 @@ export interface FieldRecipeQcChip {
35
35
  count: number
36
36
  }
37
37
 
38
+ /** One token of the example sample name, with the role it was read as. */
39
+ export interface FieldRecipeAnatomyToken {
40
+ /** The token as written in the sample name. */
41
+ text: string
42
+ /** The layer number for a grouped field; the field name or role for any other token. */
43
+ caption: string
44
+ /** `on`: a grouped field. `off`: a field that is switched off. `fixed`: not a grouping field. */
45
+ state: 'on' | 'off' | 'fixed'
46
+ }
47
+
38
48
  export interface SmartGroupFieldRecipeProps {
39
49
  /** Sample records behind the experiment (one row per real sample). */
40
50
  samples?: SmartGroupSampleRecord[]
@@ -75,6 +85,19 @@ export interface SmartGroupFieldRecipeProps {
75
85
  canDownloadTemplate?: boolean
76
86
  /** Show a loading state in the main body (e.g. while experiment data loads). */
77
87
  loading?: boolean
88
+ /** Tokens of one example sample name; shown above the fields when not empty. */
89
+ anatomy?: FieldRecipeAnatomyToken[]
90
+ /**
91
+ * Per-card hint for a field that is on, for example the groups that remain
92
+ * when the user switches it off. `null` shows no hint for that card.
93
+ */
94
+ hints?: (string | null)[]
95
+ /** One chip for each QC class; when set it replaces the single `qcChip`. */
96
+ qcChips?: FieldRecipeQcChip[]
97
+ /** Show the rename control on each field; a new name emits `rename`. */
98
+ renamable?: boolean
99
+ /** Names that a rename must refuse, in addition to the names of the other fields. */
100
+ reservedNames?: string[]
78
101
  }
79
102
 
80
103
  export interface FieldRecipeApplyPayload {
@@ -34,8 +34,11 @@ export const QC_POOL_NAME = 'QC_Pool'
34
34
  /** How QC samples are routed relative to the biological groups. */
35
35
  export type QcRoute = 'Overlay only' | 'Exclude' | 'Mix into groups'
36
36
 
37
- /** Kind of inline summary callout to render. */
38
- export type FieldRecipeSummaryKind = 'mute' | 'warn' | 'info'
37
+ /**
38
+ * Kind of inline summary callout to render. `warn`: a replicate field is on.
39
+ * `singletons`: no replicate field is on, but half or more of the groups have one sample.
40
+ */
41
+ export type FieldRecipeSummaryKind = 'mute' | 'warn' | 'singletons' | 'info'
39
42
 
40
43
  export interface FieldRecipeSummary {
41
44
  kind: FieldRecipeSummaryKind
@@ -99,6 +102,14 @@ export function sampleTotalFor(route: QcRoute, realCount = 9, qcCount = 3): numb
99
102
  return route === 'Exclude' ? realCount : realCount + qcCount
100
103
  }
101
104
 
105
+ /**
106
+ * True when half or more of the groups have one sample: the design has no
107
+ * replicate in the names, or a field that is on is not part of the condition.
108
+ */
109
+ export function mostlySingletons(groups: FieldRecipeGroup[]): boolean {
110
+ return groups.length > 1 && groups.filter(group => group.count === 1).length * 2 >= groups.length
111
+ }
112
+
102
113
  /** Decide which inline summary callout the Field Recipe should show. */
103
114
  export function fieldRecipeSummary(input: {
104
115
  activeFactors: string[]
@@ -108,5 +119,6 @@ export function fieldRecipeSummary(input: {
108
119
  const groupCount = input.baseGroups.length
109
120
  if (input.activeFactors.length === 0) return { kind: 'mute', groupCount }
110
121
  if (input.repOn) return { kind: 'warn', groupCount }
122
+ if (mostlySingletons(input.baseGroups)) return { kind: 'singletons', groupCount }
111
123
  return { kind: 'info', groupCount }
112
124
  }