@morscherlab/mint-sdk 1.0.39 → 1.0.42
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/{ExperimentPopover-DEzCbTqo.js → ExperimentPopover-8A4Rhffp.js} +1 -1
- package/dist/{ExperimentPopover-mzmSfAUp.js → ExperimentPopover-BbPkIFsI.js} +8 -2
- package/dist/ExperimentPopover-BbPkIFsI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-Bn0Hmg07.js → ExperimentSelectorModal-B2qek_YG.js} +91 -46
- package/dist/ExperimentSelectorModal-B2qek_YG.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BAIlIybO.js → ExperimentSelectorModal-BwPbQN1g.js} +1 -1
- package/dist/__tests__/components/AutoGroupModal.preview.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/classKey.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupTree.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/tokenLength.test.d.ts +1 -0
- package/dist/components/AppTopBar.navigation.d.ts +0 -1
- package/dist/components/index.js +3 -3
- package/dist/{components-Cyi0IfRl.js → components-BGVwavdd.js} +5632 -5629
- package/dist/components-BGVwavdd.js.map +1 -0
- package/dist/composables/autoGroup/classKey.d.ts +1 -0
- package/dist/composables/autoGroup/index.d.ts +2 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
- package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +3 -3
- package/dist/composables/useAutoGroup.d.ts +2 -0
- package/dist/composables/usePluginClient.d.ts +82 -5
- package/dist/{composables-CFSn4NN3.js → composables-C_hPF0Gn.js} +256 -9
- package/dist/{composables-CFSn4NN3.js.map → composables-C_hPF0Gn.js.map} +1 -1
- package/dist/index.js +6 -6
- package/dist/install.js +3 -3
- package/dist/styles.css +602 -555
- package/dist/types/auto-group.d.ts +19 -0
- package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.navigation.test.ts +3 -5
- package/src/__tests__/components/AppTopBar.test.ts +2 -5
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +22 -0
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +18 -0
- package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
- package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
- package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
- package/src/__tests__/composables/usePluginClient.test.ts +129 -3
- package/src/components/AppTopBar.navigation.ts +0 -2
- package/src/components/AppTopBar.story.vue +5 -5
- package/src/components/AppTopBar.vue +0 -1
- package/src/components/AutoGroupModal.vue +23 -19
- package/src/components/BaseModal.story.vue +7 -15
- package/src/components/ExperimentDataViewer.vue +1 -0
- package/src/components/ExperimentPopover.vue +6 -4
- package/src/components/ExperimentSelectorModal.vue +30 -3
- package/src/components/IconButton.story.vue +5 -0
- package/src/components/PluginWorkspaceView.vue +5 -1
- package/src/components/SampleSelector.vue +3 -2
- package/src/components/SampleSelectorSampleRow.vue +4 -2
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +0 -1
- package/src/composables/autoGroup/classKey.ts +5 -2
- package/src/composables/autoGroup/columns.ts +2 -2
- package/src/composables/autoGroup/compose.ts +56 -0
- package/src/composables/autoGroup/fingerprint.ts +15 -1
- package/src/composables/autoGroup/index.ts +2 -0
- package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
- package/src/composables/autoGroup/template.ts +2 -2
- package/src/composables/autoGroup/tokenLength.ts +53 -0
- package/src/composables/autoGroup/vocab.json +1 -2
- package/src/composables/index.ts +6 -0
- package/src/composables/useAutoGroup.ts +34 -13
- package/src/composables/usePluginClient.ts +453 -8
- package/src/styles/components/app-page-selector.css +3 -5
- package/src/styles/components/auto-group-modal.css +7 -11
- package/src/styles/components/button.css +14 -4
- package/src/styles/components/modal.css +3 -0
- package/src/styles/components/sample-selector.css +17 -0
- package/src/styles/variables.css +8 -0
- package/src/types/auto-group.ts +19 -0
- package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
- package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
- package/dist/components-Cyi0IfRl.js.map +0 -1
- package/dist/useProtocolTemplates-CXP2ZosM.js.map +0 -1
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
resolvePluginBaseUrl,
|
|
13
13
|
uploadPluginEndpoint,
|
|
14
14
|
useCurrentExperiment,
|
|
15
|
+
usePluginEventStream,
|
|
15
16
|
usePluginClient,
|
|
17
|
+
usePluginSettings,
|
|
16
18
|
type PluginContract,
|
|
17
19
|
} from '../../composables/usePluginClient'
|
|
20
|
+
import { useAuthStore } from '../../stores/auth'
|
|
18
21
|
|
|
19
22
|
const contract: PluginContract = {
|
|
20
23
|
schemaVersion: 1,
|
|
@@ -32,6 +35,8 @@ describe('usePluginClient', () => {
|
|
|
32
35
|
let requestBodies: unknown[] = []
|
|
33
36
|
let nextGetError: Error | null = null
|
|
34
37
|
let nextGetData: unknown
|
|
38
|
+
let nextPatchData: unknown
|
|
39
|
+
let nextPutData: unknown
|
|
35
40
|
|
|
36
41
|
beforeEach(() => {
|
|
37
42
|
setActivePinia(createPinia())
|
|
@@ -39,6 +44,8 @@ describe('usePluginClient', () => {
|
|
|
39
44
|
requestBodies = []
|
|
40
45
|
nextGetError = null
|
|
41
46
|
nextGetData = undefined
|
|
47
|
+
nextPatchData = undefined
|
|
48
|
+
nextPutData = undefined
|
|
42
49
|
vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
|
|
43
50
|
this: unknown,
|
|
44
51
|
url: string,
|
|
@@ -58,6 +65,26 @@ describe('usePluginClient', () => {
|
|
|
58
65
|
requestBodies.push(data)
|
|
59
66
|
return { data: { called: url } }
|
|
60
67
|
})
|
|
68
|
+
vi.spyOn(axios.Axios.prototype, 'patch').mockImplementation(async function (
|
|
69
|
+
this: unknown,
|
|
70
|
+
url: string,
|
|
71
|
+
data?: unknown,
|
|
72
|
+
config?: AxiosRequestConfig,
|
|
73
|
+
) {
|
|
74
|
+
requestConfigs.push({ url, ...config })
|
|
75
|
+
requestBodies.push(data)
|
|
76
|
+
return { data: nextPatchData ?? { called: url } }
|
|
77
|
+
})
|
|
78
|
+
vi.spyOn(axios.Axios.prototype, 'put').mockImplementation(async function (
|
|
79
|
+
this: unknown,
|
|
80
|
+
url: string,
|
|
81
|
+
data?: unknown,
|
|
82
|
+
config?: AxiosRequestConfig,
|
|
83
|
+
) {
|
|
84
|
+
requestConfigs.push({ url, ...config })
|
|
85
|
+
requestBodies.push(data)
|
|
86
|
+
return { data: nextPutData ?? { called: url } }
|
|
87
|
+
})
|
|
61
88
|
vi.spyOn(axios.Axios.prototype, 'delete').mockImplementation(async function (
|
|
62
89
|
this: unknown,
|
|
63
90
|
url: string,
|
|
@@ -353,21 +380,18 @@ describe('usePluginClient', () => {
|
|
|
353
380
|
label: 'Dashboard',
|
|
354
381
|
to: '/',
|
|
355
382
|
icon: 'DASHBOARD_ICON',
|
|
356
|
-
hint: 'Overview',
|
|
357
383
|
},
|
|
358
384
|
{
|
|
359
385
|
id: 'dose-design',
|
|
360
386
|
label: 'Dose Design',
|
|
361
387
|
to: '/dose-design',
|
|
362
388
|
icon: 'PLUGIN_ICON',
|
|
363
|
-
hint: 'Dose Designer',
|
|
364
389
|
},
|
|
365
390
|
{
|
|
366
391
|
id: 'qc-review',
|
|
367
392
|
label: 'QC Review',
|
|
368
393
|
to: '/qc/review',
|
|
369
394
|
icon: 'QC_ICON',
|
|
370
|
-
hint: 'Dose Designer',
|
|
371
395
|
},
|
|
372
396
|
])
|
|
373
397
|
expect(requestConfigs).toHaveLength(0)
|
|
@@ -396,6 +420,108 @@ describe('usePluginClient', () => {
|
|
|
396
420
|
expect(requestConfigs).toHaveLength(0)
|
|
397
421
|
})
|
|
398
422
|
|
|
423
|
+
it('loads and saves plugin settings through the integrated platform config endpoint', async () => {
|
|
424
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
425
|
+
isIntegrated: true,
|
|
426
|
+
theme: 'light',
|
|
427
|
+
platformApiUrl: '/api',
|
|
428
|
+
plugin: {
|
|
429
|
+
id: 'drp',
|
|
430
|
+
name: 'drp',
|
|
431
|
+
version: '1.0.0',
|
|
432
|
+
route_prefix: '/drp',
|
|
433
|
+
api_prefix: '/api/drp',
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
nextGetData = { plugin_name: 'drp', config: { nasBasePath: '/data' } }
|
|
437
|
+
nextPatchData = { plugin_name: 'drp', config: { nasBasePath: '/mnt' } }
|
|
438
|
+
|
|
439
|
+
const settings = usePluginSettings<{ nasBasePath?: string }>({ pluginName: 'drp' })
|
|
440
|
+
await settings.load()
|
|
441
|
+
|
|
442
|
+
expect(requestConfigs[0]!.baseURL).toBe('/api')
|
|
443
|
+
expect(requestConfigs[0]!.url).toBe('/plugins/drp/config')
|
|
444
|
+
expect(settings.values.value).toEqual({ nasBasePath: '/data' })
|
|
445
|
+
expect(settings.settings.value).toEqual({ nasBasePath: '/data' })
|
|
446
|
+
expect(settings.isDirty.value).toBe(false)
|
|
447
|
+
|
|
448
|
+
settings.setValues({ nasBasePath: '/mnt' })
|
|
449
|
+
expect(settings.isDirty.value).toBe(true)
|
|
450
|
+
const ok = await settings.save()
|
|
451
|
+
|
|
452
|
+
expect(ok).toBe(true)
|
|
453
|
+
expect(requestConfigs[1]!.baseURL).toBe('/api')
|
|
454
|
+
expect(requestConfigs[1]!.url).toBe('/plugins/drp/config')
|
|
455
|
+
expect(requestBodies[0]).toEqual({ config: { nasBasePath: '/mnt' } })
|
|
456
|
+
expect(settings.values.value).toEqual({ nasBasePath: '/mnt' })
|
|
457
|
+
expect(settings.isDirty.value).toBe(false)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('loads and saves plugin settings through the standalone settings router', async () => {
|
|
461
|
+
nextGetData = { settings: { nas_base_path: '/data' }, mode: 'standalone' }
|
|
462
|
+
nextPutData = { settings: { nas_base_path: '/mnt' }, mode: 'standalone' }
|
|
463
|
+
|
|
464
|
+
const settings = usePluginSettings<{ nas_base_path?: string }>({
|
|
465
|
+
apiBaseUrl: '/api/drp',
|
|
466
|
+
loadOnMount: false,
|
|
467
|
+
})
|
|
468
|
+
await settings.load()
|
|
469
|
+
const ok = await settings.save({ nas_base_path: '/mnt' })
|
|
470
|
+
|
|
471
|
+
expect(ok).toBe(true)
|
|
472
|
+
expect(requestConfigs[0]!.baseURL).toBe('/api/drp')
|
|
473
|
+
expect(requestConfigs[0]!.url).toBe('/settings')
|
|
474
|
+
expect(requestConfigs[1]!.baseURL).toBe('/api/drp')
|
|
475
|
+
expect(requestConfigs[1]!.url).toBe('/settings')
|
|
476
|
+
expect(requestBodies[0]).toEqual({ nas_base_path: '/mnt' })
|
|
477
|
+
expect(settings.settingsConfig.value.values).toEqual({ nas_base_path: '/mnt' })
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('streams plugin events with auth headers and SSE parsing', async () => {
|
|
481
|
+
const authStore = useAuthStore()
|
|
482
|
+
authStore.setToken('stream-token', 3600)
|
|
483
|
+
const onMessage = vi.fn()
|
|
484
|
+
const streamBody = new ReadableStream({
|
|
485
|
+
start(controller) {
|
|
486
|
+
controller.enqueue(new TextEncoder().encode('event: progress\nid: 7\ndata: {"pct":50}\n\n'))
|
|
487
|
+
controller.close()
|
|
488
|
+
},
|
|
489
|
+
})
|
|
490
|
+
const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) =>
|
|
491
|
+
new Response(streamBody, { status: 200 }),
|
|
492
|
+
)
|
|
493
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
494
|
+
|
|
495
|
+
const stream = usePluginEventStream<{ pct: number }>(
|
|
496
|
+
contract,
|
|
497
|
+
{ method: 'get', path: '/downloads/events' },
|
|
498
|
+
{
|
|
499
|
+
parseJson: true,
|
|
500
|
+
reconnect: false,
|
|
501
|
+
onMessage,
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
await vi.waitFor(() => {
|
|
506
|
+
expect(onMessage).toHaveBeenCalledOnce()
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
expect(fetchMock).toHaveBeenCalledOnce()
|
|
510
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
511
|
+
expect(url).toBe('/api/drp/downloads/events')
|
|
512
|
+
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer stream-token')
|
|
513
|
+
expect((init?.headers as Headers).get('Accept')).toBe('text/event-stream')
|
|
514
|
+
expect(onMessage).toHaveBeenCalledWith({
|
|
515
|
+
event: 'progress',
|
|
516
|
+
data: { pct: 50 },
|
|
517
|
+
id: '7',
|
|
518
|
+
retry: undefined,
|
|
519
|
+
raw: 'event: progress\nid: 7\ndata: {"pct":50}',
|
|
520
|
+
})
|
|
521
|
+
expect(stream.lastMessage.value?.data).toEqual({ pct: 50 })
|
|
522
|
+
expect(stream.lastEventId.value).toBe('7')
|
|
523
|
+
})
|
|
524
|
+
|
|
399
525
|
it('can build path-only endpoint URLs with inferred experiment ids', () => {
|
|
400
526
|
window.history.replaceState({}, '', '/experiments/42/plugins/drp')
|
|
401
527
|
|
|
@@ -3,7 +3,6 @@ import type { PluginNavItem } from '../types/platform'
|
|
|
3
3
|
|
|
4
4
|
export interface PluginNavPageSelectorOptions {
|
|
5
5
|
pluginIcon?: string
|
|
6
|
-
pluginName?: string
|
|
7
6
|
}
|
|
8
7
|
|
|
9
8
|
export function normalizeNavPath(path?: string): string {
|
|
@@ -29,7 +28,6 @@ export function pluginNavItemToPageSelectorItem(
|
|
|
29
28
|
label: item.label,
|
|
30
29
|
to: path,
|
|
31
30
|
icon: item.icon || options.pluginIcon,
|
|
32
|
-
hint: item.description || options.pluginName,
|
|
33
31
|
}
|
|
34
32
|
}
|
|
35
33
|
|
|
@@ -18,11 +18,11 @@ const samplePillNav: PillNavItem[] = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
const samplePageSelector: PageSelectorItem[] = [
|
|
21
|
-
{ id: 'workspace', label: 'Workspace'
|
|
22
|
-
{ id: 'experiments', label: 'Experiments'
|
|
23
|
-
{ id: 'projects', label: 'Projects'
|
|
24
|
-
{ id: 'plugins', label: 'Plugins'
|
|
25
|
-
{ id: 'admin', label: 'Admin'
|
|
21
|
+
{ id: 'workspace', label: 'Workspace' },
|
|
22
|
+
{ id: 'experiments', label: 'Experiments' },
|
|
23
|
+
{ id: 'projects', label: 'Projects' },
|
|
24
|
+
{ id: 'plugins', label: 'Plugins' },
|
|
25
|
+
{ id: 'admin', label: 'Admin' },
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
// Theme is now surfaced as a top-level icon button (show-theme-toggle),
|
|
@@ -160,7 +160,6 @@ const platformPageSelector = computed<PageSelectorItem[]>(() =>
|
|
|
160
160
|
? plugin.value?.nav_items?.map((item, index) =>
|
|
161
161
|
pluginNavItemToPageSelectorItem(item, index, {
|
|
162
162
|
pluginIcon: plugin.value?.icon,
|
|
163
|
-
pluginName: plugin.value?.name,
|
|
164
163
|
})
|
|
165
164
|
) ?? []
|
|
166
165
|
: [],
|
|
@@ -7,6 +7,7 @@ import BaseInput from './BaseInput.vue'
|
|
|
7
7
|
import StepWizard from './StepWizard.vue'
|
|
8
8
|
import LoadingSpinner from './LoadingSpinner.vue'
|
|
9
9
|
import AlertBox from './AlertBox.vue'
|
|
10
|
+
import SampleHierarchyTree from './SampleHierarchyTree.vue'
|
|
10
11
|
import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
|
|
11
12
|
import { classKey } from '../composables/autoGroup'
|
|
12
13
|
import { useApi } from '../composables/useApi'
|
|
@@ -113,6 +114,12 @@ const totalQc = computed(() =>
|
|
|
113
114
|
(autoGroup.qcGroups.value ?? []).reduce((acc, g) => acc + g.samples.length, 0),
|
|
114
115
|
)
|
|
115
116
|
|
|
117
|
+
// Nested experimental hierarchy (class → groupBy layers → sample leaves) for the
|
|
118
|
+
// collapsible Preview tree. Expand the class roots by default so the first
|
|
119
|
+
// grouping level is visible without overwhelming the panel with every sample.
|
|
120
|
+
const groupTree = computed(() => autoGroup.result.value.groupTree ?? [])
|
|
121
|
+
const defaultExpandedTreeIds = computed(() => groupTree.value.map(n => n.id))
|
|
122
|
+
|
|
116
123
|
function cloneGroups(groups: SampleGroup[]): SampleGroup[] {
|
|
117
124
|
return groups.map(group => ({
|
|
118
125
|
...group,
|
|
@@ -744,6 +751,7 @@ const isFirstStep = computed(() => currentStep.value === 0)
|
|
|
744
751
|
class="mint-auto-group__textarea"
|
|
745
752
|
rows="12"
|
|
746
753
|
placeholder="Paste sample names, one per line..."
|
|
754
|
+
aria-label="Sample names"
|
|
747
755
|
/>
|
|
748
756
|
<div v-if="autoGroup.samples.value.length > 0" class="mint-auto-group__sample-count">
|
|
749
757
|
{{ autoGroup.samples.value.length }} samples
|
|
@@ -765,6 +773,7 @@ const isFirstStep = computed(() => currentStep.value === 0)
|
|
|
765
773
|
type="file"
|
|
766
774
|
accept=".csv,.tsv"
|
|
767
775
|
class="mint-auto-group__file-input"
|
|
776
|
+
aria-label="Upload CSV or TSV file"
|
|
768
777
|
@change="handleFileInput"
|
|
769
778
|
/>
|
|
770
779
|
<svg class="mint-auto-group__upload-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -922,6 +931,8 @@ const isFirstStep = computed(() => currentStep.value === 0)
|
|
|
922
931
|
:key="col.index"
|
|
923
932
|
type="button"
|
|
924
933
|
role="row"
|
|
934
|
+
:aria-expanded="openPopoverIdx === col.index"
|
|
935
|
+
aria-haspopup="dialog"
|
|
925
936
|
:class="[
|
|
926
937
|
'mint-auto-group__token-row',
|
|
927
938
|
openPopoverIdx === col.index ? 'mint-auto-group__token-row--active' : '',
|
|
@@ -960,12 +971,14 @@ const isFirstStep = computed(() => currentStep.value === 0)
|
|
|
960
971
|
<div
|
|
961
972
|
v-if="openPopoverIdx !== null && activeColumn"
|
|
962
973
|
class="mint-auto-group__popover"
|
|
974
|
+
role="dialog"
|
|
975
|
+
:aria-label="`${activeColumn.displayName ?? activeColumn.name} column options`"
|
|
963
976
|
@click.stop
|
|
964
977
|
>
|
|
965
978
|
<div class="mint-auto-group__popover-head">
|
|
966
979
|
<strong>{{ activeColumn.displayName ?? activeColumn.name }}</strong>
|
|
967
980
|
<span class="mono">{{ activeColumn.cardinality }} unique</span>
|
|
968
|
-
<button type="button" class="mint-auto-group__popover-close" @click="openPopoverIdx = null">×</button>
|
|
981
|
+
<button type="button" class="mint-auto-group__popover-close" aria-label="Close" @click="openPopoverIdx = null">×</button>
|
|
969
982
|
</div>
|
|
970
983
|
|
|
971
984
|
<div class="mint-auto-group__popover-section">
|
|
@@ -1044,24 +1057,15 @@ const isFirstStep = computed(() => currentStep.value === 0)
|
|
|
1044
1057
|
<div class="mint-auto-group__preview-grid">
|
|
1045
1058
|
<div class="mint-auto-group__preview-panel">
|
|
1046
1059
|
<h4>Experimental groups</h4>
|
|
1047
|
-
<
|
|
1048
|
-
v-
|
|
1049
|
-
:
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
</summary>
|
|
1057
|
-
<div class="mint-auto-group__preview-samples">
|
|
1058
|
-
<span
|
|
1059
|
-
v-for="s in group.samples"
|
|
1060
|
-
:key="s"
|
|
1061
|
-
class="mint-auto-group__preview-sample"
|
|
1062
|
-
>{{ s }}</span>
|
|
1063
|
-
</div>
|
|
1064
|
-
</details>
|
|
1060
|
+
<SampleHierarchyTree
|
|
1061
|
+
v-if="groupTree.length"
|
|
1062
|
+
:nodes="groupTree"
|
|
1063
|
+
:default-expanded-ids="defaultExpandedTreeIds"
|
|
1064
|
+
:show-icons="false"
|
|
1065
|
+
size="sm"
|
|
1066
|
+
class="mint-auto-group__preview-tree"
|
|
1067
|
+
/>
|
|
1068
|
+
<p v-else class="mint-auto-group__preview-empty">No experimental groups.</p>
|
|
1065
1069
|
</div>
|
|
1066
1070
|
|
|
1067
1071
|
<div class="mint-auto-group__preview-panel">
|
|
@@ -171,13 +171,9 @@ const sheetOpen = ref(false)
|
|
|
171
171
|
</Variant>
|
|
172
172
|
<Variant title="Drawer variant — right-edge detail panel">
|
|
173
173
|
<div style="padding: 2rem;">
|
|
174
|
-
<
|
|
175
|
-
type="button"
|
|
176
|
-
style="padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-primary); color: white; border: none; cursor: pointer;"
|
|
177
|
-
@click="drawerOpen = true"
|
|
178
|
-
>
|
|
174
|
+
<BaseButton variant="primary" @click="drawerOpen = true">
|
|
179
175
|
Open drawer
|
|
180
|
-
</
|
|
176
|
+
</BaseButton>
|
|
181
177
|
<p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
|
|
182
178
|
Slides in from the right edge. Fills viewport height. For detail panels that shouldn't take over the page.
|
|
183
179
|
</p>
|
|
@@ -203,20 +199,16 @@ const sheetOpen = ref(false)
|
|
|
203
199
|
</div>
|
|
204
200
|
</div>
|
|
205
201
|
<template #footer>
|
|
206
|
-
<
|
|
202
|
+
<BaseButton variant="primary" @click="drawerOpen = false">Close</BaseButton>
|
|
207
203
|
</template>
|
|
208
204
|
</BaseModal>
|
|
209
205
|
</Variant>
|
|
210
206
|
|
|
211
207
|
<Variant title="Sheet variant — mobile-friendly bottom sheet">
|
|
212
208
|
<div style="padding: 2rem;">
|
|
213
|
-
<
|
|
214
|
-
type="button"
|
|
215
|
-
style="padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-primary); color: white; border: none; cursor: pointer;"
|
|
216
|
-
@click="sheetOpen = true"
|
|
217
|
-
>
|
|
209
|
+
<BaseButton variant="primary" @click="sheetOpen = true">
|
|
218
210
|
Open sheet
|
|
219
|
-
</
|
|
211
|
+
</BaseButton>
|
|
220
212
|
<p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
|
|
221
213
|
Slides up from the bottom. Top corners rounded. Grab-hint bar at top. Auto-used on <768px viewports regardless of caller's variant.
|
|
222
214
|
</p>
|
|
@@ -228,8 +220,8 @@ const sheetOpen = ref(false)
|
|
|
228
220
|
<strong>Compound X · 10 µM · 24h</strong>. The change applies immediately and can be undone within 10 seconds.
|
|
229
221
|
</p>
|
|
230
222
|
<template #footer>
|
|
231
|
-
<
|
|
232
|
-
<
|
|
223
|
+
<BaseButton variant="secondary" @click="sheetOpen = false">Cancel</BaseButton>
|
|
224
|
+
<BaseButton variant="primary" @click="sheetOpen = false">Apply</BaseButton>
|
|
233
225
|
</template>
|
|
234
226
|
</BaseModal>
|
|
235
227
|
</Variant>
|
|
@@ -102,10 +102,11 @@ onUnmounted(() => {
|
|
|
102
102
|
{ 'mint-experiment-popover__trigger--empty': !experimentCode && !experimentName },
|
|
103
103
|
]"
|
|
104
104
|
:title="experimentName || experimentCode || undefined"
|
|
105
|
+
:aria-expanded="isOpen"
|
|
105
106
|
@click.stop="toggle"
|
|
106
107
|
>
|
|
107
108
|
<!-- Flask icon -->
|
|
108
|
-
<svg class="mint-experiment-popover__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
109
|
+
<svg class="mint-experiment-popover__trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
109
110
|
<path
|
|
110
111
|
stroke-linecap="round"
|
|
111
112
|
stroke-linejoin="round"
|
|
@@ -118,7 +119,7 @@ onUnmounted(() => {
|
|
|
118
119
|
<span v-else-if="experimentCode" class="mint-experiment-popover__trigger-code">{{ experimentCode }}</span>
|
|
119
120
|
<span v-else class="mint-experiment-popover__trigger-text">No experiment</span>
|
|
120
121
|
<!-- Chevron -->
|
|
121
|
-
<svg class="mint-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
122
|
+
<svg class="mint-experiment-popover__trigger-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
122
123
|
<path d="m6 9 6 6 6-6" />
|
|
123
124
|
</svg>
|
|
124
125
|
</button>
|
|
@@ -135,16 +136,17 @@ onUnmounted(() => {
|
|
|
135
136
|
]"
|
|
136
137
|
:disabled="saveDisabled && !showSuccess"
|
|
137
138
|
:title="saveDisabled && saveDisabledMessage ? saveDisabledMessage : showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment'"
|
|
139
|
+
aria-label="Save to experiment"
|
|
138
140
|
@click.stop="handleSave"
|
|
139
141
|
>
|
|
140
142
|
<!-- Loading spinner -->
|
|
141
143
|
<span v-if="saveLoading" class="mint-experiment-popover__spinner--inline" />
|
|
142
144
|
<!-- Success check -->
|
|
143
|
-
<svg v-else-if="showSuccess" class="mint-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
145
|
+
<svg v-else-if="showSuccess" class="mint-experiment-popover__save-trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
144
146
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
|
145
147
|
</svg>
|
|
146
148
|
<!-- Save icon -->
|
|
147
|
-
<svg v-else class="mint-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
149
|
+
<svg v-else class="mint-experiment-popover__save-trigger-icon" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
148
150
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
|
149
151
|
</svg>
|
|
150
152
|
</button>
|
|
@@ -91,6 +91,19 @@ const flatExperiments = computed(() => {
|
|
|
91
91
|
return groupedByProject.value.flatMap(([, exps]) => exps)
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// Stable ids so the search field (role="combobox") can expose the currently
|
|
95
|
+
// arrow-highlighted row to assistive tech via aria-activedescendant. This
|
|
96
|
+
// surfaces the existing keyboard model to screen readers without changing it.
|
|
97
|
+
const LISTBOX_ID = 'mint-experiment-selector-listbox'
|
|
98
|
+
function optionId(experiment: ExperimentSummary): string {
|
|
99
|
+
return `mint-experiment-option-${experiment.id}`
|
|
100
|
+
}
|
|
101
|
+
const activeDescendantId = computed(() => {
|
|
102
|
+
const exp = flatExperiments.value[activeIndex.value]
|
|
103
|
+
return activeIndex.value >= 0 && exp ? optionId(exp) : undefined
|
|
104
|
+
})
|
|
105
|
+
const hasResults = computed(() => !isLoading.value && !error.value && experiments.value.length > 0)
|
|
106
|
+
|
|
94
107
|
function setFilter<K extends keyof ExperimentFilters>(key: K, value: string | number) {
|
|
95
108
|
;(filters as Record<string, unknown>)[key] = String(value) || undefined
|
|
96
109
|
}
|
|
@@ -196,6 +209,11 @@ watch(
|
|
|
196
209
|
placeholder="Search experiments..."
|
|
197
210
|
size="sm"
|
|
198
211
|
type="search"
|
|
212
|
+
role="combobox"
|
|
213
|
+
aria-label="Search experiments"
|
|
214
|
+
:aria-expanded="hasResults"
|
|
215
|
+
:aria-controls="LISTBOX_ID"
|
|
216
|
+
:aria-activedescendant="activeDescendantId"
|
|
199
217
|
/>
|
|
200
218
|
</div>
|
|
201
219
|
<div class="mint-experiment-selector__filter-select">
|
|
@@ -218,9 +236,10 @@ watch(
|
|
|
218
236
|
class="mint-experiment-selector__filters-toggle"
|
|
219
237
|
:class="{ 'mint-experiment-selector__filters-toggle--active': hasActiveAdvancedFilters }"
|
|
220
238
|
type="button"
|
|
239
|
+
:aria-expanded="showAdvanced"
|
|
221
240
|
@click="showAdvanced = !showAdvanced"
|
|
222
241
|
>
|
|
223
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
242
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
224
243
|
<line x1="4" y1="6" x2="20" y2="6" /><line x1="8" y1="12" x2="20" y2="12" /><line x1="12" y1="18" x2="20" y2="18" />
|
|
225
244
|
<circle cx="6" cy="12" r="2" /><circle cx="10" cy="18" r="2" /><circle cx="6" cy="6" r="2" />
|
|
226
245
|
</svg>
|
|
@@ -290,16 +309,18 @@ watch(
|
|
|
290
309
|
/>
|
|
291
310
|
|
|
292
311
|
<!-- Experiment list: grouped mode -->
|
|
293
|
-
<div v-else-if="groupToggle" ref="listRef" class="mint-experiment-selector__list">
|
|
312
|
+
<div v-else-if="groupToggle" :id="LISTBOX_ID" ref="listRef" class="mint-experiment-selector__list" role="listbox" aria-label="Experiments">
|
|
294
313
|
<template v-for="([groupName, groupExps]) in groupedByProject" :key="groupName">
|
|
295
314
|
<button
|
|
296
315
|
type="button"
|
|
297
316
|
class="mint-experiment-selector__group-header"
|
|
317
|
+
:aria-expanded="!collapsedGroups.has(groupName)"
|
|
298
318
|
@click="toggleGroup(groupName)"
|
|
299
319
|
>
|
|
300
320
|
<svg
|
|
301
321
|
class="mint-experiment-selector__group-chevron"
|
|
302
322
|
:class="{ 'mint-experiment-selector__group-chevron--collapsed': collapsedGroups.has(groupName) }"
|
|
323
|
+
aria-hidden="true"
|
|
303
324
|
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
304
325
|
>
|
|
305
326
|
<polyline points="6 9 12 15 18 9" />
|
|
@@ -310,8 +331,11 @@ watch(
|
|
|
310
331
|
<template v-if="!collapsedGroups.has(groupName)">
|
|
311
332
|
<div
|
|
312
333
|
v-for="exp in groupExps"
|
|
334
|
+
:id="optionId(exp)"
|
|
313
335
|
:key="exp.id"
|
|
314
336
|
class="mint-experiment-selector__row"
|
|
337
|
+
role="option"
|
|
338
|
+
:aria-selected="exp.id === currentExperimentId"
|
|
315
339
|
:class="{
|
|
316
340
|
'mint-experiment-selector__row--active': exp.id === currentExperimentId,
|
|
317
341
|
'mint-experiment-selector__row--focused': getFlatIndex(exp) === activeIndex,
|
|
@@ -342,11 +366,14 @@ watch(
|
|
|
342
366
|
</div>
|
|
343
367
|
|
|
344
368
|
<!-- Experiment list: flat mode -->
|
|
345
|
-
<div v-else ref="listRef" class="mint-experiment-selector__list">
|
|
369
|
+
<div v-else :id="LISTBOX_ID" ref="listRef" class="mint-experiment-selector__list" role="listbox" aria-label="Experiments">
|
|
346
370
|
<div
|
|
347
371
|
v-for="(exp, idx) in experiments"
|
|
372
|
+
:id="optionId(exp)"
|
|
348
373
|
:key="exp.id"
|
|
349
374
|
class="mint-experiment-selector__row"
|
|
375
|
+
role="option"
|
|
376
|
+
:aria-selected="exp.id === currentExperimentId"
|
|
350
377
|
:class="{
|
|
351
378
|
'mint-experiment-selector__row--active': exp.id === currentExperimentId,
|
|
352
379
|
'mint-experiment-selector__row--focused': idx === activeIndex,
|
|
@@ -113,6 +113,11 @@ function handleClick() {
|
|
|
113
113
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
114
114
|
</svg>
|
|
115
115
|
</IconButton>
|
|
116
|
+
<IconButton label="AI" variant="ghost" @click="handleClick">
|
|
117
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
118
|
+
<path d="M12 3 14 10 21 12 14 14 12 21 10 14 3 12 10 10Z" />
|
|
119
|
+
</svg>
|
|
120
|
+
</IconButton>
|
|
116
121
|
</div>
|
|
117
122
|
</Variant>
|
|
118
123
|
|
|
@@ -208,6 +208,10 @@ const {
|
|
|
208
208
|
emitPillSelect: (item) => emit('pill-select', item),
|
|
209
209
|
})
|
|
210
210
|
|
|
211
|
+
const visiblePageSelector = computed(() =>
|
|
212
|
+
(props.pageSelector?.length ?? 0) > 1 ? props.pageSelector : undefined,
|
|
213
|
+
)
|
|
214
|
+
|
|
211
215
|
const hasSidebarSurface = computed(() => {
|
|
212
216
|
return hasPluginWorkspaceSidebarSurface({
|
|
213
217
|
showSidebar: props.showSidebar,
|
|
@@ -270,7 +274,7 @@ function handleFormCancel(sectionId: string) {
|
|
|
270
274
|
:variant="topBarVariant"
|
|
271
275
|
:home-path="homePath"
|
|
272
276
|
:show-logo="showLogo"
|
|
273
|
-
:page-selector="
|
|
277
|
+
:page-selector="visiblePageSelector"
|
|
274
278
|
:current-page-selector-id="resolvedCurrentPageSelectorId"
|
|
275
279
|
:plugin-switcher="pluginSwitcher"
|
|
276
280
|
:pill-nav="resolvedPillNav"
|
|
@@ -301,12 +301,12 @@ defineExpose({ handleSmartGroupApply })
|
|
|
301
301
|
<div class="mint-sample-selector__groups-header">
|
|
302
302
|
<span class="mint-sample-selector__groups-title">Groups ({{ internalGroups.length }})</span>
|
|
303
303
|
<div class="mint-sample-selector__groups-controls">
|
|
304
|
-
<button type="button" class="mint-sample-selector__expand-btn" @click="expandAllGroups" title="Expand all">
|
|
304
|
+
<button type="button" class="mint-sample-selector__expand-btn" @click="expandAllGroups" title="Expand all" aria-label="Expand all groups">
|
|
305
305
|
<svg class="mint-sample-selector__expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
306
306
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
307
307
|
</svg>
|
|
308
308
|
</button>
|
|
309
|
-
<button type="button" class="mint-sample-selector__expand-btn" @click="collapseAllGroups" title="Collapse all">
|
|
309
|
+
<button type="button" class="mint-sample-selector__expand-btn" @click="collapseAllGroups" title="Collapse all" aria-label="Collapse all groups">
|
|
310
310
|
<svg class="mint-sample-selector__expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
311
311
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
312
312
|
</svg>
|
|
@@ -704,6 +704,7 @@ defineExpose({ handleSmartGroupApply })
|
|
|
704
704
|
type="checkbox"
|
|
705
705
|
:checked="modelValue.includes(sample)"
|
|
706
706
|
class="mint-sample-selector__checkbox"
|
|
707
|
+
:aria-label="`Select ${sample}`"
|
|
707
708
|
@change="toggleSample(sample)"
|
|
708
709
|
/>
|
|
709
710
|
<span class="mint-sample-selector__flat-name">{{ sample }}</span>
|
|
@@ -40,7 +40,7 @@ const checkboxClasses = computed(() => [
|
|
|
40
40
|
:aria-label="`Sample: ${sample}`"
|
|
41
41
|
draggable="true"
|
|
42
42
|
>
|
|
43
|
-
<svg class="mint-sample-selector__drag-handle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
43
|
+
<svg class="mint-sample-selector__drag-handle" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
44
44
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
|
45
45
|
</svg>
|
|
46
46
|
<input
|
|
@@ -48,6 +48,7 @@ const checkboxClasses = computed(() => [
|
|
|
48
48
|
:checked="selected"
|
|
49
49
|
:class="checkboxClasses"
|
|
50
50
|
:style="accentColor ? { accentColor } : undefined"
|
|
51
|
+
:aria-label="`Select sample ${sample}`"
|
|
51
52
|
@change="emit('toggle')"
|
|
52
53
|
/>
|
|
53
54
|
<span class="mint-sample-selector__sample-name">{{ sample }}</span>
|
|
@@ -56,9 +57,10 @@ const checkboxClasses = computed(() => [
|
|
|
56
57
|
type="button"
|
|
57
58
|
class="mint-sample-selector__remove-btn"
|
|
58
59
|
title="Remove from group"
|
|
60
|
+
aria-label="Remove from group"
|
|
59
61
|
@click="emit('remove')"
|
|
60
62
|
>
|
|
61
|
-
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
63
|
+
<svg aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
62
64
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
|
63
65
|
</svg>
|
|
64
66
|
</button>
|
|
@@ -117,7 +117,6 @@ function handleSelect(page: PageSelectorItem) {
|
|
|
117
117
|
</slot>
|
|
118
118
|
</span>
|
|
119
119
|
<span class="mint-page-selector__item-label">{{ page.label }}</span>
|
|
120
|
-
<span v-if="page.hint" class="mint-page-selector__item-hint">{{ page.hint }}</span>
|
|
121
120
|
</ActionItemInternal>
|
|
122
121
|
</div>
|
|
123
122
|
</div>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SampleClass } from '../../types/auto-group'
|
|
2
2
|
|
|
3
|
-
export function classKey(
|
|
4
|
-
|
|
3
|
+
export function classKey(
|
|
4
|
+
c: Pick<SampleClass, 'kind'> & { subKind?: string; tokenLength?: number },
|
|
5
|
+
): string {
|
|
6
|
+
const base = c.subKind ? `${c.kind}:${c.subKind}` : c.kind
|
|
7
|
+
return c.tokenLength != null ? `${base}#${c.tokenLength}` : base
|
|
5
8
|
}
|
|
@@ -135,8 +135,8 @@ function inferColumnName(
|
|
|
135
135
|
// Date-like all-digits tokens (YYMMDD or YYYYMMDD).
|
|
136
136
|
if (values.every(v => /^\d{6}$/.test(v) || /^\d{8}$/.test(v))) return 'Date'
|
|
137
137
|
|
|
138
|
-
// Run-order column
|
|
139
|
-
if (role === 'run-order') return '
|
|
138
|
+
// Run-order column = the trailing injection / acquisition sequence number.
|
|
139
|
+
if (role === 'run-order') return 'Injection #'
|
|
140
140
|
|
|
141
141
|
// Polarity tokens (POS/NEG/positive/negative).
|
|
142
142
|
if (values.every(v => /^(pos|neg|positive|negative)$/i.test(v))) return 'Polarity'
|