@morscherlab/mint-sdk 1.0.0-beta.4 → 1.0.0-beta.6

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 (58) hide show
  1. package/dist/components/AppSidebar.vue.d.ts +1 -1
  2. package/dist/components/index.js +2 -2
  3. package/dist/{components-BkGF4B4y.js → components-DihbSJjU.js} +2524 -2517
  4. package/dist/components-DihbSJjU.js.map +1 -0
  5. package/dist/composables/index.js +3 -3
  6. package/dist/{composables-CHsME9H1.js → composables-BcgZ6diz.js} +2 -2
  7. package/dist/{composables-CHsME9H1.js.map → composables-BcgZ6diz.js.map} +1 -1
  8. package/dist/index.js +5 -5
  9. package/dist/install.js +2 -2
  10. package/dist/styles.css +738 -738
  11. package/dist/templates/adapters.d.ts +7 -1
  12. package/dist/templates/catalog.d.ts +5 -5
  13. package/dist/templates/index.d.ts +2 -2
  14. package/dist/templates/index.js +2 -2
  15. package/dist/templates/presets.d.ts +4 -4
  16. package/dist/templates/types.d.ts +4 -1
  17. package/dist/{templates-B5jmTWuk.js → templates-Cyt0Suwf.js} +213 -19
  18. package/dist/{templates-B5jmTWuk.js.map → templates-Cyt0Suwf.js.map} +1 -1
  19. package/dist/{useScheduleDrag-BgzpQT53.js → useExperimentData-CM6Y0u5L.js} +183 -183
  20. package/dist/useExperimentData-CM6Y0u5L.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/__tests__/components/AppSidebar.test.ts +4 -2
  23. package/src/__tests__/components/AppTopBar.test.ts +9 -3
  24. package/src/__tests__/components/{AppPageSelector.test.ts → AppTopBarPageSelector.test.ts} +8 -8
  25. package/src/__tests__/components/{AppPillNav.test.ts → AppTopBarPillNav.test.ts} +7 -7
  26. package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +17 -0
  27. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +22 -0
  28. package/src/__tests__/components/BioTemplateRenderer.test.ts +25 -0
  29. package/src/__tests__/components/ComponentBindingRenderer.test.ts +117 -0
  30. package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +1 -1
  31. package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +1 -1
  32. package/src/__tests__/composables/useControlSchema.test.ts +1 -1
  33. package/src/__tests__/templates/templates.test.ts +44 -0
  34. package/src/components/AppSidebar.vue +3 -3
  35. package/src/components/AppTopBar.vue +7 -7
  36. package/src/components/BioTemplatePresetWorkspaceView.vue +3 -3
  37. package/src/components/BioTemplateRenderer.story.vue +2 -2
  38. package/src/components/ComponentBindingRenderer.story.vue +30 -0
  39. package/src/components/ComponentBindingRenderer.vue +9 -0
  40. package/src/components/ExperimentPopover.story.vue +2 -2
  41. package/src/styles/components/app-page-selector.css +1 -1
  42. package/src/styles/components/app-pill-nav.css +1 -1
  43. package/src/styles/components/experiment-popover.css +2 -2
  44. package/src/templates/adapters.ts +193 -0
  45. package/src/templates/catalog.ts +5 -5
  46. package/src/templates/componentBindings.ts +52 -3
  47. package/src/templates/index.ts +6 -0
  48. package/src/templates/packs.ts +10 -1
  49. package/src/templates/presets.ts +14 -4
  50. package/src/templates/types.ts +4 -0
  51. package/dist/components-BkGF4B4y.js.map +0 -1
  52. package/dist/useScheduleDrag-BgzpQT53.js.map +0 -1
  53. /package/dist/__tests__/components/{AppPageSelector.test.d.ts → AppTopBarPageSelector.test.d.ts} +0 -0
  54. /package/dist/__tests__/components/{AppPillNav.test.d.ts → AppTopBarPillNav.test.d.ts} +0 -0
  55. /package/dist/components/internal/{AppPageSelectorInternal.vue.d.ts → AppTopBarPageSelectorInternal.vue.d.ts} +0 -0
  56. /package/dist/components/internal/{AppPillNavInternal.vue.d.ts → AppTopBarPillNavInternal.vue.d.ts} +0 -0
  57. /package/src/components/internal/{AppPageSelectorInternal.vue → AppTopBarPageSelectorInternal.vue} +0 -0
  58. /package/src/components/internal/{AppPillNavInternal.vue → AppTopBarPillNavInternal.vue} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.6",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -747,11 +747,13 @@ describe('AppSidebar', () => {
747
747
  expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true)
748
748
  })
749
749
 
750
- it('should handle undefined activeView', () => {
750
+ it('uses the first non-empty panel view when activeView is omitted', () => {
751
751
  const wrapper = mount(AppSidebar, {
752
752
  props: { panels: samplePanels },
753
753
  })
754
- expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true)
754
+ expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false)
755
+ expect(wrapper.findAllComponents(CollapsibleCard)).toHaveLength(2)
756
+ expect(wrapper.find('.mint-sidebar__sections').exists()).toBe(true)
755
757
  })
756
758
  })
757
759
 
@@ -1063,18 +1063,24 @@ describe('AppTopBar', () => {
1063
1063
  version: '1.0',
1064
1064
  route_prefix: '/test',
1065
1065
  api_prefix: '/api/test',
1066
- nav_items: [{ path: '/stale', label: 'Stale' }],
1066
+ nav_items: [
1067
+ { path: '/stale', label: 'Stale' },
1068
+ { path: '/stale-two', label: 'Stale Two' },
1069
+ ],
1067
1070
  },
1068
1071
  })
1069
1072
 
1070
1073
  const wrapper = createWrapper({
1071
- pageSelector: [{ id: 'overview', label: 'Overview', to: '/' }],
1074
+ pageSelector: [
1075
+ { id: 'overview', label: 'Overview', to: '/' },
1076
+ { id: 'reports', label: 'Reports', to: '/reports' },
1077
+ ],
1072
1078
  currentPageSelectorId: 'overview',
1073
1079
  })
1074
1080
  await wrapper.get('.mint-page-selector__trigger').trigger('click')
1075
1081
 
1076
1082
  expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
1077
- expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual(['Overview'])
1083
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual(['Overview', 'Reports'])
1078
1084
  })
1079
1085
  })
1080
1086
 
@@ -1,10 +1,10 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
- import AppPageSelectorInternal from '../../components/internal/AppPageSelectorInternal.vue'
3
+ import AppTopBarPageSelectorInternal from '../../components/internal/AppTopBarPageSelectorInternal.vue'
4
4
 
5
- describe('AppPageSelectorInternal', () => {
5
+ describe('AppTopBarPageSelectorInternal', () => {
6
6
  it('accepts string shorthand pages', async () => {
7
- const wrapper = mount(AppPageSelectorInternal, {
7
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
8
8
  props: {
9
9
  currentPageId: 'Workspace',
10
10
  pages: ['Workspace', 'Results'],
@@ -25,7 +25,7 @@ describe('AppPageSelectorInternal', () => {
25
25
  })
26
26
 
27
27
  it('closes linked page actions without emitting select', async () => {
28
- const wrapper = mount(AppPageSelectorInternal, {
28
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
29
29
  props: {
30
30
  currentPageId: 'workspace',
31
31
  pages: [
@@ -43,7 +43,7 @@ describe('AppPageSelectorInternal', () => {
43
43
  })
44
44
 
45
45
  it('keeps disabled linked page actions inert', async () => {
46
- const wrapper = mount(AppPageSelectorInternal, {
46
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
47
47
  props: {
48
48
  currentPageId: 'workspace',
49
49
  pages: [
@@ -66,7 +66,7 @@ describe('AppPageSelectorInternal', () => {
66
66
 
67
67
  it('emits select for button page actions', async () => {
68
68
  const page = { id: 'workspace', label: 'Workspace' }
69
- const wrapper = mount(AppPageSelectorInternal, {
69
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
70
70
  props: {
71
71
  pages: [page],
72
72
  },
@@ -81,7 +81,7 @@ describe('AppPageSelectorInternal', () => {
81
81
  it('renders PluginIcon-compatible metadata icons and keeps symbolic icons as fallback initials', async () => {
82
82
  const iconPath = 'M4 19h16M7 16V8m5 8V4m5 12v-6'
83
83
  const pngIcon = 'data:image/png;base64,iVBORw0KGgo='
84
- const wrapper = mount(AppPageSelectorInternal, {
84
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
85
85
  props: {
86
86
  pages: [
87
87
  { id: 'analysis', label: 'Analysis', icon: iconPath },
@@ -117,7 +117,7 @@ describe('AppPageSelectorInternal', () => {
117
117
 
118
118
  it('renders https page icons through PluginIcon', async () => {
119
119
  const httpsIcon = 'https://example.com/plugin-icon.png'
120
- const wrapper = mount(AppPageSelectorInternal, {
120
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
121
121
  props: {
122
122
  pages: [
123
123
  { id: 'docs', label: 'Docs', icon: httpsIcon },
@@ -1,10 +1,10 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
- import AppPillNavInternal from '../../components/internal/AppPillNavInternal.vue'
3
+ import AppTopBarPillNavInternal from '../../components/internal/AppTopBarPillNavInternal.vue'
4
4
 
5
- describe('AppPillNavInternal', () => {
5
+ describe('AppTopBarPillNavInternal', () => {
6
6
  it('accepts string shorthand items', async () => {
7
- const wrapper = mount(AppPillNavInternal, {
7
+ const wrapper = mount(AppTopBarPillNavInternal, {
8
8
  props: {
9
9
  currentItemId: 'Overview',
10
10
  items: ['Overview', 'Analysis'],
@@ -22,7 +22,7 @@ describe('AppPillNavInternal', () => {
22
22
  })
23
23
 
24
24
  it('emits select for button items but not linked items', async () => {
25
- const wrapper = mount(AppPillNavInternal, {
25
+ const wrapper = mount(AppTopBarPillNavInternal, {
26
26
  props: {
27
27
  currentItemId: 'overview',
28
28
  items: [
@@ -40,7 +40,7 @@ describe('AppPillNavInternal', () => {
40
40
 
41
41
  it('renders SVG icons from item metadata', () => {
42
42
  const icon = ['M4 12h16', 'M12 4v16']
43
- const wrapper = mount(AppPillNavInternal, {
43
+ const wrapper = mount(AppTopBarPillNavInternal, {
44
44
  props: {
45
45
  items: [
46
46
  { id: 'run', label: 'Run', icon },
@@ -59,7 +59,7 @@ describe('AppPillNavInternal', () => {
59
59
  })
60
60
 
61
61
  it('prevents disabled link navigation and selection', async () => {
62
- const wrapper = mount(AppPillNavInternal, {
62
+ const wrapper = mount(AppTopBarPillNavInternal, {
63
63
  props: {
64
64
  items: [
65
65
  { id: 'docs', label: 'Docs', href: '/docs', disabled: true },
@@ -77,7 +77,7 @@ describe('AppPillNavInternal', () => {
77
77
  })
78
78
 
79
79
  it('supports dropdown child items for grouped pill navigation', async () => {
80
- const wrapper = mount(AppPillNavInternal, {
80
+ const wrapper = mount(AppTopBarPillNavInternal, {
81
81
  props: {
82
82
  currentItemId: 'pca',
83
83
  items: [
@@ -21,8 +21,10 @@ const globalOptions = {
21
21
  stubs: {
22
22
  DataFrame: true,
23
23
  DoseCalculator: true,
24
+ ExperimentTimeline: true,
24
25
  PlateMapEditor: true,
25
26
  SampleSelector: true,
27
+ ScheduleCalendar: true,
26
28
  WellPlate: true,
27
29
  },
28
30
  }
@@ -172,4 +174,19 @@ describe('BioTemplatePackWorkspaceView', () => {
172
174
  })
173
175
  expect(wrapper.find('.mint-bio-template-renderer').exists()).toBe(false)
174
176
  })
177
+
178
+ it('renders metabolism pack run queues as timeline and calendar previews', () => {
179
+ const wrapper = mount(BioTemplatePackWorkspaceView, {
180
+ props: {
181
+ pack: 'metabolism',
182
+ },
183
+ global: globalOptions,
184
+ })
185
+
186
+ expect(wrapper.text()).toContain('Omics assay saves the full template collection')
187
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ExperimentTimeline"]').exists()).toBe(true)
188
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ScheduleCalendar"]').exists()).toBe(true)
189
+ expect(wrapper.find('[data-template-component="ExperimentTimeline"]').exists()).toBe(true)
190
+ expect(wrapper.find('[data-template-component="ScheduleCalendar"]').exists()).toBe(true)
191
+ })
175
192
  })
@@ -303,4 +303,26 @@ describe('BioTemplatePresetWorkspaceView', () => {
303
303
  expect(workspace.activeControlView.value).toBe('analysis')
304
304
  expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('analysis')
305
305
  })
306
+
307
+ it('renders LC-MS batch timeline and calendar previews from one preset prop', () => {
308
+ const wrapper = mount(BioTemplatePresetWorkspaceView, {
309
+ props: {
310
+ preset: 'lcms-batch',
311
+ },
312
+ global: {
313
+ stubs: {
314
+ DataFrame: true,
315
+ ExperimentTimeline: true,
316
+ ScheduleCalendar: true,
317
+ SampleSelector: true,
318
+ },
319
+ },
320
+ })
321
+
322
+ expect(wrapper.text()).toContain('LC-MS batch preset saves')
323
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ExperimentTimeline"]').exists()).toBe(true)
324
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ScheduleCalendar"]').exists()).toBe(true)
325
+ expect(wrapper.find('[data-template-component="ExperimentTimeline"]').exists()).toBe(true)
326
+ expect(wrapper.find('[data-template-component="ScheduleCalendar"]').exists()).toBe(true)
327
+ })
306
328
  })
@@ -33,6 +33,31 @@ describe('BioTemplateRenderer', () => {
33
33
  expect(wrapper.text()).toContain('dose-response')
34
34
  })
35
35
 
36
+ it('renders instrument-run timelines from LC-MS template collections', () => {
37
+ const collection = createLcmsBatchCollection({
38
+ samples: ['S001', 'S002'],
39
+ instrument: 'LC-MS',
40
+ })
41
+ const wrapper = mount(BioTemplateRenderer, {
42
+ props: { target: collection },
43
+ global: {
44
+ stubs: {
45
+ DataFrame: true,
46
+ ExperimentTimeline: true,
47
+ SampleSelector: true,
48
+ ScheduleCalendar: true,
49
+ },
50
+ },
51
+ })
52
+
53
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ScheduleCalendar"]').exists()).toBe(true)
54
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ExperimentTimeline"]').exists()).toBe(true)
55
+ expect(wrapper.find('[data-component-binding-id="assay-matrix:SampleSelector"]').exists()).toBe(true)
56
+ expect(wrapper.find('[data-template-component="ScheduleCalendar"]').exists()).toBe(true)
57
+ expect(wrapper.find('[data-template-component="ExperimentTimeline"]').exists()).toBe(true)
58
+ expect(wrapper.find('[data-template-id="instrument-run"]').exists()).toBe(true)
59
+ })
60
+
36
61
  it('filters rendered components with include and exclude lists', () => {
37
62
  const collection = createWellPlateScreenCollection()
38
63
  const wrapper = mount(BioTemplateRenderer, {
@@ -46,11 +46,48 @@ const PlateMapEditorStub = defineComponent({
46
46
  template: '<div data-test="plate-map-editor" />',
47
47
  })
48
48
 
49
+ const ExperimentTimelineStub = defineComponent({
50
+ name: 'ExperimentTimeline',
51
+ props: {
52
+ modelValue: { type: Array, default: () => [] },
53
+ editable: { type: Boolean, default: true },
54
+ size: { type: String, default: undefined },
55
+ },
56
+ template: '<div data-test="experiment-timeline" />',
57
+ })
58
+
59
+ const ScheduleCalendarStub = defineComponent({
60
+ name: 'ScheduleCalendar',
61
+ props: {
62
+ modelValue: { type: [String, Date], default: undefined },
63
+ events: { type: Array, default: () => [] },
64
+ readonly: { type: Boolean, default: false },
65
+ showNavigation: { type: Boolean, default: true },
66
+ showViewToggle: { type: Boolean, default: true },
67
+ view: { type: String, default: undefined },
68
+ },
69
+ template: '<div data-test="schedule-calendar" />',
70
+ })
71
+
72
+ const SampleSelectorStub = defineComponent({
73
+ name: 'SampleSelector',
74
+ props: {
75
+ samples: { type: Array, default: () => [] },
76
+ modelValue: { type: Array, default: () => [] },
77
+ enableGrouping: { type: Boolean, default: true },
78
+ enableSmartGroup: { type: Boolean, default: true },
79
+ },
80
+ template: '<div data-test="sample-selector" />',
81
+ })
82
+
49
83
  const globalOptions = {
50
84
  stubs: {
51
85
  DataFrame: DataFrameStub,
52
86
  DoseCalculator: DoseCalculatorStub,
87
+ ExperimentTimeline: ExperimentTimelineStub,
53
88
  PlateMapEditor: PlateMapEditorStub,
89
+ SampleSelector: SampleSelectorStub,
90
+ ScheduleCalendar: ScheduleCalendarStub,
54
91
  WellPlate: WellPlateStub,
55
92
  },
56
93
  }
@@ -158,4 +195,84 @@ describe('ComponentBindingRenderer', () => {
158
195
  maxHeight: '280px',
159
196
  })
160
197
  })
198
+
199
+ it('renders ExperimentTimeline bindings with preview-safe props', () => {
200
+ const wrapper = mount(ComponentBindingRenderer, {
201
+ props: {
202
+ binding: {
203
+ id: 'instrument-run:ExperimentTimeline',
204
+ component: 'ExperimentTimeline',
205
+ propsObject: {
206
+ modelValue: [{ id: 's001-run', name: 'S001', type: 'measurement', status: 'pending', order: 1 }],
207
+ editable: true,
208
+ },
209
+ },
210
+ dense: true,
211
+ },
212
+ global: globalOptions,
213
+ })
214
+
215
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ExperimentTimeline"]').exists()).toBe(true)
216
+ expect(wrapper.findComponent(ExperimentTimelineStub).props()).toMatchObject({
217
+ modelValue: [{ id: 's001-run', name: 'S001', type: 'measurement', status: 'pending', order: 1 }],
218
+ editable: false,
219
+ size: 'sm',
220
+ })
221
+ })
222
+
223
+ it('renders ScheduleCalendar bindings with preview-safe props', () => {
224
+ const events = [{ id: 's001-run', title: 'S001', start: '2024-01-01T08:00:00.000Z', end: '2024-01-01T08:10:00.000Z' }]
225
+ const wrapper = mount(ComponentBindingRenderer, {
226
+ props: {
227
+ binding: {
228
+ id: 'instrument-run:ScheduleCalendar',
229
+ component: 'ScheduleCalendar',
230
+ propsObject: {
231
+ modelValue: events[0].start,
232
+ events,
233
+ readonly: false,
234
+ showNavigation: true,
235
+ showViewToggle: true,
236
+ view: 'day',
237
+ },
238
+ },
239
+ dense: true,
240
+ },
241
+ global: globalOptions,
242
+ })
243
+
244
+ expect(wrapper.find('[data-component-binding-id="instrument-run:ScheduleCalendar"]').exists()).toBe(true)
245
+ expect(wrapper.findComponent(ScheduleCalendarStub).props()).toMatchObject({
246
+ modelValue: events[0].start,
247
+ events,
248
+ readonly: true,
249
+ showNavigation: false,
250
+ showViewToggle: false,
251
+ view: 'day',
252
+ })
253
+ })
254
+
255
+ it('renders SampleSelector bindings with preview-safe grouping props', () => {
256
+ const wrapper = mount(ComponentBindingRenderer, {
257
+ props: {
258
+ binding: {
259
+ id: 'assay-matrix:SampleSelector',
260
+ component: 'SampleSelector',
261
+ propsObject: {
262
+ samples: ['S001', 'S002'],
263
+ modelValue: [],
264
+ },
265
+ },
266
+ },
267
+ global: globalOptions,
268
+ })
269
+
270
+ expect(wrapper.find('[data-component-binding-id="assay-matrix:SampleSelector"]').exists()).toBe(true)
271
+ expect(wrapper.findComponent(SampleSelectorStub).props()).toMatchObject({
272
+ samples: ['S001', 'S002'],
273
+ modelValue: [],
274
+ enableGrouping: false,
275
+ enableSmartGroup: false,
276
+ })
277
+ })
161
278
  })
@@ -68,7 +68,7 @@ describe('useBioTemplatePackWorkspace', () => {
68
68
  expect(workspace.componentUsage.value?.template).toContain('<DoseCalculator')
69
69
  })
70
70
 
71
- it('exposes live AppSidebar, AppTopBar, and AppPillNav bindings for mixed-view packs', () => {
71
+ it('exposes live AppSidebar, AppTopBar, and pillNav bindings for mixed-view packs', () => {
72
72
  const workspace = useBioTemplatePackWorkspace('omics-assay')
73
73
  const inner = workspace.workspace.value
74
74
 
@@ -91,7 +91,7 @@ describe('useBioTemplatePresetWorkspace', () => {
91
91
  expect(workspace.activeControlView.value).toBe('analysis')
92
92
  })
93
93
 
94
- it('exposes live FormBuilder, AppSidebar, AppTopBar, and AppPillNav bindings', () => {
94
+ it('exposes live FormBuilder, AppSidebar, AppTopBar, and pillNav bindings', () => {
95
95
  const workspace = useBioTemplatePresetWorkspace('lcms-batch')
96
96
 
97
97
  workspace.form['onUpdate:modelValue']({
@@ -194,7 +194,7 @@ describe('useControlSchema', () => {
194
194
  ])
195
195
  })
196
196
 
197
- it('returns view helpers that can drive AppPillNav and AppSidebar together', () => {
197
+ it('returns view helpers that can drive AppTopBar pillNav and AppSidebar together', () => {
198
198
  expect(controlsToViewIds(controls)).toEqual(['analysis'])
199
199
  expect(controlsToViewItems(controls)).toEqual([{ id: 'analysis', label: 'Analysis' }])
200
200
  expect(getDefaultControlView(controls)).toBe('analysis')
@@ -54,6 +54,7 @@ import {
54
54
  toAssayMatrixColumns,
55
55
  toAssayMatrixDataFrame,
56
56
  toAssayMatrixRows,
57
+ toAssayMatrixSampleOptions,
57
58
  toCalibrationCurveDataFrame,
58
59
  toCalibrationCurveRows,
59
60
  toDoseConditions,
@@ -62,6 +63,8 @@ import {
62
63
  toFlowPanelRows,
63
64
  toInstrumentRunDataFrame,
64
65
  toInstrumentRunRows,
66
+ toInstrumentRunScheduleEvents,
67
+ toInstrumentRunSteps,
65
68
  toPlateMapEditorState,
66
69
  toProtocolDataFrame,
67
70
  toProtocolSteps,
@@ -87,6 +90,7 @@ import bioTemplateCatalogContract from '../../../../bio-template-catalog.contrac
87
90
  import bioTemplatePacksContract from '../../../../bio-template-packs.contract.json'
88
91
  import bioTemplatePresetsContract from '../../../../bio-template-presets.contract.json'
89
92
  import type {
93
+ AssayMatrixTemplate,
90
94
  DoseResponseTemplate,
91
95
  FlowCytometryPanelTemplate,
92
96
  InstrumentRunTemplate,
@@ -158,6 +162,9 @@ describe('bio data templates', () => {
158
162
  'flow-cytometry-panel',
159
163
  ])
160
164
  expect(getBioTemplatePackInfo('metabolomics')?.name).toBe('omics-assay')
165
+ expect(getBioTemplatePackInfo('metabolism')?.name).toBe('omics-assay')
166
+ expect(getBioTemplatePackInfo('metabolite profiling')?.name).toBe('omics-assay')
167
+ expect(getBioTemplatePackInfo('metabolomics')?.components).toContain('ExperimentTimeline')
161
168
  expect(getBioTemplatePackInfo('longitudinal')?.init_command).toBe('mint init --template longitudinal-study')
162
169
  expect(getBioTemplatePackInfo('gene expression')?.templates.at(-1)).toBe('qpcr-plate')
163
170
  expect(getBioTemplatePackInfo('gene-expression')?.templates.at(-1)).toBe('qpcr-plate')
@@ -214,6 +221,9 @@ describe('bio data templates', () => {
214
221
  'qpcr-plate',
215
222
  ])
216
223
  expect(searchBioTemplatePresets('run queue')[0].name).toBe('lcms-batch')
224
+ expect(getBioTemplatePresetInfo('metabolomics')?.name).toBe('lcms-batch')
225
+ expect(getBioTemplatePresetInfo('metabolism')?.name).toBe('lcms-batch')
226
+ expect(getBioTemplatePresetInfo('metabolite profiling')?.name).toBe('lcms-batch')
217
227
  expect(getBioTemplatePresetInfo('immunoassay')?.templates).toEqual([
218
228
  'plate-map',
219
229
  'sample-sheet',
@@ -288,6 +298,7 @@ describe('bio data templates', () => {
288
298
  const qpcrBindings = getBioTemplateComponentBindings('gene expression')
289
299
  const elisaBindings = getBioTemplateComponentBindings('elisa-assay')
290
300
  const flowBindings = getBioTemplateComponentBindings('flow-cytometry-assay')
301
+ const lcmsBindings = getBioTemplateComponentBindings('lcms-batch')
291
302
  const westernBindings = getBioTemplateComponentBindings('western-blot-assay')
292
303
  const wellplateImports = toBioTemplateComponentImports('wellplate-screen')
293
304
 
@@ -298,11 +309,14 @@ describe('bio data templates', () => {
298
309
  expect(qpcrBindings.map(binding => binding.component)).toContain('DataFrame')
299
310
  expect(elisaBindings.map(binding => binding.component)).toContain('PlateMapEditor')
300
311
  expect(elisaBindings.map(binding => binding.component)).toContain('DataFrame')
312
+ expect(lcmsBindings.map(binding => binding.component)).toContain('ScheduleCalendar')
313
+ expect(lcmsBindings.map(binding => binding.component)).toContain('ExperimentTimeline')
301
314
  expect(flowBindings.map(binding => binding.template_id)).toEqual([
302
315
  'sample-sheet',
303
316
  'sample-sheet',
304
317
  'flow-cytometry-panel',
305
318
  'assay-matrix',
319
+ 'assay-matrix',
306
320
  ])
307
321
  expect(westernBindings.map(binding => binding.component)).toContain('ReagentList')
308
322
  expect(westernBindings.map(binding => binding.component)).toContain('ExperimentTimeline')
@@ -412,6 +426,7 @@ describe('bio data templates', () => {
412
426
  expect(componentPropsById['plate-map:WellPlate'].wells).toBeDefined()
413
427
  expect(componentPropsById['calibration-curve:DataFrame'].data).toBeDefined()
414
428
  expect(componentPropsById['assay-matrix:DataFrame'].columns).toBeDefined()
429
+ expect(componentPropsById['assay-matrix:SampleSelector'].samples).toEqual(['Control', 'Treatment'])
415
430
  })
416
431
 
417
432
  it('generates Vue snippets for concrete template component props', () => {
@@ -556,6 +571,7 @@ describe('bio data templates', () => {
556
571
  expect(toInstrumentRunRows(lcmsTemplates['instrument-run'] as InstrumentRunTemplate)[0].kind).toBe('blank')
557
572
  expect(toInstrumentRunRows(lcmsTemplates['instrument-run'] as InstrumentRunTemplate)[2].sampleId).toBe('s001')
558
573
  expect(toTemplateDataFrame(lcmsTemplates['assay-matrix']).columns.map(column => column.key)).toContain('glucose')
574
+ expect(toAssayMatrixSampleOptions(lcmsTemplates['assay-matrix'] as AssayMatrixTemplate).map(option => option.label)).toEqual(['S001', 'S002'])
559
575
  expect(Object.keys(flowTemplates)).toEqual(['sample-sheet', 'flow-cytometry-panel', 'assay-matrix'])
560
576
  expect(flow.metadata?.preset).toBe('flow-cytometry-assay')
561
577
  expect(flowTemplates['flow-cytometry-panel'].data).toMatchObject({
@@ -921,6 +937,10 @@ describe('bio data templates', () => {
921
937
  const assayFrame = toAssayMatrixDataFrame(template)
922
938
  expect(assayFrame.rowKey).toBe('sampleId')
923
939
  expect(assayFrame.data[0].lactate).toBe(1.2)
940
+ expect(toAssayMatrixSampleOptions(template)).toEqual([
941
+ { value: 's001', label: 'S001', description: undefined },
942
+ { value: 's002', label: 'S002', description: undefined },
943
+ ])
924
944
  expect(toTemplateDataFrame(template).columns.map(column => column.key)).toContain('glucose')
925
945
  })
926
946
 
@@ -978,6 +998,11 @@ describe('bio data templates', () => {
978
998
  })
979
999
  const rows = toInstrumentRunRows(template)
980
1000
  const frame = toInstrumentRunDataFrame(template)
1001
+ const steps = toInstrumentRunSteps(template)
1002
+ const events = toInstrumentRunScheduleEvents(template)
1003
+ const componentProps = toBioTemplateComponentProps(template)
1004
+ const calendar = componentProps.find(binding => binding.component === 'ScheduleCalendar')
1005
+ const timeline = componentProps.find(binding => binding.component === 'ExperimentTimeline')
981
1006
 
982
1007
  expect(template.template_id).toBe('instrument-run')
983
1008
  expect(template.data.items.map(item => item.kind)).toEqual(['blank', 'qc', 'sample', 'sample', 'qc'])
@@ -987,6 +1012,25 @@ describe('bio data templates', () => {
987
1012
  expect(frame.rowKey).toBe('id')
988
1013
  expect(frame.columns.map(column => column.key)).toContain('status')
989
1014
  expect(toTemplateDataFrame(template).data[3].sampleId).toBe('s002')
1015
+ expect(steps[2]).toMatchObject({
1016
+ id: 's001-run',
1017
+ type: 'measurement',
1018
+ name: 'S001',
1019
+ status: 'pending',
1020
+ order: 3,
1021
+ })
1022
+ expect(steps[2].parameters?.method).toBe('Default method')
1023
+ expect(events[0]).toMatchObject({
1024
+ id: 'blank-start',
1025
+ title: 'Blank start',
1026
+ start: '2024-01-01T08:00:00.000Z',
1027
+ end: '2024-01-01T08:10:00.000Z',
1028
+ status: 'pending',
1029
+ })
1030
+ expect(events[2].title).toBe('S001')
1031
+ expect(calendar?.propsObject.modelValue).toBe(events[0].start)
1032
+ expect(calendar?.propsObject.events).toEqual(events)
1033
+ expect(timeline?.propsObject.modelValue).toEqual(steps)
990
1034
  })
991
1035
 
992
1036
  it('creates qPCR plate templates and adapts reactions to dataframe and wells', () => {
@@ -6,7 +6,8 @@
6
6
  * the `panels` config and rendered as CollapsibleCards. Controls can be
7
7
  * provided through named slots or auto-rendered from FormBuilder schemas.
8
8
  *
9
- * When the active view has no matching panels, the sidebar hides entirely.
9
+ * When activeView is omitted, the first non-empty panel view is selected.
10
+ * When no view has matching panels, the sidebar hides entirely.
10
11
  *
11
12
  * @example
12
13
  * ```vue
@@ -52,7 +53,7 @@ interface Props {
52
53
  variant?: 'default' | 'analysis'
53
54
  /** Map of view IDs to their tool sections */
54
55
  panels?: Record<string, SidebarToolSection[]>
55
- /** Which view's panels to display */
56
+ /** Which view's panels to display. Defaults to the first non-empty panel view. */
56
57
  activeView?: string
57
58
  /** Floating variant with absolute positioning. Defaults to false for analysis variant. */
58
59
  floating?: boolean
@@ -219,7 +220,6 @@ const resolvedValues = computed<Record<string, unknown>>(() => ({
219
220
  const resolvedActiveView = computed(() => {
220
221
  if (props.activeView) return props.activeView
221
222
  if (props.defaultView) return props.defaultView
222
- if (!resolvedControls.value) return ''
223
223
  return firstVisibleViewId(resolvedPanels.value)
224
224
  })
225
225
 
@@ -20,8 +20,8 @@ import ThemeToggle from './ThemeToggle.vue'
20
20
  import SettingsModal from './SettingsModal.vue'
21
21
  import ExperimentPopover from './ExperimentPopover.vue'
22
22
  import ExperimentSelectorModal from './ExperimentSelectorModal.vue'
23
- import AppPageSelectorInternal from './internal/AppPageSelectorInternal.vue'
24
- import AppPillNavInternal from './internal/AppPillNavInternal.vue'
23
+ import AppTopBarPageSelectorInternal from './internal/AppTopBarPageSelectorInternal.vue'
24
+ import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
25
25
  import AppAvatarMenu from './AppAvatarMenu.vue'
26
26
  import AppPluginSwitcher from './AppPluginSwitcher.vue'
27
27
  import PluginIcon from './PluginIcon.vue'
@@ -126,9 +126,9 @@ const profileInitial = computed(() => {
126
126
  })
127
127
 
128
128
  const hasPlatformPageSelector = computed(() =>
129
- props.pageSelector === undefined && !!plugin.value?.nav_items?.length,
129
+ props.pageSelector === undefined && (plugin.value?.nav_items?.length ?? 0) > 1,
130
130
  )
131
- const hasPageSelector = computed(() => !!props.pageSelector?.length || hasPlatformPageSelector.value)
131
+ const hasPageSelector = computed(() => (props.pageSelector?.length ?? 0) > 1 || hasPlatformPageSelector.value)
132
132
  const hasPluginSwitcher = computed(() => !!props.pluginSwitcher)
133
133
  const hasPillNav = computed(() => !!props.pillNav?.length)
134
134
  const hasAccountMenu = computed(() => !!props.accountMenu?.length)
@@ -295,7 +295,7 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
295
295
  @select="emit('plugin-switcher-select', $event)"
296
296
  @install-click="emit('plugin-switcher-install')"
297
297
  />
298
- <AppPageSelectorInternal
298
+ <AppTopBarPageSelectorInternal
299
299
  v-else-if="hasPageSelector"
300
300
  :pages="normalizedPageSelector"
301
301
  :current-page-id="effectiveCurrentPageSelectorId"
@@ -307,7 +307,7 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
307
307
  <template v-if="$slots['page-selector-item-icon']" #item-icon="slotProps">
308
308
  <slot name="page-selector-item-icon" v-bind="slotProps" />
309
309
  </template>
310
- </AppPageSelectorInternal>
310
+ </AppTopBarPageSelectorInternal>
311
311
 
312
312
  <!-- Left: title -->
313
313
  <div v-if="hasTitleGroup" class="mint-topbar-title-group">
@@ -323,7 +323,7 @@ function currentItemIdFromLocation(pages: Array<Pick<PageSelectorItem, 'id' | 't
323
323
  <!-- Center: pill nav (new) -->
324
324
  <div v-if="hasPillNav || $slots.center" class="mint-topbar__center">
325
325
  <slot name="center">
326
- <AppPillNavInternal
326
+ <AppTopBarPillNavInternal
327
327
  v-if="hasPillNav && pillNav"
328
328
  :items="normalizedPillNav"
329
329
  :current-item-id="currentPillId"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- /** Complete editable WellPlate/DoseCalculator workspace for dose design that rebuilds generated AppSidebar preset wiring when preset props load late. */
2
+ /** Complete editable biology template preset workspace for WellPlate/DoseCalculator, LC-MS, qPCR, generated controls, preview components, and current-experiment save wiring. */
3
3
  import { computed, effectScope, onScopeDispose, shallowRef, toRaw, unref, watch, type EffectScope } from 'vue'
4
4
  import type { BioTemplateControlValues, TemplatePresetId } from '../templates'
5
5
  import type {
@@ -11,7 +11,7 @@ import AlertBox from './AlertBox.vue'
11
11
  import AppSidebar from './AppSidebar.vue'
12
12
  import BaseButton from './BaseButton.vue'
13
13
  import BioTemplateRenderer from './BioTemplateRenderer.vue'
14
- import AppPillNavInternal from './internal/AppPillNavInternal.vue'
14
+ import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
15
15
 
16
16
  type BioTemplatePresetSidebarVariant = 'default' | 'analysis'
17
17
 
@@ -256,7 +256,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
256
256
  <p class="mint-bio-template-preset-workspace__sidebar-title">
257
257
  Controls
258
258
  </p>
259
- <AppPillNavInternal
259
+ <AppTopBarPillNavInternal
260
260
  v-if="resolvedWorkspace.pillNav.items.length > 1"
261
261
  class="mint-bio-template-preset-workspace__view-nav"
262
262
  :items="resolvedWorkspace.pillNav.items"