@morscherlab/mint-sdk 1.1.7 → 1.1.9
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/RetiredStyles.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
- package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +6 -11
- package/dist/components/AutoGroupModal.adapter.d.ts +10 -6
- package/dist/components/PluginIcon.vue.d.ts +2 -2
- package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
- package/dist/components/SampleSelector.groups.d.ts +7 -7
- package/dist/components/SampleSelector.selection.d.ts +6 -12
- package/dist/components/SampleSelector.tree.d.ts +39 -0
- package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
- package/dist/components/SmartGroup.types.d.ts +5 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CDomhGSj.js → components-BbtUpCTv.js} +913 -943
- package/dist/components-BbtUpCTv.js.map +1 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/composables/useAutoGroup.d.ts +3 -0
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/useSampleGroups.d.ts +25 -0
- package/dist/index.js +2 -2
- package/dist/install.js +1 -1
- package/dist/styles.css +130 -557
- package/dist/types/platform.d.ts +1 -0
- package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-BWfNaynA.js} +199 -24
- package/dist/useProtocolTemplates-BWfNaynA.js.map +1 -0
- package/dist/utils/pluginIcon.d.ts +1 -1
- package/dist/utils/sampleGroupPath.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
- package/src/__tests__/components/AppTopBar.test.ts +84 -10
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +16 -6
- package/src/__tests__/components/PluginIcon.test.ts +19 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
- package/src/__tests__/components/RetiredStyles.test.ts +82 -0
- package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
- package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
- package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
- package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +249 -0
- package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
- package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
- package/src/components/AppTopBar.story.vue +9 -7
- package/src/components/AppTopBar.vue +79 -72
- package/src/components/AutoGroupModal.adapter.ts +17 -12
- package/src/components/AutoGroupModal.story.vue +89 -0
- package/src/components/AutoGroupModal.vue +16 -8
- package/src/components/PluginIcon.story.vue +11 -9
- package/src/components/PluginIcon.vue +3 -3
- package/src/components/PluginWorkspaceView.props.ts +5 -7
- package/src/components/PluginWorkspaceView.vue +0 -4
- package/src/components/SampleSelector.drag.ts +5 -5
- package/src/components/SampleSelector.groups.ts +15 -15
- package/src/components/SampleSelector.selection.ts +7 -46
- package/src/components/SampleSelector.story.vue +37 -0
- package/src/components/SampleSelector.tree.ts +48 -0
- package/src/components/SampleSelector.vue +52 -320
- package/src/components/SampleSelectorGroupNode.vue +197 -0
- package/src/components/SmartGroup.types.ts +5 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
- package/src/components/SmartGroupFieldRecipe.vue +76 -4
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
- package/src/composables/autoGroup/compose.ts +31 -10
- package/src/composables/index.ts +1 -0
- package/src/composables/useAutoGroup.ts +108 -5
- package/src/composables/useSampleGroups.ts +118 -33
- package/src/styles/components/app-avatar-menu.css +2 -1
- package/src/styles/components/app-plugin-switcher.css +0 -13
- package/src/styles/components/app-top-bar.css +13 -54
- package/src/styles/components/dose-calculator.css +0 -4
- package/src/styles/components/experiment-popover.css +2 -104
- package/src/styles/components/experiment-selector-modal.css +0 -7
- package/src/styles/components/plugin-icon.css +3 -0
- package/src/styles/components/protocol-step-editor.css +0 -6
- package/src/styles/components/reagent-editor.css +0 -5
- package/src/styles/components/sample-hierarchy-tree.css +0 -4
- package/src/styles/components/sample-selector.css +0 -96
- package/src/styles/components/schedule-calendar.css +0 -6
- package/src/styles/components/smart-group.css +22 -0
- package/src/styles/components/well-plate.css +1 -27
- package/src/types/platform.ts +1 -0
- package/src/utils/pluginIcon.ts +5 -2
- package/src/utils/sampleGroupPath.ts +19 -0
- package/dist/components-CDomhGSj.js.map +0 -1
- package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
|
@@ -52,7 +52,7 @@ interface Props {
|
|
|
52
52
|
/** Active id for the preferred centered pill navigation. */
|
|
53
53
|
currentPillId?: string
|
|
54
54
|
|
|
55
|
-
/** Account dropdown entries.
|
|
55
|
+
/** Account dropdown entries. */
|
|
56
56
|
accountMenu?: AccountMenuItem[]
|
|
57
57
|
/** Show the notifications icon button. */
|
|
58
58
|
showNotifications?: boolean
|
|
@@ -69,17 +69,15 @@ interface Props {
|
|
|
69
69
|
showStandaloneLabel?: boolean
|
|
70
70
|
/** Custom standalone badge label. */
|
|
71
71
|
standaloneLabel?: string
|
|
72
|
-
/** Show the
|
|
72
|
+
/** Show the admin shortcut. */
|
|
73
73
|
showAdmin?: boolean
|
|
74
|
-
/** Route used by the
|
|
74
|
+
/** Route used by the admin shortcut. */
|
|
75
75
|
adminPath?: string
|
|
76
|
-
/**
|
|
77
|
-
showProfile?: boolean
|
|
78
|
-
/** Classic profile display name. */
|
|
76
|
+
/** Account display name. */
|
|
79
77
|
userName?: string
|
|
80
|
-
/** Explicit
|
|
78
|
+
/** Explicit account avatar initial. */
|
|
81
79
|
userInitial?: string
|
|
82
|
-
/**
|
|
80
|
+
/** Account email shown in the avatar menu. */
|
|
83
81
|
userEmail?: string
|
|
84
82
|
}
|
|
85
83
|
|
|
@@ -93,7 +91,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
93
91
|
standaloneLabel: 'Standalone',
|
|
94
92
|
showAdmin: false,
|
|
95
93
|
adminPath: '/admin',
|
|
96
|
-
showProfile: false,
|
|
97
94
|
showNotifications: false,
|
|
98
95
|
hasNotificationDot: false,
|
|
99
96
|
})
|
|
@@ -103,7 +100,6 @@ const ExperimentPopover = defineAsyncComponent(() => import('./ExperimentPopover
|
|
|
103
100
|
const ExperimentSelectorModal = defineAsyncComponent(() => import('./ExperimentSelectorModal.vue'))
|
|
104
101
|
|
|
105
102
|
const emit = defineEmits<{
|
|
106
|
-
'profile-click': []
|
|
107
103
|
'admin-click': []
|
|
108
104
|
'page-selector-select': [page: PageSelectorItem]
|
|
109
105
|
'pill-select': [item: PillNavItem]
|
|
@@ -120,6 +116,7 @@ const settingsOpen = ref(false)
|
|
|
120
116
|
const { isIntegrated, plugin } = usePlatformContext()
|
|
121
117
|
const isStandalone = computed(() => !isIntegrated.value)
|
|
122
118
|
const appExperiment = inject(APP_EXPERIMENT_KEY, null)
|
|
119
|
+
const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
|
|
123
120
|
|
|
124
121
|
const pluginIcon = computed(() => {
|
|
125
122
|
const currentPlugin = plugin.value
|
|
@@ -128,12 +125,6 @@ const pluginIcon = computed(() => {
|
|
|
128
125
|
: null
|
|
129
126
|
})
|
|
130
127
|
|
|
131
|
-
const profileInitial = computed(() => {
|
|
132
|
-
if (props.userInitial) return props.userInitial
|
|
133
|
-
if (props.userName) return props.userName.charAt(0).toUpperCase()
|
|
134
|
-
return 'U'
|
|
135
|
-
})
|
|
136
|
-
|
|
137
128
|
const hasPlatformPageSelector = computed(() =>
|
|
138
129
|
props.pageSelector === undefined && (plugin.value?.nav_items?.length ?? 0) > 1,
|
|
139
130
|
)
|
|
@@ -201,69 +192,99 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
201
192
|
:href="homePath"
|
|
202
193
|
class="mint-topbar-home-link"
|
|
203
194
|
>
|
|
204
|
-
<
|
|
195
|
+
<span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
|
|
205
196
|
<PluginIcon
|
|
206
|
-
v-if="
|
|
197
|
+
v-if="isIntegrated"
|
|
207
198
|
class="mint-topbar__plugin-icon"
|
|
208
|
-
:icon="
|
|
209
|
-
:tone="
|
|
210
|
-
size="
|
|
199
|
+
:icon="plugin?.icon"
|
|
200
|
+
:tone="plugin?.color"
|
|
201
|
+
size="topbar"
|
|
211
202
|
variant="solid"
|
|
212
203
|
/>
|
|
213
|
-
<slot v-else name="
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
204
|
+
<slot v-else name="icon">
|
|
205
|
+
<PluginIcon
|
|
206
|
+
v-if="pluginIcon"
|
|
207
|
+
class="mint-topbar__plugin-icon"
|
|
208
|
+
:icon="pluginIcon.icon"
|
|
209
|
+
:tone="pluginIcon.color"
|
|
210
|
+
size="topbar"
|
|
211
|
+
variant="solid"
|
|
212
|
+
/>
|
|
213
|
+
<slot v-else name="logo">
|
|
214
|
+
<div v-if="showLogo" class="mint-topbar__logo">
|
|
215
|
+
<div class="mint-topbar__logo-icon">
|
|
216
|
+
<span class="mint-topbar__logo-text">M</span>
|
|
217
|
+
</div>
|
|
217
218
|
</div>
|
|
218
|
-
</
|
|
219
|
+
</slot>
|
|
219
220
|
</slot>
|
|
220
|
-
</
|
|
221
|
+
</span>
|
|
221
222
|
</a>
|
|
222
223
|
<router-link
|
|
223
224
|
v-else-if="homePath"
|
|
224
225
|
:to="homePath"
|
|
225
226
|
class="mint-topbar-home-link"
|
|
226
227
|
>
|
|
227
|
-
<
|
|
228
|
+
<span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
|
|
228
229
|
<PluginIcon
|
|
229
|
-
v-if="
|
|
230
|
+
v-if="isIntegrated"
|
|
230
231
|
class="mint-topbar__plugin-icon"
|
|
231
|
-
:icon="
|
|
232
|
-
:tone="
|
|
233
|
-
size="
|
|
232
|
+
:icon="plugin?.icon"
|
|
233
|
+
:tone="plugin?.color"
|
|
234
|
+
size="topbar"
|
|
234
235
|
variant="solid"
|
|
235
236
|
/>
|
|
236
|
-
<slot v-else name="
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
<slot v-else name="icon">
|
|
238
|
+
<PluginIcon
|
|
239
|
+
v-if="pluginIcon"
|
|
240
|
+
class="mint-topbar__plugin-icon"
|
|
241
|
+
:icon="pluginIcon.icon"
|
|
242
|
+
:tone="pluginIcon.color"
|
|
243
|
+
size="topbar"
|
|
244
|
+
variant="solid"
|
|
245
|
+
/>
|
|
246
|
+
<slot v-else name="logo">
|
|
247
|
+
<div v-if="showLogo" class="mint-topbar__logo">
|
|
248
|
+
<div class="mint-topbar__logo-icon">
|
|
249
|
+
<span class="mint-topbar__logo-text">M</span>
|
|
250
|
+
</div>
|
|
240
251
|
</div>
|
|
241
|
-
</
|
|
252
|
+
</slot>
|
|
242
253
|
</slot>
|
|
243
|
-
</
|
|
254
|
+
</span>
|
|
244
255
|
</router-link>
|
|
245
256
|
<template v-else>
|
|
246
|
-
<
|
|
257
|
+
<span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
|
|
247
258
|
<PluginIcon
|
|
248
|
-
v-if="
|
|
259
|
+
v-if="isIntegrated"
|
|
249
260
|
class="mint-topbar__plugin-icon"
|
|
250
|
-
:icon="
|
|
251
|
-
:tone="
|
|
252
|
-
size="
|
|
261
|
+
:icon="plugin?.icon"
|
|
262
|
+
:tone="plugin?.color"
|
|
263
|
+
size="topbar"
|
|
253
264
|
variant="solid"
|
|
254
265
|
/>
|
|
255
|
-
<slot v-else name="
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
<slot v-else name="icon">
|
|
267
|
+
<PluginIcon
|
|
268
|
+
v-if="pluginIcon"
|
|
269
|
+
class="mint-topbar__plugin-icon"
|
|
270
|
+
:icon="pluginIcon.icon"
|
|
271
|
+
:tone="pluginIcon.color"
|
|
272
|
+
size="topbar"
|
|
273
|
+
variant="solid"
|
|
274
|
+
/>
|
|
275
|
+
<slot v-else name="logo">
|
|
276
|
+
<div v-if="showLogo" class="mint-topbar__logo">
|
|
277
|
+
<div class="mint-topbar__logo-icon">
|
|
278
|
+
<span class="mint-topbar__logo-text">M</span>
|
|
279
|
+
</div>
|
|
259
280
|
</div>
|
|
260
|
-
</
|
|
281
|
+
</slot>
|
|
261
282
|
</slot>
|
|
262
|
-
</
|
|
283
|
+
</span>
|
|
263
284
|
</template>
|
|
264
285
|
</div>
|
|
265
286
|
|
|
266
|
-
<!-- Left: page selector or plugin switcher
|
|
287
|
+
<!-- Left: page selector or plugin switcher -->
|
|
267
288
|
<AppPluginSwitcher
|
|
268
289
|
v-if="hasPluginSwitcher && pluginSwitcher"
|
|
269
290
|
:current="pluginSwitcher.current"
|
|
@@ -298,7 +319,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
298
319
|
<!-- Nav slot (inline, after brand/selector) -->
|
|
299
320
|
<slot name="nav" />
|
|
300
321
|
|
|
301
|
-
<!-- Center: pill nav
|
|
322
|
+
<!-- Center: pill nav -->
|
|
302
323
|
<div v-if="hasPillNav || $slots.center" class="mint-topbar__center">
|
|
303
324
|
<slot name="center">
|
|
304
325
|
<AppTopBarPillNavInternal
|
|
@@ -318,7 +339,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
318
339
|
</span>
|
|
319
340
|
|
|
320
341
|
<ExperimentPopover
|
|
321
|
-
v-if="appExperiment && !isStandalone"
|
|
342
|
+
v-if="appExperiment && !isStandalone && canBindExperiment"
|
|
322
343
|
v-bind="appExperiment.popover.value"
|
|
323
344
|
@select="appExperiment.openModal()"
|
|
324
345
|
@save="appExperiment.handleSave()"
|
|
@@ -331,7 +352,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
331
352
|
|
|
332
353
|
<slot name="actions" />
|
|
333
354
|
|
|
334
|
-
<!-- Notifications
|
|
355
|
+
<!-- Notifications -->
|
|
335
356
|
<button
|
|
336
357
|
v-if="showNotifications"
|
|
337
358
|
type="button"
|
|
@@ -346,10 +367,10 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
346
367
|
<span v-if="hasNotificationDot" class="mint-topbar__icon-btn-dot" aria-hidden="true" />
|
|
347
368
|
</button>
|
|
348
369
|
|
|
349
|
-
<!--
|
|
370
|
+
<!-- Theme toggle -->
|
|
350
371
|
<ThemeToggle v-if="showThemeToggle" size="sm" />
|
|
351
372
|
|
|
352
|
-
<!--
|
|
373
|
+
<!-- Settings -->
|
|
353
374
|
<button
|
|
354
375
|
v-if="showSettings"
|
|
355
376
|
type="button"
|
|
@@ -362,7 +383,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
362
383
|
</svg>
|
|
363
384
|
</button>
|
|
364
385
|
|
|
365
|
-
<!--
|
|
386
|
+
<!-- Admin link -->
|
|
366
387
|
<router-link
|
|
367
388
|
v-if="showAdmin"
|
|
368
389
|
:to="adminPath"
|
|
@@ -375,7 +396,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
375
396
|
</svg>
|
|
376
397
|
</router-link>
|
|
377
398
|
|
|
378
|
-
<!--
|
|
399
|
+
<!-- Account menu -->
|
|
379
400
|
<AppAvatarMenu
|
|
380
401
|
v-if="hasAccountMenu || $slots['account-menu-items']"
|
|
381
402
|
:user-name="userName"
|
|
@@ -392,20 +413,6 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
392
413
|
<slot name="account-menu-item-icon" v-bind="slotProps" />
|
|
393
414
|
</template>
|
|
394
415
|
</AppAvatarMenu>
|
|
395
|
-
|
|
396
|
-
<!-- Classic: profile button (only when avatar menu not provided) -->
|
|
397
|
-
<button
|
|
398
|
-
v-else-if="showProfile"
|
|
399
|
-
type="button"
|
|
400
|
-
class="mint-topbar__profile-btn"
|
|
401
|
-
aria-label="Edit profile"
|
|
402
|
-
@click="emit('profile-click')"
|
|
403
|
-
>
|
|
404
|
-
<div class="mint-topbar__profile-avatar">
|
|
405
|
-
{{ profileInitial }}
|
|
406
|
-
</div>
|
|
407
|
-
<span v-if="userName" class="mint-topbar__profile-name">{{ userName }}</span>
|
|
408
|
-
</button>
|
|
409
416
|
</div>
|
|
410
417
|
</div>
|
|
411
418
|
</header>
|
|
@@ -436,7 +443,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
436
443
|
</SettingsModal>
|
|
437
444
|
|
|
438
445
|
<ExperimentSelectorModal
|
|
439
|
-
v-if="appExperiment && !isStandalone && appExperiment.selectorModal.value.modelValue"
|
|
446
|
+
v-if="appExperiment && !isStandalone && canBindExperiment && appExperiment.selectorModal.value.modelValue"
|
|
440
447
|
v-bind="appExperiment.selectorModal.value"
|
|
441
448
|
@update:model-value="$event ? appExperiment.openModal() : appExperiment.closeModal()"
|
|
442
449
|
/>
|
|
@@ -28,23 +28,24 @@ export function roleLabel(role?: ColumnRole): string {
|
|
|
28
28
|
/**
|
|
29
29
|
* Columns worth offering as grouping toggles: factors and replicates, plus any
|
|
30
30
|
* column already enabled in `groupBy` (so a manual group-by choice still shows).
|
|
31
|
-
* Preserves the engine's column order.
|
|
31
|
+
* Preserves the engine's column order. `groupBy` is the schema's ORDERED layer
|
|
32
|
+
* list — entry N is hierarchy layer N of the composed group names.
|
|
32
33
|
*/
|
|
33
|
-
export function candidateColumns(columns: ColumnInfo[],
|
|
34
|
+
export function candidateColumns(columns: ColumnInfo[], groupBy: readonly number[]): ColumnInfo[] {
|
|
35
|
+
const enabled = new Set(groupBy)
|
|
34
36
|
return columns.filter(c => c.role === 'factor' || c.role === 'replicate' || enabled.has(c.index))
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** Map engine columns to Field Recipe cards + their on/off state (same order). */
|
|
39
|
+
/**
|
|
40
|
+
* Map engine columns to Field Recipe cards + their on/off state (same order).
|
|
41
|
+
* `layers` aligns with the cards: the column's 1-based position in the
|
|
42
|
+
* `groupBy` layer order, or `null` when the column is not grouped by.
|
|
43
|
+
*/
|
|
43
44
|
export function engineFieldsToCards(
|
|
44
45
|
columns: ColumnInfo[],
|
|
45
|
-
|
|
46
|
-
): { fields: SmartGroupField[]; enabled: boolean[] } {
|
|
47
|
-
const candidates = candidateColumns(columns,
|
|
46
|
+
groupBy: readonly number[],
|
|
47
|
+
): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } {
|
|
48
|
+
const candidates = candidateColumns(columns, groupBy)
|
|
48
49
|
return {
|
|
49
50
|
fields: candidates.map(c => ({
|
|
50
51
|
key: c.displayName ?? c.name,
|
|
@@ -52,7 +53,11 @@ export function engineFieldsToCards(
|
|
|
52
53
|
vals: c.uniqueValues.join(', '),
|
|
53
54
|
rep: c.role === 'replicate',
|
|
54
55
|
})),
|
|
55
|
-
enabled: candidates.map(c =>
|
|
56
|
+
enabled: candidates.map(c => groupBy.includes(c.index)),
|
|
57
|
+
layers: candidates.map(c => {
|
|
58
|
+
const pos = groupBy.indexOf(c.index)
|
|
59
|
+
return pos === -1 ? null : pos + 1
|
|
60
|
+
}),
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -85,6 +85,70 @@ const mockDesignData = {
|
|
|
85
85
|
extraction_results: [],
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function wellId(index: number): string {
|
|
89
|
+
const row = String.fromCharCode(65 + Math.floor(index / 12))
|
|
90
|
+
return `${row}${(index % 12) + 1}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const complexBiologicalConditions = ['Liver', 'Kidney'].flatMap(tissue =>
|
|
94
|
+
['WT', 'KO'].flatMap(genotype =>
|
|
95
|
+
[
|
|
96
|
+
{ treatment: 'Vehicle', dose: '0 uM' },
|
|
97
|
+
{ treatment: 'Drug A', dose: '10 uM' },
|
|
98
|
+
{ treatment: 'Drug A', dose: '50 uM' },
|
|
99
|
+
].flatMap(arm =>
|
|
100
|
+
['6 h', '24 h'].flatMap(timepoint =>
|
|
101
|
+
[1, 2].map(replicate => ({ tissue, genotype, ...arm, timepoint, replicate })),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const complexBiologicalSamples = complexBiologicalConditions.map((condition, index) => {
|
|
108
|
+
const subject = `M${String(index + 1).padStart(3, '0')}`
|
|
109
|
+
const conditionIndex = Math.floor(index / 2)
|
|
110
|
+
const batch = `Batch ${String((conditionIndex % 3) + 1).padStart(2, '0')}`
|
|
111
|
+
const polarity = conditionIndex % 2 === 0 ? 'POS' : 'NEG'
|
|
112
|
+
const sampleName = [
|
|
113
|
+
condition.tissue,
|
|
114
|
+
condition.genotype,
|
|
115
|
+
condition.treatment.replaceAll(' ', ''),
|
|
116
|
+
condition.dose.replaceAll(' ', ''),
|
|
117
|
+
condition.timepoint.replaceAll(' ', ''),
|
|
118
|
+
subject,
|
|
119
|
+
`R${condition.replicate}`,
|
|
120
|
+
polarity,
|
|
121
|
+
].join('_')
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
plate_id: 'Plate 1',
|
|
125
|
+
well_id: wellId(index),
|
|
126
|
+
sample_name: sampleName,
|
|
127
|
+
sample_type: 'sample',
|
|
128
|
+
conditions: {
|
|
129
|
+
Tissue: condition.tissue,
|
|
130
|
+
Genotype: condition.genotype,
|
|
131
|
+
Treatment: condition.treatment,
|
|
132
|
+
Dose: condition.dose,
|
|
133
|
+
Timepoint: condition.timepoint,
|
|
134
|
+
Sex: conditionIndex % 2 === 0 ? 'Female' : 'Male',
|
|
135
|
+
Cohort: index < 24 ? 'Discovery' : 'Validation',
|
|
136
|
+
Batch: batch,
|
|
137
|
+
Polarity: polarity,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const complexDesignData = {
|
|
143
|
+
schema_version: '2.0',
|
|
144
|
+
study_name: 'Drug A multi-factor tissue response',
|
|
145
|
+
organism: 'Mus musculus',
|
|
146
|
+
platform: 'LC-MS',
|
|
147
|
+
samples: complexBiologicalSamples,
|
|
148
|
+
plates: [{ name: 'Plate 1', layout_type: '96-well' }],
|
|
149
|
+
extraction_results: [],
|
|
150
|
+
}
|
|
151
|
+
|
|
88
152
|
// Hyphen-delimited samples
|
|
89
153
|
const hyphenSamples = [
|
|
90
154
|
'WT-Vehicle-1', 'WT-Vehicle-2', 'WT-Vehicle-3',
|
|
@@ -310,6 +374,31 @@ function initState(samples: string[] = proteomicsSamples) {
|
|
|
310
374
|
</template>
|
|
311
375
|
</Variant>
|
|
312
376
|
|
|
377
|
+
<Variant title="Complex Experiment Metadata" :init-state="() => ({ isOpen: true, lastResult: null as AutoGroupResult | null })">
|
|
378
|
+
<template #default="{ state }">
|
|
379
|
+
<div style="padding: 2rem;">
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
style="padding: 0.5rem 1rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e5e7eb); border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;"
|
|
383
|
+
@click="state.isOpen = true"
|
|
384
|
+
>
|
|
385
|
+
Re-open Modal
|
|
386
|
+
</button>
|
|
387
|
+
<div v-if="state.lastResult" style="margin-top: 1rem; font-size: 0.8125rem; color: var(--text-secondary, #64748b);">
|
|
388
|
+
<strong>Last result:</strong>
|
|
389
|
+
{{ state.lastResult.groups.length }} groups,
|
|
390
|
+
{{ state.lastResult.metadata.length }} samples,
|
|
391
|
+
{{ state.lastResult.excludedSamples.length }} excluded
|
|
392
|
+
</div>
|
|
393
|
+
<AutoGroupModal
|
|
394
|
+
v-model="state.isOpen"
|
|
395
|
+
:design-data="complexDesignData"
|
|
396
|
+
@apply="(r: AutoGroupResult) => { state.lastResult = r }"
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
</template>
|
|
400
|
+
</Variant>
|
|
401
|
+
|
|
313
402
|
<Variant title="Empty (Manual Paste)">
|
|
314
403
|
<div style="padding: 2rem;">
|
|
315
404
|
<button
|
|
@@ -17,7 +17,6 @@ import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
|
|
|
17
17
|
import { useApi } from '../composables/useApi'
|
|
18
18
|
import { readFileAsText } from '../composables/useFileImport'
|
|
19
19
|
import {
|
|
20
|
-
candidateColumnIndices,
|
|
21
20
|
engineFieldsToCards,
|
|
22
21
|
engineGroupsToRail,
|
|
23
22
|
engineToQcRoute,
|
|
@@ -83,10 +82,9 @@ function setQcRouting(value: QcRouting) {
|
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
// ── Auto mode (Field Recipe) ─────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
)
|
|
85
|
+
// The schema's ordered groupBy IS the hierarchy layer order (layer 1 first).
|
|
86
|
+
const groupByOrder = computed(() => autoGroup.activeSchema.value?.groupBy ?? [])
|
|
87
|
+
const fieldCards = computed(() => engineFieldsToCards(autoGroup.fields.value, groupByOrder.value))
|
|
90
88
|
const railGroups = computed(() =>
|
|
91
89
|
engineGroupsToRail(autoGroup.result.value.experimentalGroups ?? autoGroup.groups.value),
|
|
92
90
|
)
|
|
@@ -103,10 +101,18 @@ const qcChip = computed(() =>
|
|
|
103
101
|
const fieldRecipeRoute = computed<QcRoute>(() => engineToQcRoute(qcRouting.value))
|
|
104
102
|
const headerCount = computed(() => autoGroup.samples.value.length)
|
|
105
103
|
|
|
104
|
+
// The cards describe the batch, not one class, so edits fan out to every class
|
|
105
|
+
// exposing the field — otherwise a multi-class batch (e.g. a Liver class and a
|
|
106
|
+
// Kidney class) ends up with only half its groups reordered.
|
|
106
107
|
function onToggleField(cardIndex: number) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
autoGroup.
|
|
108
|
+
const field = fieldCards.value.fields[cardIndex]
|
|
109
|
+
if (!field) return
|
|
110
|
+
autoGroup.toggleGroupByField(field.key)
|
|
111
|
+
}
|
|
112
|
+
function onMoveField(cardIndex: number, direction: 'up' | 'down') {
|
|
113
|
+
const field = fieldCards.value.fields[cardIndex]
|
|
114
|
+
if (!field) return
|
|
115
|
+
autoGroup.moveGroupByField(field.key, direction)
|
|
110
116
|
}
|
|
111
117
|
function onRouteChange(route: QcRoute) {
|
|
112
118
|
setQcRouting(qcRouteToEngine(route))
|
|
@@ -247,6 +253,7 @@ watch(
|
|
|
247
253
|
v-if="activeWorkflow === 'auto'"
|
|
248
254
|
:fields="fieldCards.fields"
|
|
249
255
|
:enabled="fieldCards.enabled"
|
|
256
|
+
:layers="fieldCards.layers"
|
|
250
257
|
:groups="railGroups"
|
|
251
258
|
:qc-chip="qcChip"
|
|
252
259
|
:route="fieldRecipeRoute"
|
|
@@ -256,6 +263,7 @@ watch(
|
|
|
256
263
|
:loading="experimentLoading"
|
|
257
264
|
experiment-label=""
|
|
258
265
|
@toggle="onToggleField"
|
|
266
|
+
@move="onMoveField"
|
|
259
267
|
@update:route="onRouteChange"
|
|
260
268
|
@csv-file="onCsvFile"
|
|
261
269
|
@download-template="onDownloadTemplate"
|
|
@@ -26,12 +26,12 @@ const SAMPLE_STRUCTURED = JSON.stringify({
|
|
|
26
26
|
})
|
|
27
27
|
// Tiny 1x1 transparent PNG.
|
|
28
28
|
const SAMPLE_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='
|
|
29
|
-
const
|
|
29
|
+
const SAMPLE_HTTPS_SVG = 'https://vite.dev/logo.svg'
|
|
30
30
|
|
|
31
31
|
function initState() {
|
|
32
32
|
return reactive({
|
|
33
33
|
icon: SAMPLE_PATH,
|
|
34
|
-
size: 'md' as 'sm' | 'md' | 'lg',
|
|
34
|
+
size: 'md' as 'sm' | 'md' | 'topbar' | 'lg',
|
|
35
35
|
variant: 'solid' as 'solid' | 'tinted',
|
|
36
36
|
tone: '',
|
|
37
37
|
})
|
|
@@ -48,7 +48,7 @@ function initState() {
|
|
|
48
48
|
</template>
|
|
49
49
|
<template #controls="{ state }">
|
|
50
50
|
<HstText v-model="state.icon" title="icon" />
|
|
51
|
-
<HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'lg']" />
|
|
51
|
+
<HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'topbar', 'lg']" />
|
|
52
52
|
<HstSelect v-model="state.variant" title="variant" :options="['solid', 'tinted']" />
|
|
53
53
|
<HstText v-model="state.tone" title="tone (hex)" />
|
|
54
54
|
</template>
|
|
@@ -58,6 +58,7 @@ function initState() {
|
|
|
58
58
|
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
59
59
|
<PluginIcon :icon="SAMPLE_PATH" size="sm" />
|
|
60
60
|
<PluginIcon :icon="SAMPLE_PATH" size="md" />
|
|
61
|
+
<PluginIcon :icon="SAMPLE_PATH" size="topbar" />
|
|
61
62
|
<PluginIcon :icon="SAMPLE_PATH" size="lg" />
|
|
62
63
|
</div>
|
|
63
64
|
</Variant>
|
|
@@ -71,14 +72,14 @@ function initState() {
|
|
|
71
72
|
|
|
72
73
|
<Variant title="Format showcase">
|
|
73
74
|
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
74
|
-
<PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="
|
|
75
|
-
<PluginIcon :icon="SAMPLE_PATH" size="lg" variant="
|
|
76
|
-
<PluginIcon :icon="SAMPLE_PNG" size="lg" variant="
|
|
77
|
-
<PluginIcon :icon="
|
|
78
|
-
<PluginIcon icon="" size="lg" variant="
|
|
75
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="solid" />
|
|
76
|
+
<PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" />
|
|
77
|
+
<PluginIcon :icon="SAMPLE_PNG" size="lg" variant="solid" />
|
|
78
|
+
<PluginIcon :icon="SAMPLE_HTTPS_SVG" size="lg" variant="solid" />
|
|
79
|
+
<PluginIcon icon="" size="lg" variant="solid" />
|
|
79
80
|
</div>
|
|
80
81
|
<p style="padding: 0 2rem; color: var(--text-muted); font-size: 0.875rem;">
|
|
81
|
-
Structured
|
|
82
|
+
Structured gradient · SVG path · data URL (PNG) · HTTPS SVG · empty (fallback)
|
|
82
83
|
</p>
|
|
83
84
|
</Variant>
|
|
84
85
|
|
|
@@ -86,6 +87,7 @@ function initState() {
|
|
|
86
87
|
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
87
88
|
<PluginIcon :icon="SAMPLE_STRUCTURED" size="sm" />
|
|
88
89
|
<PluginIcon :icon="SAMPLE_STRUCTURED" size="md" />
|
|
90
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="topbar" />
|
|
89
91
|
<PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" />
|
|
90
92
|
</div>
|
|
91
93
|
</Variant>
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* - Structured MINT icon JSON ({"type":"mint-icon",...}) → controlled multi-color SVG
|
|
4
4
|
* - SVG path data (e.g. "M13 10V3...") → inline <svg><path>
|
|
5
5
|
* - Raster data URL (data:image/png|jpeg|jpg|gif|webp;...) → <img>
|
|
6
|
-
* - https:// URL → <img> with no-referrer + lazy loading
|
|
6
|
+
* - https:// URL or root-relative static image/SVG asset → <img> with no-referrer + lazy loading
|
|
7
7
|
* - Anything else → MINT canonical plugin placeholder path
|
|
8
8
|
*
|
|
9
|
-
* http
|
|
9
|
+
* http://, protocol-relative, and data:image/svg+xml URLs are deliberately rejected
|
|
10
10
|
* (mixed-content + XSS, see spec 2026-05-04-plugin-icon-component-design.md). */
|
|
11
11
|
import { computed } from 'vue'
|
|
12
12
|
import { detectPluginIcon, type DetectedPluginIcon, type StructuredPluginIcon } from '../utils/pluginIcon'
|
|
@@ -15,7 +15,7 @@ defineOptions({ name: 'PluginIcon' })
|
|
|
15
15
|
|
|
16
16
|
interface Props {
|
|
17
17
|
icon?: string
|
|
18
|
-
size?: 'sm' | 'md' | 'lg'
|
|
18
|
+
size?: 'sm' | 'md' | 'topbar' | 'lg'
|
|
19
19
|
variant?: 'solid' | 'tinted'
|
|
20
20
|
tone?: string
|
|
21
21
|
}
|
|
@@ -59,17 +59,15 @@ export interface PluginWorkspaceViewProps {
|
|
|
59
59
|
showNotifications?: boolean
|
|
60
60
|
/** Draw a notification dot on the notifications icon. */
|
|
61
61
|
hasNotificationDot?: boolean
|
|
62
|
-
/** Show the
|
|
62
|
+
/** Show the admin shortcut. */
|
|
63
63
|
showAdmin?: boolean
|
|
64
|
-
/** Route used by the
|
|
64
|
+
/** Route used by the admin shortcut. */
|
|
65
65
|
adminPath?: string
|
|
66
|
-
/**
|
|
67
|
-
showProfile?: boolean
|
|
68
|
-
/** Classic profile display name. */
|
|
66
|
+
/** Account display name. */
|
|
69
67
|
userName?: string
|
|
70
|
-
/** Explicit
|
|
68
|
+
/** Explicit account avatar initial. */
|
|
71
69
|
userInitial?: string
|
|
72
|
-
/**
|
|
70
|
+
/** Account email shown in the avatar menu. */
|
|
73
71
|
userEmail?: string
|
|
74
72
|
/** Enable the built-in AppTopBar experiment popover and selector flow. The current experiment comes from the shared experiment store, not from a prop. Keep this comment on one line — the docs extractor drops multi-line JSDoc. */
|
|
75
73
|
experimentShell?: boolean
|
|
@@ -56,7 +56,6 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
|
|
|
56
56
|
hasNotificationDot: false,
|
|
57
57
|
showAdmin: false,
|
|
58
58
|
adminPath: '/admin',
|
|
59
|
-
showProfile: false,
|
|
60
59
|
userName: undefined,
|
|
61
60
|
userInitial: undefined,
|
|
62
61
|
userEmail: undefined,
|
|
@@ -115,7 +114,6 @@ const emit = defineEmits<{
|
|
|
115
114
|
'sign-out': []
|
|
116
115
|
'notifications-click': []
|
|
117
116
|
'settings-values-change': [data: Record<string, unknown>]
|
|
118
|
-
'profile-click': []
|
|
119
117
|
'admin-click': []
|
|
120
118
|
'experiment-select': [experiment: ExperimentSummary]
|
|
121
119
|
'experiment-detach': []
|
|
@@ -292,7 +290,6 @@ function handleFormCancel(sectionId: string) {
|
|
|
292
290
|
:has-notification-dot="hasNotificationDot"
|
|
293
291
|
:show-admin="showAdmin"
|
|
294
292
|
:admin-path="adminPath"
|
|
295
|
-
:show-profile="showProfile"
|
|
296
293
|
:user-name="userName"
|
|
297
294
|
:user-initial="userInitial"
|
|
298
295
|
:user-email="userEmail"
|
|
@@ -305,7 +302,6 @@ function handleFormCancel(sectionId: string) {
|
|
|
305
302
|
@sign-out="emit('sign-out')"
|
|
306
303
|
@notifications-click="emit('notifications-click')"
|
|
307
304
|
@settings-values-change="emit('settings-values-change', $event)"
|
|
308
|
-
@profile-click="emit('profile-click')"
|
|
309
305
|
@admin-click="emit('admin-click')"
|
|
310
306
|
>
|
|
311
307
|
<template
|