@morscherlab/mint-sdk 1.0.0-beta.3 → 1.0.0-beta.5

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 (181) hide show
  1. package/README.md +9 -2
  2. package/dist/__tests__/composables/experiment-utils.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useApi.test.d.ts +1 -0
  4. package/dist/components/AppContainer.vue.d.ts +1 -1
  5. package/dist/components/AppLayout.vue.d.ts +20 -1
  6. package/dist/components/AppSidebar.vue.d.ts +57 -5
  7. package/dist/components/AppTopBar.vue.d.ts +7 -25
  8. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +3 -1
  9. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -0
  10. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +5 -0
  11. package/dist/components/ComponentBindingRenderer.vue.d.ts +44 -0
  12. package/dist/components/ControlWorkspaceView.vue.d.ts +24 -7
  13. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +149 -0
  14. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  15. package/dist/components/FormBuilder.vue.d.ts +9 -9
  16. package/dist/components/PlateMapEditor.vue.d.ts +1 -1
  17. package/dist/components/PluginWorkspaceView.vue.d.ts +310 -0
  18. package/dist/components/SettingsModal.vue.d.ts +1 -1
  19. package/dist/components/WellPlate.vue.d.ts +2 -2
  20. package/dist/components/index.d.ts +3 -12
  21. package/dist/components/index.js +3 -3
  22. package/dist/components/{AppPageSelector.vue.d.ts → internal/AppTopBarPageSelectorInternal.vue.d.ts} +1 -1
  23. package/dist/components/{AppPillNav.vue.d.ts → internal/AppTopBarPillNavInternal.vue.d.ts} +3 -1
  24. package/dist/components/{CalendarGridPanel.vue.d.ts → internal/CalendarGridPanelInternal.vue.d.ts} +1 -1
  25. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  26. package/dist/components/{WellEditPopup.vue.d.ts → internal/WellEditPopupInternal.vue.d.ts} +1 -1
  27. package/dist/{components-D_Sr0adg.js → components-DihbSJjU.js} +5932 -5408
  28. package/dist/components-DihbSJjU.js.map +1 -0
  29. package/dist/composables/experiment-utils.d.ts +8 -0
  30. package/dist/composables/index.d.ts +5 -7
  31. package/dist/composables/index.js +4 -4
  32. package/dist/composables/useAppExperiment.d.ts +31 -2
  33. package/dist/composables/useBioTemplateComponents.d.ts +5 -3
  34. package/dist/composables/useBioTemplatePackWorkspace.d.ts +3 -2
  35. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +6 -5
  36. package/dist/composables/useBioTemplateWorkspace.d.ts +5 -4
  37. package/dist/composables/useControlSchema.d.ts +43 -21
  38. package/dist/composables/usePluginClient.d.ts +5 -2
  39. package/dist/{composables-C3dpXQN5.js → composables-BcgZ6diz.js} +40 -28
  40. package/dist/composables-BcgZ6diz.js.map +1 -0
  41. package/dist/index.d.ts +5 -12
  42. package/dist/index.js +5 -5
  43. package/dist/install.js +2 -2
  44. package/dist/styles.css +5637 -5663
  45. package/dist/templates/adapters.d.ts +7 -1
  46. package/dist/templates/catalog.d.ts +5 -5
  47. package/dist/templates/componentBindings.d.ts +13 -0
  48. package/dist/templates/index.d.ts +5 -5
  49. package/dist/templates/index.js +2 -2
  50. package/dist/templates/presets.d.ts +4 -4
  51. package/dist/templates/types.d.ts +4 -1
  52. package/dist/{templates-50NPjaxL.js → templates-Cyt0Suwf.js} +322 -73
  53. package/dist/templates-Cyt0Suwf.js.map +1 -0
  54. package/dist/types/components.d.ts +6 -25
  55. package/dist/types/index.d.ts +1 -1
  56. package/dist/{useScheduleDrag-D4oWdh41.js → useExperimentData-CM6Y0u5L.js} +400 -357
  57. package/dist/useExperimentData-CM6Y0u5L.js.map +1 -0
  58. package/package.json +1 -1
  59. package/src/__tests__/components/ActionItem.test.ts +6 -6
  60. package/src/__tests__/components/AppLayout.test.ts +44 -0
  61. package/src/__tests__/components/AppSidebar.test.ts +130 -2
  62. package/src/__tests__/components/AppToastContainer.test.ts +0 -11
  63. package/src/__tests__/components/AppTopBar.test.ts +189 -120
  64. package/src/__tests__/components/{AppPageSelector.test.ts → AppTopBarPageSelector.test.ts} +8 -8
  65. package/src/__tests__/components/{AppPillNav.test.ts → AppTopBarPillNav.test.ts} +53 -6
  66. package/src/__tests__/components/BioTemplateExperimentWorkspaceView.test.ts +7 -1
  67. package/src/__tests__/components/BioTemplatePackWorkspaceView.test.ts +32 -1
  68. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +48 -1
  69. package/src/__tests__/components/BioTemplateRenderer.test.ts +25 -0
  70. package/src/__tests__/components/CalendarGridPanel.test.ts +3 -3
  71. package/src/__tests__/components/ComponentBindingRenderer.test.ts +278 -0
  72. package/src/__tests__/components/ControlWorkspaceView.test.ts +134 -63
  73. package/src/__tests__/components/DateTimePicker.test.ts +2 -2
  74. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +185 -0
  75. package/src/__tests__/components/PluginWorkspaceView.test.ts +548 -0
  76. package/src/__tests__/composables/experiment-utils.test.ts +30 -0
  77. package/src/__tests__/composables/useApi.test.ts +30 -0
  78. package/src/__tests__/composables/useAppExperiment.test.ts +100 -1
  79. package/src/__tests__/composables/useBioTemplatePackWorkspace.test.ts +7 -4
  80. package/src/__tests__/composables/useBioTemplatePresetWorkspace.test.ts +7 -7
  81. package/src/__tests__/composables/useBioTemplateWorkspace.test.ts +6 -1
  82. package/src/__tests__/composables/useControlSchema.test.ts +151 -37
  83. package/src/__tests__/composables/usePluginClient.test.ts +99 -2
  84. package/src/__tests__/docs/frontendDocsCatalog.test.ts +120 -25
  85. package/src/__tests__/templates/templates.test.ts +56 -0
  86. package/src/components/AppAvatarMenu.vue +3 -3
  87. package/src/components/AppLayout.story.vue +39 -0
  88. package/src/components/AppLayout.vue +83 -2
  89. package/src/components/AppPluginSwitcher.vue +5 -5
  90. package/src/components/AppSidebar.story.vue +113 -5
  91. package/src/components/AppSidebar.vue +147 -27
  92. package/src/components/AppTopBar.story.vue +2 -5
  93. package/src/components/AppTopBar.vue +35 -425
  94. package/src/components/BioTemplateExperimentWorkspaceView.story.vue +2 -2
  95. package/src/components/BioTemplateExperimentWorkspaceView.vue +6 -0
  96. package/src/components/BioTemplatePackWorkspaceView.story.vue +4 -4
  97. package/src/components/BioTemplatePackWorkspaceView.vue +1 -0
  98. package/src/components/BioTemplatePresetWorkspaceView.story.vue +14 -2
  99. package/src/components/BioTemplatePresetWorkspaceView.vue +12 -3
  100. package/src/components/BioTemplateRenderer.story.vue +2 -2
  101. package/src/components/BioTemplateRenderer.vue +15 -227
  102. package/src/components/ComponentBindingRenderer.story.vue +87 -0
  103. package/src/components/ComponentBindingRenderer.vue +317 -0
  104. package/src/components/ControlWorkspaceView.story.vue +20 -9
  105. package/src/components/ControlWorkspaceView.vue +43 -12
  106. package/src/components/DatePicker.vue +2 -2
  107. package/src/components/DateTimePicker.vue +2 -2
  108. package/src/components/DoseDesignWorkspaceView.story.vue +77 -0
  109. package/src/components/DoseDesignWorkspaceView.vue +255 -0
  110. package/src/components/ExperimentPopover.story.vue +2 -2
  111. package/src/components/ExperimentPopover.vue +2 -6
  112. package/src/components/ExperimentSelectorModal.vue +6 -5
  113. package/src/components/FormBuilder.story.vue +190 -0
  114. package/src/components/PluginWorkspaceView.story.vue +334 -0
  115. package/src/components/PluginWorkspaceView.vue +708 -0
  116. package/src/components/SettingsModal.story.vue +87 -0
  117. package/src/components/WellPlate.vue +2 -2
  118. package/src/components/index.ts +3 -12
  119. package/src/components/{AppPageSelector.vue → internal/AppTopBarPageSelectorInternal.vue} +9 -9
  120. package/src/components/internal/AppTopBarPillNavInternal.vue +194 -0
  121. package/src/components/{CalendarGridPanel.vue → internal/CalendarGridPanelInternal.vue} +1 -1
  122. package/src/components/{WellEditPopup.vue → internal/WellEditPopupInternal.vue} +3 -3
  123. package/src/composables/experiment-utils.ts +26 -0
  124. package/src/composables/index.ts +21 -7
  125. package/src/composables/useApi.ts +9 -2
  126. package/src/composables/useAppExperiment.ts +85 -13
  127. package/src/composables/useBioTemplateComponents.ts +12 -0
  128. package/src/composables/useBioTemplatePackWorkspace.ts +6 -2
  129. package/src/composables/useBioTemplatePresetWorkspace.ts +10 -21
  130. package/src/composables/useBioTemplateWorkspace.ts +6 -4
  131. package/src/composables/useControlSchema.ts +157 -69
  132. package/src/composables/usePluginClient.ts +50 -9
  133. package/src/index.ts +6 -563
  134. package/src/styles/components/app-layout.css +82 -0
  135. package/src/styles/components/app-page-selector.css +1 -1
  136. package/src/styles/components/app-pill-nav.css +71 -1
  137. package/src/styles/components/app-sidebar.css +119 -0
  138. package/src/styles/components/app-top-bar.css +0 -235
  139. package/src/styles/components/experiment-popover.css +2 -2
  140. package/src/styles/index.css +0 -1
  141. package/src/templates/adapters.ts +193 -0
  142. package/src/templates/catalog.ts +5 -5
  143. package/src/templates/componentBindings.ts +90 -3
  144. package/src/templates/index.ts +10 -0
  145. package/src/templates/packs.ts +10 -1
  146. package/src/templates/presets.ts +14 -4
  147. package/src/templates/types.ts +4 -0
  148. package/src/types/components.ts +6 -31
  149. package/src/types/index.ts +2 -6
  150. package/dist/__tests__/composables/usePluginApi.test.d.ts +0 -13
  151. package/dist/components/FormFieldRenderer.vue.d.ts +0 -28
  152. package/dist/components/FormSection.vue.d.ts +0 -30
  153. package/dist/components/GroupingModal.vue.d.ts +0 -12
  154. package/dist/components/SettingsButton.vue.d.ts +0 -30
  155. package/dist/components/ToastNotification.vue.d.ts +0 -2
  156. package/dist/components-D_Sr0adg.js.map +0 -1
  157. package/dist/composables/usePluginApi.d.ts +0 -22
  158. package/dist/composables-C3dpXQN5.js.map +0 -1
  159. package/dist/templates-50NPjaxL.js.map +0 -1
  160. package/dist/useScheduleDrag-D4oWdh41.js.map +0 -1
  161. package/src/__tests__/components/FormCompatibility.test.ts +0 -94
  162. package/src/__tests__/components/GroupingModal.test.ts +0 -73
  163. package/src/__tests__/components/SettingsButton.test.ts +0 -44
  164. package/src/__tests__/composables/usePluginApi.test.ts +0 -81
  165. package/src/components/AppPillNav.vue +0 -71
  166. package/src/components/FormFieldRenderer.vue +0 -35
  167. package/src/components/FormSection.vue +0 -37
  168. package/src/components/GroupingModal.story.vue +0 -52
  169. package/src/components/GroupingModal.vue +0 -61
  170. package/src/components/SettingsButton.story.vue +0 -58
  171. package/src/components/SettingsButton.vue +0 -64
  172. package/src/components/ToastNotification.vue +0 -9
  173. package/src/composables/usePluginApi.ts +0 -32
  174. package/src/styles/components/settings-button.css +0 -31
  175. /package/dist/__tests__/components/{AppPageSelector.test.d.ts → AppTopBarPageSelector.test.d.ts} +0 -0
  176. /package/dist/__tests__/components/{AppPillNav.test.d.ts → AppTopBarPillNav.test.d.ts} +0 -0
  177. /package/dist/__tests__/components/{FormCompatibility.test.d.ts → ComponentBindingRenderer.test.d.ts} +0 -0
  178. /package/dist/__tests__/components/{GroupingModal.test.d.ts → DoseDesignWorkspaceView.test.d.ts} +0 -0
  179. /package/dist/__tests__/components/{SettingsButton.test.d.ts → PluginWorkspaceView.test.d.ts} +0 -0
  180. /package/dist/components/{ActionItem.vue.d.ts → internal/ActionItemInternal.vue.d.ts} +0 -0
  181. /package/src/components/{ActionItem.vue → internal/ActionItemInternal.vue} +0 -0
@@ -3,8 +3,11 @@ import { mount } from '@vue/test-utils'
3
3
  import { computed, ref } from 'vue'
4
4
  import { createPinia } from 'pinia'
5
5
  import AppTopBar from '../../components/AppTopBar.vue'
6
+ import ExperimentPopover from '../../components/ExperimentPopover.vue'
7
+ import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
6
8
  import { usePlatformContext } from '../../composables/usePlatformContext'
7
- import { controlsToTopBarTabs, defineControlModel, defineControls } from '../../composables/useControlSchema'
9
+ import { APP_EXPERIMENT_KEY, type AppExperimentState } from '../../composables/useAppExperiment'
10
+ import { defineControlModel, defineControls } from '../../composables/useControlSchema'
8
11
  import ThemeToggle from '../../components/ThemeToggle.vue'
9
12
  import SettingsModal from '../../components/SettingsModal.vue'
10
13
  import type { TopBarSettingsConfig } from '../../types/components'
@@ -43,7 +46,15 @@ function createWrapper(props = {}, slots = {}) {
43
46
  plugins: [pinia],
44
47
  stubs: {
45
48
  'router-link': {
46
- template: '<a><slot /></a>',
49
+ props: ['to', 'custom'],
50
+ template: `
51
+ <slot
52
+ v-if="custom"
53
+ :href="typeof to === 'string' ? to : '#'"
54
+ :navigate="() => {}"
55
+ />
56
+ <a v-else :href="typeof to === 'string' ? to : '#'"><slot /></a>
57
+ `,
47
58
  },
48
59
  BaseModal: {
49
60
  template: '<div><slot /></div>',
@@ -525,6 +536,86 @@ describe('AppTopBar', () => {
525
536
  })
526
537
 
527
538
  describe('combined features', () => {
539
+ it('passes injected app experiment bindings to the popover and selector modal', () => {
540
+ vi.mocked(usePlatformContext).mockReturnValueOnce({
541
+ isIntegrated: computed(() => true),
542
+ context: ref({ isIntegrated: true, theme: 'system' }),
543
+ plugin: computed(() => undefined),
544
+ user: computed(() => undefined),
545
+ theme: computed(() => 'system' as const),
546
+ features: computed(() => undefined),
547
+ navigate: vi.fn(),
548
+ notify: vi.fn(),
549
+ sendToPlatform: vi.fn(),
550
+ })
551
+ const appExperiment: AppExperimentState = {
552
+ experimentName: ref('Dose response'),
553
+ experimentCode: ref('EXP-012'),
554
+ experimentStatus: ref('ongoing'),
555
+ experimentId: ref(12),
556
+ showModal: ref(false),
557
+ saveLoading: ref(false),
558
+ saveSuccessMessage: ref('Saved'),
559
+ showSave: ref(true),
560
+ showDetach: computed(() => true),
561
+ saveDisabled: computed(() => true),
562
+ saveDisabledMessage: computed(() => 'Locked'),
563
+ popover: computed(() => ({
564
+ experimentName: 'Dose response',
565
+ experimentCode: 'EXP-012',
566
+ experimentStatus: 'ongoing',
567
+ showSave: true,
568
+ showDetach: true,
569
+ saveDisabled: true,
570
+ saveDisabledMessage: 'Locked',
571
+ saveLoading: false,
572
+ saveSuccessMessage: 'Saved',
573
+ })),
574
+ selectorModal: computed(() => ({
575
+ modelValue: false,
576
+ currentExperimentId: 12,
577
+ })),
578
+ openModal: vi.fn(),
579
+ closeModal: vi.fn(),
580
+ handleSelect: vi.fn(),
581
+ handleSave: vi.fn(),
582
+ handleDetach: vi.fn(),
583
+ }
584
+ const wrapper = mount(AppTopBar, {
585
+ props: { title: 'Test App' },
586
+ global: {
587
+ plugins: [createPinia()],
588
+ provide: {
589
+ [APP_EXPERIMENT_KEY as symbol]: appExperiment,
590
+ },
591
+ stubs: {
592
+ 'router-link': {
593
+ template: '<a><slot /></a>',
594
+ },
595
+ BaseModal: {
596
+ template: '<div><slot /></div>',
597
+ },
598
+ },
599
+ },
600
+ })
601
+
602
+ expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({
603
+ experimentName: 'Dose response',
604
+ experimentCode: 'EXP-012',
605
+ experimentStatus: 'ongoing',
606
+ showSave: true,
607
+ showDetach: true,
608
+ saveDisabled: true,
609
+ saveDisabledMessage: 'Locked',
610
+ saveLoading: false,
611
+ saveSuccessMessage: 'Saved',
612
+ })
613
+ expect(wrapper.findComponent(ExperimentSelectorModal).props()).toMatchObject({
614
+ modelValue: false,
615
+ currentExperimentId: 12,
616
+ })
617
+ })
618
+
528
619
  it('should render ThemeToggle and settings button together', () => {
529
620
  const wrapper = createWrapper({
530
621
  title: 'Test App',
@@ -591,8 +682,8 @@ describe('AppTopBar', () => {
591
682
  })
592
683
  })
593
684
 
594
- describe('classic prop surface', () => {
595
- it('should not break existing usage without new props', () => {
685
+ describe('navigation prop surface', () => {
686
+ it('renders a title-only layout without navigation props', () => {
596
687
  const wrapper = createWrapper({
597
688
  title: 'Classic App',
598
689
  subtitle: 'Old API',
@@ -605,120 +696,113 @@ describe('AppTopBar', () => {
605
696
  expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
606
697
  })
607
698
 
608
- it('should work with existing pages and tabs props', () => {
699
+ it('composes page selector, pill nav, theme, and settings controls', () => {
609
700
  const wrapper = createWrapper({
610
- pluginName: 'Test Plugin',
611
701
  title: 'Dashboard',
612
- pages: [
702
+ pageSelector: [
613
703
  { id: 'dashboard', label: 'Dashboard' },
614
704
  { id: 'settings', label: 'Settings' },
615
705
  ],
616
- tabs: [
706
+ currentPageSelectorId: 'dashboard',
707
+ pillNav: [
617
708
  { id: 'overview', label: 'Overview' },
618
709
  { id: 'details', label: 'Details' },
619
710
  ],
711
+ currentPillId: 'overview',
620
712
  showThemeToggle: true,
621
713
  showSettings: true,
622
714
  })
623
715
 
624
- expect(wrapper.text()).toContain('Test Plugin')
625
- expect(wrapper.text()).toContain('Dashboard')
716
+ expect(wrapper.find('.mint-page-selector__label').text()).toBe('Dashboard')
717
+ expect(wrapper.findAll('.mint-pill-nav__item').map(item => item.text())).toEqual([
718
+ 'Overview',
719
+ 'Details',
720
+ ])
721
+ expect(wrapper.findAll('.mint-pill-nav__item')[0].classes()).toContain('mint-pill-nav__item--active')
626
722
  expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
627
723
  expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
628
724
  })
629
725
 
630
- it('renders tabs generated from a compact control schema', async () => {
726
+ it('renders pill nav items with icon metadata', async () => {
631
727
  const runIcon = 'M8 5v14l11-7z'
632
- const controls = defineControls({
633
- threshold: {
634
- default: 0.05,
635
- section: 'parameters',
636
- view: 'analysis',
637
- },
638
- chartScale: {
639
- default: 'linear',
640
- section: 'display',
641
- view: 'results',
642
- },
643
- })
644
728
  const wrapper = createWrapper({
645
- tabs: controlsToTopBarTabs(controls, {
646
- views: {
647
- analysis: { label: 'Run', icon: runIcon },
648
- results: { label: 'Results' },
649
- },
650
- }),
651
- currentTabId: 'analysis',
729
+ pillNav: [
730
+ { id: 'analysis', label: 'Run', icon: runIcon },
731
+ { id: 'results', label: 'Results' },
732
+ ],
733
+ currentPillId: 'analysis',
652
734
  })
653
735
 
654
- const tabs = wrapper.findAll('.mint-topbar-tab')
655
- expect(tabs.map(tab => tab.text())).toEqual(['Run', 'Results'])
656
- expect(tabs[0].classes()).toContain('mint-topbar-tab--active')
657
- expect(tabs[0].get('.mint-topbar-tab-icon path').attributes('d')).toBe(runIcon)
658
- expect(tabs[1].find('.mint-topbar-tab-icon').exists()).toBe(false)
736
+ const pills = wrapper.findAll('.mint-pill-nav__item')
737
+ expect(pills.map(pill => pill.text())).toEqual(['Run', 'Results'])
738
+ expect(pills[0].classes()).toContain('mint-pill-nav__item--active')
739
+ expect(pills[0].get('.mint-pill-nav__icon path').attributes('d')).toBe(runIcon)
740
+ expect(pills[1].find('.mint-pill-nav__icon').exists()).toBe(false)
659
741
 
660
- await tabs[1].trigger('click')
661
- expect(wrapper.emitted('tab-select')?.[0][0]).toMatchObject({ id: 'results', label: 'Results' })
742
+ await pills[1].trigger('click')
743
+ expect(wrapper.emitted('pill-select')?.[0][0]).toMatchObject({ id: 'results', label: 'Results' })
662
744
  })
663
745
 
664
- it('uses shared dropdown state for the legacy page menu', async () => {
746
+ it('uses shared dropdown state for the page selector menu', async () => {
665
747
  const wrapper = createWrapper({
666
- pluginName: 'Test Plugin',
667
- pages: [
748
+ pageSelector: [
668
749
  { id: 'dashboard', label: 'Dashboard' },
669
750
  { id: 'settings', label: 'Settings' },
670
751
  ],
752
+ currentPageSelectorId: 'dashboard',
671
753
  })
672
- const dropdown = () => wrapper.get('.mint-topbar-dropdown')
754
+ const dropdown = () => wrapper.find('.mint-page-selector__menu')
673
755
 
674
- expect(dropdown().attributes('style')).toContain('display: none')
756
+ expect(dropdown().exists()).toBe(false)
675
757
 
676
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
677
- expect(dropdown().attributes('style') ?? '').not.toContain('display: none')
758
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
759
+ expect(dropdown().exists()).toBe(true)
678
760
 
679
761
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
680
762
  await wrapper.vm.$nextTick()
681
- expect(dropdown().attributes('style')).toContain('display: none')
763
+ expect(dropdown().exists()).toBe(false)
682
764
 
683
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
684
- expect(dropdown().attributes('style') ?? '').not.toContain('display: none')
765
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
766
+ expect(dropdown().exists()).toBe(true)
685
767
 
686
768
  document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
687
769
  await wrapper.vm.$nextTick()
688
- expect(dropdown().attributes('style')).toContain('display: none')
770
+ expect(dropdown().exists()).toBe(false)
689
771
 
690
772
  wrapper.unmount()
691
773
  })
692
774
 
693
- it('renders PluginIcon-compatible metadata icons in the legacy page menu', async () => {
775
+ it('renders PluginIcon-compatible metadata icons in the page selector menu', async () => {
694
776
  const iconPath = 'M4 19h16M7 16V8m5 8V4m5 12v-6'
695
777
  const pngIcon = 'data:image/png;base64,iVBORw0KGgo='
696
778
  const wrapper = createWrapper({
697
- pluginName: 'Test Plugin',
698
- pages: [
699
- { id: 'dashboard', label: 'Dashboard', to: '/', icon: iconPath, description: 'Overview' },
779
+ pageSelector: [
780
+ { id: 'dashboard', label: 'Dashboard', to: '/', icon: iconPath, hint: 'Overview' },
700
781
  { id: 'image', label: 'Image', to: '/image', icon: pngIcon },
701
- { id: 'legacy', label: 'Legacy', icon: 'home' },
782
+ { id: 'text', label: 'Text', icon: 'home' },
702
783
  ],
703
- currentPageId: 'dashboard',
784
+ currentPageSelectorId: 'dashboard',
704
785
  })
705
786
 
706
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
787
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
707
788
 
708
- const icons = wrapper.findAllComponents({ name: 'PluginIcon' })
789
+ const icons = wrapper
790
+ .findAllComponents({ name: 'PluginIcon' })
791
+ .filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
709
792
  expect(icons).toHaveLength(2)
710
793
  expect(icons[0].props('icon')).toBe(iconPath)
711
794
  expect(icons[1].props('icon')).toBe(pngIcon)
712
795
  expect(icons.every((icon) => icon.props('variant') === 'tinted')).toBe(true)
713
796
 
714
797
  const pathIcons = wrapper.findAll('.mint-plugin-icon__svg')
715
- expect(pathIcons).toHaveLength(1)
716
- expect(pathIcons[0].find('path').attributes('d')).toBe(iconPath)
798
+ expect(pathIcons.map(icon => icon.find('path').attributes('d'))).toEqual(
799
+ expect.arrayContaining([iconPath]),
800
+ )
717
801
 
718
802
  const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
719
803
  expect(imageIcons).toHaveLength(1)
720
804
  expect(imageIcons[0].attributes('src')).toBe(pngIcon)
721
- expect(wrapper.find('.mint-topbar-dropdown-item__description').text()).toBe('Overview')
805
+ expect(wrapper.find('.mint-page-selector__item-hint').text()).toBe('Overview')
722
806
  })
723
807
  })
724
808
 
@@ -926,7 +1010,7 @@ describe('AppTopBar', () => {
926
1010
  expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
927
1011
  })
928
1012
 
929
- it('uses platform plugin nav metadata when pluginName and pages are omitted', async () => {
1013
+ it('uses platform plugin nav metadata as the page selector when pageSelector is omitted', async () => {
930
1014
  const previousPath = window.location.pathname
931
1015
  window.history.pushState({}, '', '/test/analysis')
932
1016
  try {
@@ -949,25 +1033,28 @@ describe('AppTopBar', () => {
949
1033
  })
950
1034
 
951
1035
  const wrapper = createWrapper()
952
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1036
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
953
1037
 
954
- expect(wrapper.get('.mint-topbar-plugin-name').text()).toContain('Dose Designer')
955
- expect(wrapper.get('.mint-topbar-current-page').text()).toBe('Analysis')
956
- expect(wrapper.findAll('.mint-topbar-dropdown-item__label').map(item => item.text())).toEqual([
1038
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Analysis')
1039
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual([
957
1040
  'Dashboard',
958
1041
  'Analysis',
959
1042
  ])
1043
+ expect(wrapper.findAll('.mint-page-selector__item-hint').map(item => item.text())).toEqual([
1044
+ 'Dose Designer',
1045
+ 'Dose Designer',
1046
+ ])
960
1047
  const dropdownIcons = wrapper
961
1048
  .findAllComponents({ name: 'PluginIcon' })
962
- .filter(icon => icon.classes().includes('mint-topbar-dropdown-item__icon'))
1049
+ .filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
963
1050
  expect(dropdownIcons.map(icon => icon.props('icon'))).toEqual([pluginIcon, analysisIcon])
964
- expect(wrapper.findAll('.mint-topbar-dropdown-item')[1].classes()).toContain('mint-topbar-dropdown-item--active')
1051
+ expect(wrapper.findAll('.mint-page-selector__item')[1].classes()).toContain('mint-page-selector__item--active')
965
1052
  } finally {
966
1053
  window.history.pushState({}, '', previousPath)
967
1054
  }
968
1055
  })
969
1056
 
970
- it('keeps explicit legacy pages ahead of platform plugin nav metadata', async () => {
1057
+ it('keeps explicit pageSelector ahead of platform plugin nav metadata', async () => {
971
1058
  mockPlatformCtx({
972
1059
  isIntegrated: true,
973
1060
  plugin: {
@@ -976,18 +1063,24 @@ describe('AppTopBar', () => {
976
1063
  version: '1.0',
977
1064
  route_prefix: '/test',
978
1065
  api_prefix: '/api/test',
979
- nav_items: [{ path: '/stale', label: 'Stale' }],
1066
+ nav_items: [
1067
+ { path: '/stale', label: 'Stale' },
1068
+ { path: '/stale-two', label: 'Stale Two' },
1069
+ ],
980
1070
  },
981
1071
  })
982
1072
 
983
1073
  const wrapper = createWrapper({
984
- pluginName: 'Explicit Plugin',
985
- pages: [{ id: 'overview', label: 'Overview', to: '/' }],
1074
+ pageSelector: [
1075
+ { id: 'overview', label: 'Overview', to: '/' },
1076
+ { id: 'reports', label: 'Reports', to: '/reports' },
1077
+ ],
1078
+ currentPageSelectorId: 'overview',
986
1079
  })
987
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1080
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
988
1081
 
989
- expect(wrapper.get('.mint-topbar-plugin-name').text()).toContain('Explicit Plugin')
990
- expect(wrapper.findAll('.mint-topbar-dropdown-item__label').map(item => item.text())).toEqual(['Overview'])
1082
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
1083
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual(['Overview', 'Reports'])
991
1084
  })
992
1085
  })
993
1086
 
@@ -1026,59 +1119,35 @@ describe('AppTopBar', () => {
1026
1119
  ])
1027
1120
  })
1028
1121
 
1029
- it('accepts string shorthand for legacy pages', async () => {
1030
- const wrapper = createWrapper({
1031
- pluginName: 'Analysis',
1032
- currentPageId: 'Overview',
1033
- pages: ['Overview', 'Settings'],
1034
- })
1035
-
1036
- await wrapper.get('.mint-topbar-plugin-name').trigger('click')
1037
- await wrapper.findAll('.mint-topbar-dropdown-item')[1].trigger('click')
1038
-
1039
- expect(wrapper.emitted('page-select')).toEqual([
1040
- [{ id: 'Settings', label: 'Settings' }],
1041
- ])
1042
- })
1043
-
1044
- it('accepts string shorthand for legacy tabs', async () => {
1045
- const wrapper = createWrapper({
1046
- currentTabId: 'Run',
1047
- tabs: ['Run', 'Results'],
1048
- })
1049
-
1050
- expect(wrapper.findAll('.mint-topbar-tab').map(tab => tab.text())).toEqual([
1051
- 'Run',
1052
- 'Results',
1053
- ])
1054
-
1055
- await wrapper.findAll('.mint-topbar-tab')[1].trigger('click')
1056
-
1057
- expect(wrapper.emitted('tab-select')).toEqual([
1058
- [{ id: 'Results', label: 'Results' }],
1059
- ])
1060
- })
1061
-
1062
- it('accepts string shorthand for legacy tab dropdown options', async () => {
1122
+ it('emits pill-option-select for pillNav dropdown children', async () => {
1063
1123
  const wrapper = createWrapper({
1064
- currentTabId: 'Summary',
1065
- tabs: [
1066
- { id: 'analysis', label: 'Analysis', children: ['Summary', 'Details'] },
1124
+ currentPillId: 'pca',
1125
+ pillNav: [
1126
+ { id: 'charts', label: 'Charts' },
1127
+ {
1128
+ id: 'visualize',
1129
+ label: 'Visualize',
1130
+ children: [
1131
+ { id: 'pca', label: 'PCA', description: 'Principal component analysis' },
1132
+ { id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
1133
+ ],
1134
+ },
1067
1135
  ],
1068
1136
  })
1069
1137
 
1070
- await wrapper.get('.mint-topbar-tab').trigger('click')
1071
- expect(wrapper.findAll('.mint-topbar-dropdown-item').map(item => item.text())).toEqual([
1072
- 'Summary',
1073
- 'Details',
1074
- ])
1075
-
1076
- await wrapper.findAll('.mint-topbar-dropdown-item')[1].trigger('click')
1138
+ await wrapper.findAll('.mint-pill-nav__item')[1].trigger('click')
1139
+ await wrapper.findAll('.mint-pill-nav__dropdown-item')[1].trigger('click')
1077
1140
 
1078
- expect(wrapper.emitted('tab-option-select')?.[0]?.[0]).toEqual({
1079
- id: 'Details',
1080
- label: 'Details',
1141
+ expect(wrapper.emitted('pill-option-select')?.[0]?.[0]).toEqual({
1142
+ id: 'heatmap',
1143
+ label: 'Heatmap',
1144
+ description: 'Metabolite heatmap',
1145
+ })
1146
+ expect(wrapper.emitted('pill-option-select')?.[0]?.[1]).toMatchObject({
1147
+ id: 'visualize',
1148
+ label: 'Visualize',
1081
1149
  })
1082
1150
  })
1151
+
1083
1152
  })
1084
1153
  })
@@ -1,10 +1,10 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
- import AppPageSelector from '../../components/AppPageSelector.vue'
3
+ import AppTopBarPageSelectorInternal from '../../components/internal/AppTopBarPageSelectorInternal.vue'
4
4
 
5
- describe('AppPageSelector', () => {
5
+ describe('AppTopBarPageSelectorInternal', () => {
6
6
  it('accepts string shorthand pages', async () => {
7
- const wrapper = mount(AppPageSelector, {
7
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
8
8
  props: {
9
9
  currentPageId: 'Workspace',
10
10
  pages: ['Workspace', 'Results'],
@@ -25,7 +25,7 @@ describe('AppPageSelector', () => {
25
25
  })
26
26
 
27
27
  it('closes linked page actions without emitting select', async () => {
28
- const wrapper = mount(AppPageSelector, {
28
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
29
29
  props: {
30
30
  currentPageId: 'workspace',
31
31
  pages: [
@@ -43,7 +43,7 @@ describe('AppPageSelector', () => {
43
43
  })
44
44
 
45
45
  it('keeps disabled linked page actions inert', async () => {
46
- const wrapper = mount(AppPageSelector, {
46
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
47
47
  props: {
48
48
  currentPageId: 'workspace',
49
49
  pages: [
@@ -66,7 +66,7 @@ describe('AppPageSelector', () => {
66
66
 
67
67
  it('emits select for button page actions', async () => {
68
68
  const page = { id: 'workspace', label: 'Workspace' }
69
- const wrapper = mount(AppPageSelector, {
69
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
70
70
  props: {
71
71
  pages: [page],
72
72
  },
@@ -81,7 +81,7 @@ describe('AppPageSelector', () => {
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(AppPageSelector, {
84
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
85
85
  props: {
86
86
  pages: [
87
87
  { id: 'analysis', label: 'Analysis', icon: iconPath },
@@ -117,7 +117,7 @@ describe('AppPageSelector', () => {
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(AppPageSelector, {
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 AppPillNav from '../../components/AppPillNav.vue'
3
+ import AppTopBarPillNavInternal from '../../components/internal/AppTopBarPillNavInternal.vue'
4
4
 
5
- describe('AppPillNav', () => {
5
+ describe('AppTopBarPillNavInternal', () => {
6
6
  it('accepts string shorthand items', async () => {
7
- const wrapper = mount(AppPillNav, {
7
+ const wrapper = mount(AppTopBarPillNavInternal, {
8
8
  props: {
9
9
  currentItemId: 'Overview',
10
10
  items: ['Overview', 'Analysis'],
@@ -22,7 +22,7 @@ describe('AppPillNav', () => {
22
22
  })
23
23
 
24
24
  it('emits select for button items but not linked items', async () => {
25
- const wrapper = mount(AppPillNav, {
25
+ const wrapper = mount(AppTopBarPillNavInternal, {
26
26
  props: {
27
27
  currentItemId: 'overview',
28
28
  items: [
@@ -40,7 +40,7 @@ describe('AppPillNav', () => {
40
40
 
41
41
  it('renders SVG icons from item metadata', () => {
42
42
  const icon = ['M4 12h16', 'M12 4v16']
43
- const wrapper = mount(AppPillNav, {
43
+ const wrapper = mount(AppTopBarPillNavInternal, {
44
44
  props: {
45
45
  items: [
46
46
  { id: 'run', label: 'Run', icon },
@@ -59,7 +59,7 @@ describe('AppPillNav', () => {
59
59
  })
60
60
 
61
61
  it('prevents disabled link navigation and selection', async () => {
62
- const wrapper = mount(AppPillNav, {
62
+ const wrapper = mount(AppTopBarPillNavInternal, {
63
63
  props: {
64
64
  items: [
65
65
  { id: 'docs', label: 'Docs', href: '/docs', disabled: true },
@@ -75,4 +75,51 @@ describe('AppPillNav', () => {
75
75
 
76
76
  expect(wrapper.emitted('select')).toBeUndefined()
77
77
  })
78
+
79
+ it('supports dropdown child items for grouped pill navigation', async () => {
80
+ const wrapper = mount(AppTopBarPillNavInternal, {
81
+ props: {
82
+ currentItemId: 'pca',
83
+ items: [
84
+ { id: 'charts', label: 'Charts' },
85
+ {
86
+ id: 'visualize',
87
+ label: 'Visualize',
88
+ children: [
89
+ { id: 'pca', label: 'PCA', description: 'Principal component analysis' },
90
+ { id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
91
+ ],
92
+ },
93
+ ],
94
+ },
95
+ })
96
+
97
+ const visualizeButton = wrapper.findAll('.mint-pill-nav__item')[1]
98
+ expect(visualizeButton.classes()).toContain('mint-pill-nav__item--active')
99
+ expect(wrapper.find('.mint-pill-nav__dropdown').exists()).toBe(false)
100
+
101
+ await visualizeButton.trigger('click')
102
+
103
+ expect(wrapper.find('.mint-pill-nav__dropdown').exists()).toBe(true)
104
+ expect(wrapper.findAll('.mint-pill-nav__dropdown-item').map(item => item.text())).toEqual([
105
+ 'PCAPrincipal component analysis',
106
+ 'HeatmapMetabolite heatmap',
107
+ ])
108
+
109
+ await wrapper.findAll('.mint-pill-nav__dropdown-item')[1].trigger('click')
110
+
111
+ expect(wrapper.emitted('option-select')).toEqual([
112
+ [
113
+ { id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
114
+ {
115
+ id: 'visualize',
116
+ label: 'Visualize',
117
+ children: [
118
+ { id: 'pca', label: 'PCA', description: 'Principal component analysis' },
119
+ { id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
120
+ ],
121
+ },
122
+ ],
123
+ ])
124
+ })
78
125
  })
@@ -118,12 +118,15 @@ describe('BioTemplateExperimentWorkspaceView', () => {
118
118
  kind: 'collection',
119
119
  },
120
120
  slots: {
121
- default: ({ bindings, componentPropsById, getComponentProps }) => h(
121
+ default: ({ bindings, componentBindingsById, componentPropsById, getComponentProps }) => h(
122
122
  'pre',
123
123
  { class: 'component-props' },
124
124
  JSON.stringify({
125
125
  doseMode: componentPropsById['dose-response:DoseCalculator'].mode,
126
+ bindingComponent: componentBindingsById['dose-response:DoseCalculator'].component,
127
+ bindingMode: componentBindingsById['dose-response:DoseCalculator'].props.mode,
126
128
  bindingDoseMode: bindings.componentPropsById['dose-response:DoseCalculator'].mode,
129
+ nestedBindingMode: bindings.componentBindingsById['dose-response:DoseCalculator'].props.mode,
127
130
  hasPlateWells: Boolean(componentPropsById['plate-map:WellPlate'].wells),
128
131
  doseModeFromGetter: getComponentProps('DoseCalculator')?.mode,
129
132
  bindingDoseModeFromGetter: bindings.getComponentProps('DoseCalculator')?.mode,
@@ -143,7 +146,10 @@ describe('BioTemplateExperimentWorkspaceView', () => {
143
146
 
144
147
  expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({
145
148
  doseMode: 'serial',
149
+ bindingComponent: 'DoseCalculator',
150
+ bindingMode: 'serial',
146
151
  bindingDoseMode: 'serial',
152
+ nestedBindingMode: 'serial',
147
153
  hasPlateWells: true,
148
154
  doseModeFromGetter: 'serial',
149
155
  bindingDoseModeFromGetter: 'serial',