@morscherlab/mint-sdk 1.4.0-beta.1 → 1.4.0-beta.11

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 (266) hide show
  1. package/dist/{ActionMenu-CScINahU.js → AlertBox-B2Bgr3ei.js} +40 -209
  2. package/dist/AlertBox-B2Bgr3ei.js.map +1 -0
  3. package/dist/{AppSidebar-BmiiPfz9.js → AppSidebar-mvm--GxN.js} +161 -110
  4. package/dist/AppSidebar-mvm--GxN.js.map +1 -0
  5. package/dist/{ConfirmDialog-CJ7hqYUP.js → ConfirmDialog-nbJj51is.js} +2 -2
  6. package/dist/{ConfirmDialog-CJ7hqYUP.js.map → ConfirmDialog-nbJj51is.js.map} +1 -1
  7. package/dist/ExperimentPopover-5T1fAq2L.js +337 -0
  8. package/dist/ExperimentPopover-5T1fAq2L.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-D-9z7cNx.js → ExperimentSelectorModal-65peG4Up.js} +198 -181
  10. package/dist/ExperimentSelectorModal-65peG4Up.js.map +1 -0
  11. package/dist/{SegmentedControl-DERBN-Xd.js → SegmentedControl-BtnnjWzs.js} +2 -2
  12. package/dist/{SegmentedControl-DERBN-Xd.js.map → SegmentedControl-BtnnjWzs.js.map} +1 -1
  13. package/dist/{SettingsModal-CSLE31yg.js → SettingsModal-DO3IZ7qz.js} +9 -9
  14. package/dist/SettingsModal-DO3IZ7qz.js.map +1 -0
  15. package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
  16. package/dist/components/AppSidebar.vue.d.ts +1 -1
  17. package/dist/components/ArtifactSaveDialog.vue.d.ts +9 -1
  18. package/dist/components/AuditTrail.vue.d.ts +1 -1
  19. package/dist/components/BaseSelect.vue.d.ts +2 -2
  20. package/dist/components/ChartContainer.vue.d.ts +2 -0
  21. package/dist/components/DatePicker.vue.d.ts +1 -1
  22. package/dist/components/ExperimentPopover.vue.d.ts +18 -0
  23. package/dist/components/ExperimentSelectorModal.vue.d.ts +0 -4
  24. package/dist/components/FilePicker.vue.d.ts +1 -1
  25. package/dist/components/FileUploader.vue.d.ts +2 -2
  26. package/dist/components/PluginIcon.vue.d.ts +2 -2
  27. package/dist/components/SampleSelector.vue.d.ts +6 -6
  28. package/dist/components/SequenceProgressBar.vue.d.ts +3 -0
  29. package/dist/components/StepWizard.vue.d.ts +3 -0
  30. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +1 -1
  31. package/dist/components/internal/DatePickerTimeInternal.vue.d.ts +1 -1
  32. package/dist/components/internal/ResourcePickerListInternal.vue.d.ts +2 -7
  33. package/dist/components/internal/SelectMultipleInternal.vue.d.ts +4 -0
  34. package/dist/composables/createClient.d.ts +2 -0
  35. package/dist/composables/index.d.ts +1 -0
  36. package/dist/composables/pluginIdentity.d.ts +24 -0
  37. package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
  38. package/dist/composables/useAppExperiment.d.ts +8 -0
  39. package/dist/composables/useCalendarGrid.d.ts +1 -1
  40. package/dist/composables/useExperimentSelector.d.ts +6 -2
  41. package/dist/composables/usePlatform.d.ts +1 -0
  42. package/dist/{controlSchemaModel-CP5-_nAj.js → controlSchemaModel-B8CTXagP.js} +2 -2
  43. package/dist/{controlSchemaModel-CP5-_nAj.js.map → controlSchemaModel-B8CTXagP.js.map} +1 -1
  44. package/dist/{data-C2PwI_dn.js → data-CgILxDLW.js} +178 -124
  45. package/dist/data-CgILxDLW.js.map +1 -0
  46. package/dist/data.js +8 -8
  47. package/dist/{experimentStatus-BZmU4czV.js → experimentStatus-Cqp6qNrR.js} +3 -3
  48. package/dist/{experimentStatus-BZmU4czV.js.map → experimentStatus-Cqp6qNrR.js.map} +1 -1
  49. package/dist/index.js +19 -19
  50. package/dist/{install-DAID6c2-.js → install-mB8-IyvO.js} +15 -14
  51. package/dist/{install-DAID6c2-.js.map → install-mB8-IyvO.js.map} +1 -1
  52. package/dist/install.js +1 -1
  53. package/dist/{lab-DLwRl_Ql.js → lab-D5bIX426.js} +128 -147
  54. package/dist/lab-D5bIX426.js.map +1 -0
  55. package/dist/lab.js +4 -3
  56. package/dist/{records-BzTcxekG.js → records-B6gpM_3e.js} +472 -274
  57. package/dist/records-B6gpM_3e.js.map +1 -0
  58. package/dist/{runtime-D6gVat6r.js → runtime-D7vKpfbF.js} +27 -31
  59. package/dist/runtime-D7vKpfbF.js.map +1 -0
  60. package/dist/runtime.js +6 -6
  61. package/dist/{shell-5ju97nOS.js → shell-CywENwBT.js} +5 -5
  62. package/dist/{shell-5ju97nOS.js.map → shell-CywENwBT.js.map} +1 -1
  63. package/dist/shell.js +5 -5
  64. package/dist/styles.css +1644 -1688
  65. package/dist/types/componentWorkflowTypes.d.ts +2 -0
  66. package/dist/types/generated/platformContext.d.ts +1 -0
  67. package/dist/types/generated-ui.d.ts +4 -0
  68. package/dist/{BaseModal-DfRIuVoj.js → useDropdownState-8yWMu-7_.js} +60 -2
  69. package/dist/useDropdownState-8yWMu-7_.js.map +1 -0
  70. package/dist/{useExperimentSamples-CxcnxZ8h.js → useExperimentSamples-DHnxKFAF.js} +3 -3
  71. package/dist/{useExperimentSamples-CxcnxZ8h.js.map → useExperimentSamples-DHnxKFAF.js.map} +1 -1
  72. package/dist/{useListReorder-CcbEYxX-.js → useListReorder-BitdUhxn.js} +109 -277
  73. package/dist/useListReorder-BitdUhxn.js.map +1 -0
  74. package/dist/{useRackEditor-BD5NIo5s.js → useRackEditor-Q8xO-cCy.js} +72 -58
  75. package/dist/useRackEditor-Q8xO-cCy.js.map +1 -0
  76. package/dist/{useRealtime-PalKue84.js → useRealtime-C3iy9QkU.js} +7 -4
  77. package/dist/useRealtime-C3iy9QkU.js.map +1 -0
  78. package/dist/useRequest-D506eHi8.js +244 -0
  79. package/dist/useRequest-D506eHi8.js.map +1 -0
  80. package/dist/utils/time.d.ts +7 -0
  81. package/package.json +1 -1
  82. package/src/__tests__/components/AppTopBar.test.ts +51 -2
  83. package/src/__tests__/components/AppTopBarPillNav.test.ts +35 -0
  84. package/src/__tests__/components/ArtifactSaveDialog.test.ts +78 -0
  85. package/src/__tests__/components/BaseSelect.multiple.test.ts +87 -32
  86. package/src/__tests__/components/BaseSelect.test.ts +2 -1
  87. package/src/__tests__/components/BatchProgressList.test.ts +16 -0
  88. package/src/__tests__/components/CalendarGridPanel.test.ts +2 -2
  89. package/src/__tests__/components/ChartContainer.test.ts +11 -0
  90. package/src/__tests__/components/ChemicalFormula.test.ts +6 -3
  91. package/src/__tests__/components/DatePicker.datetime.test.ts +6 -0
  92. package/src/__tests__/components/DatePicker.test.ts +21 -0
  93. package/src/__tests__/components/DatePicker.time.test.ts +2 -2
  94. package/src/__tests__/components/EmptyState.test.ts +1 -1
  95. package/src/__tests__/components/ExperimentPopover.test.ts +125 -16
  96. package/src/__tests__/components/ExperimentSelectorModal.test.ts +119 -13
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +22 -1
  98. package/src/__tests__/components/InstrumentStateBadge.test.ts +4 -4
  99. package/src/__tests__/components/PluginWorkspaceView.test.ts +7 -0
  100. package/src/__tests__/components/ResourcePickerListInternal.test.ts +0 -20
  101. package/src/__tests__/components/SampleSelector.test.ts +13 -8
  102. package/src/__tests__/components/SequenceProgressBar.test.ts +12 -0
  103. package/src/__tests__/components/StepWizard.test.ts +11 -0
  104. package/src/__tests__/components/WellPlate.decoration.test.ts +11 -4
  105. package/src/__tests__/components/deprecationTags.test.ts +1 -1
  106. package/src/__tests__/composables/useAppExperiment.test.ts +27 -1
  107. package/src/__tests__/composables/useCalendarGrid.test.ts +4 -2
  108. package/src/__tests__/composables/useExperimentSelector.test.ts +111 -0
  109. package/src/__tests__/styles/baseControlsRules.test.ts +6 -2
  110. package/src/__tests__/styles/controlRefreshRules.test.ts +0 -6
  111. package/src/__tests__/styles/cssTokens.test.ts +2 -2
  112. package/src/__tests__/styles/menusShellRefresh.test.ts +14 -10
  113. package/src/__tests__/styles/v2AuditRules.test.ts +96 -0
  114. package/src/__tests__/utils/plotlyTemplate.test.ts +4 -4
  115. package/src/__tests__/utils/time.test.ts +18 -1
  116. package/src/components/AdductText.story.vue +7 -0
  117. package/src/components/AdductText.vue +1 -1
  118. package/src/components/AlertBox.story.vue +2 -2
  119. package/src/components/AlertBox.vue +18 -16
  120. package/src/components/AppSidebar.story.vue +0 -28
  121. package/src/components/AppTopBar.story.vue +47 -12
  122. package/src/components/AppTopBar.vue +118 -114
  123. package/src/components/ArtifactSaveDialog.story.vue +23 -0
  124. package/src/components/ArtifactSaveDialog.vue +102 -12
  125. package/src/components/AuditTrail.story.vue +2 -1
  126. package/src/components/AuditTrail.vue +2 -21
  127. package/src/components/Avatar.vue +10 -14
  128. package/src/components/BaseSelect.multiple.story.vue +13 -2
  129. package/src/components/BaseSelect.vue +4 -3
  130. package/src/components/BatchProgressList.vue +35 -25
  131. package/src/components/ChartContainer.story.vue +2 -1
  132. package/src/components/ChartContainer.vue +8 -6
  133. package/src/components/ConfirmDialog.story.vue +1 -1
  134. package/src/components/EmptyState.vue +1 -1
  135. package/src/components/ExperimentPopover.story.vue +165 -53
  136. package/src/components/ExperimentPopover.vue +169 -187
  137. package/src/components/ExperimentSelectorModal.story.vue +40 -36
  138. package/src/components/ExperimentSelectorModal.vue +124 -110
  139. package/src/components/FormField.story.vue +8 -48
  140. package/src/components/GeneratedPluginView.vue +7 -1
  141. package/src/components/GeneratedResultRenderer.vue +3 -4
  142. package/src/components/InstrumentStateBadge.vue +1 -1
  143. package/src/components/JobsStatusTray.vue +1 -1
  144. package/src/components/LoadingSpinner.vue +1 -1
  145. package/src/components/NumberInput.vue +3 -6
  146. package/src/components/PluginIcon.story.vue +2 -10
  147. package/src/components/PluginIcon.vue +2 -2
  148. package/src/components/PluginWorkspaceView.story.vue +1 -1
  149. package/src/components/RuntimeUpdateNotice.vue +1 -1
  150. package/src/components/SampleSelector.story.vue +1 -1
  151. package/src/components/SampleSelector.vue +27 -37
  152. package/src/components/SampleSelectorGroupNode.vue +1 -1
  153. package/src/components/SectionCard.story.vue +2 -2
  154. package/src/components/SequenceProgressBar.story.vue +9 -0
  155. package/src/components/SequenceProgressBar.vue +6 -1
  156. package/src/components/SettingsModal.schema.ts +1 -1
  157. package/src/components/SettingsModal.vue +2 -2
  158. package/src/components/SmartGroupFieldRecipe.story.vue +1 -1
  159. package/src/components/SmartGroupManual.story.vue +1 -1
  160. package/src/components/StepWizard.story.vue +33 -0
  161. package/src/components/StepWizard.vue +8 -1
  162. package/src/components/WellPlate.story.vue +3 -1
  163. package/src/components/WellPlate.vue +7 -0
  164. package/src/components/internal/AppTopBarPillNavInternal.vue +33 -2
  165. package/src/components/internal/DatePickerDateInternal.vue +16 -40
  166. package/src/components/internal/DatePickerDateTimeInternal.vue +3 -13
  167. package/src/components/internal/DatePickerTimeInternal.vue +5 -8
  168. package/src/components/internal/ResourcePickerListInternal.vue +45 -85
  169. package/src/components/internal/SelectMultipleInternal.vue +182 -65
  170. package/src/components/internal/SelectSearchableInternal.vue +2 -3
  171. package/src/composables/createClient.ts +2 -0
  172. package/src/composables/index.ts +1 -0
  173. package/src/composables/pluginIdentity.ts +31 -0
  174. package/src/composables/pluginWorkspaceModel.ts +2 -2
  175. package/src/composables/useAppExperiment.ts +20 -0
  176. package/src/composables/useCalendarGrid.ts +4 -3
  177. package/src/composables/useExperimentSelector.ts +77 -31
  178. package/src/composables/usePluginWorkspace.ts +1 -1
  179. package/src/styles/components/adduct-text.css +13 -5
  180. package/src/styles/components/alert-box.css +39 -58
  181. package/src/styles/components/app-layout.css +7 -5
  182. package/src/styles/components/app-page-selector.css +3 -1
  183. package/src/styles/components/app-pill-nav.css +11 -12
  184. package/src/styles/components/app-sidebar.css +9 -18
  185. package/src/styles/components/app-top-bar.css +57 -55
  186. package/src/styles/components/artifact-save-dialog.css +153 -2
  187. package/src/styles/components/audit-trail.css +16 -19
  188. package/src/styles/components/avatar.css +6 -4
  189. package/src/styles/components/batch-progress-list.css +38 -36
  190. package/src/styles/components/button.css +28 -59
  191. package/src/styles/components/calendar.css +12 -12
  192. package/src/styles/components/chart-container.css +34 -23
  193. package/src/styles/components/checkbox.css +2 -12
  194. package/src/styles/components/collapsible-card.css +27 -29
  195. package/src/styles/components/confirm-dialog.css +13 -14
  196. package/src/styles/components/dataframe.css +13 -5
  197. package/src/styles/components/date-picker.css +80 -92
  198. package/src/styles/components/datetime-picker.css +31 -43
  199. package/src/styles/components/divider.css +20 -20
  200. package/src/styles/components/dose-calculator.css +3 -3
  201. package/src/styles/components/empty-state.css +20 -17
  202. package/src/styles/components/experiment-code-badge.css +2 -0
  203. package/src/styles/components/experiment-popover.css +174 -329
  204. package/src/styles/components/experiment-selector-modal.css +75 -56
  205. package/src/styles/components/file-picker.css +3 -3
  206. package/src/styles/components/file-uploader.css +38 -49
  207. package/src/styles/components/form-builder.css +3 -3
  208. package/src/styles/components/formula-input.css +1 -1
  209. package/src/styles/components/icon-button.css +18 -24
  210. package/src/styles/components/input.css +5 -4
  211. package/src/styles/components/instrument-monitor.css +13 -15
  212. package/src/styles/components/jobs-status-tray.css +31 -25
  213. package/src/styles/components/modal.css +4 -6
  214. package/src/styles/components/molecule-input.css +2 -2
  215. package/src/styles/components/multi-select.css +65 -117
  216. package/src/styles/components/number-input.css +26 -19
  217. package/src/styles/components/pill.css +3 -2
  218. package/src/styles/components/plate-editor.css +0 -1
  219. package/src/styles/components/plugin-icon.css +26 -23
  220. package/src/styles/components/popover.css +2 -2
  221. package/src/styles/components/presence-avatars.css +1 -1
  222. package/src/styles/components/progress-bar.css +3 -3
  223. package/src/styles/components/protocol-step-editor.css +1 -3
  224. package/src/styles/components/resource-card.css +2 -2
  225. package/src/styles/components/resource-picker-list.css +6 -57
  226. package/src/styles/components/sample-hierarchy-tree.css +4 -4
  227. package/src/styles/components/sample-selector.css +95 -79
  228. package/src/styles/components/schedule-calendar.css +8 -8
  229. package/src/styles/components/searchable-select.css +12 -12
  230. package/src/styles/components/section-card.css +30 -47
  231. package/src/styles/components/segmented-control.css +18 -15
  232. package/src/styles/components/select.css +3 -3
  233. package/src/styles/components/sequence-input.css +2 -2
  234. package/src/styles/components/sequence-progress-bar.css +17 -8
  235. package/src/styles/components/settings-modal.css +7 -2
  236. package/src/styles/components/smart-group.css +18 -18
  237. package/src/styles/components/step-wizard.css +52 -16
  238. package/src/styles/components/tabs.css +2 -2
  239. package/src/styles/components/textarea.css +3 -3
  240. package/src/styles/components/time-picker.css +36 -21
  241. package/src/styles/components/toast.css +12 -10
  242. package/src/styles/components/toggle.css +1 -11
  243. package/src/styles/components/tooltip.css +10 -9
  244. package/src/styles/components/unit-input.css +8 -7
  245. package/src/styles/components/well-plate.css +9 -2
  246. package/src/styles/variables.css +118 -77
  247. package/src/types/componentWorkflowTypes.ts +2 -0
  248. package/src/types/generated/platformContext.ts +1 -0
  249. package/src/types/generated-ui.ts +4 -0
  250. package/src/utils/time.ts +21 -0
  251. package/dist/ActionMenu-CScINahU.js.map +0 -1
  252. package/dist/AppSidebar-BmiiPfz9.js.map +0 -1
  253. package/dist/BaseModal-DfRIuVoj.js.map +0 -1
  254. package/dist/ExperimentPopover-C4sGw-is.js +0 -359
  255. package/dist/ExperimentPopover-C4sGw-is.js.map +0 -1
  256. package/dist/ExperimentSelectorModal-D-9z7cNx.js.map +0 -1
  257. package/dist/SettingsModal-CSLE31yg.js.map +0 -1
  258. package/dist/data-C2PwI_dn.js.map +0 -1
  259. package/dist/lab-DLwRl_Ql.js.map +0 -1
  260. package/dist/records-BzTcxekG.js.map +0 -1
  261. package/dist/runtime-D6gVat6r.js.map +0 -1
  262. package/dist/useListReorder-CcbEYxX-.js.map +0 -1
  263. package/dist/useRackEditor-BD5NIo5s.js.map +0 -1
  264. package/dist/useRealtime-PalKue84.js.map +0 -1
  265. package/dist/useRequest-D5JDfsgZ.js +0 -76
  266. package/dist/useRequest-D5JDfsgZ.js.map +0 -1
@@ -1,35 +1,45 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
3
  import { createPinia, setActivePinia } from 'pinia'
4
- import { computed, reactive, ref } from 'vue'
4
+ import { reactive, ref } from 'vue'
5
5
 
6
6
  import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
7
7
  import { useExperimentSelector } from '../../composables/useExperimentSelector'
8
8
  import { useExperimentStore } from '../../stores/experiment'
9
- import type { ExperimentSummary } from '../../types'
9
+ import type { ExperimentFilters, ExperimentSummary } from '../../types'
10
10
 
11
11
  const fetchExperiments = vi.fn(async () => undefined)
12
12
  const fetchFilterOptions = vi.fn(async () => undefined)
13
+ const fetchStatusCounts = vi.fn(async () => undefined)
13
14
  const experiments = ref<ExperimentSummary[]>([])
15
+ const experimentTypes = ref<{ value: string; label: string }[]>([])
16
+ const projects = ref<{ value: string; label: string }[]>([])
17
+ const statusCounts = ref<Record<string, number>>({})
18
+ let filters: ExperimentFilters = newFilters()
19
+
20
+ function newFilters(): ExperimentFilters {
21
+ return reactive({
22
+ search: undefined,
23
+ status: undefined,
24
+ project: undefined,
25
+ experimentType: undefined,
26
+ datePreset: undefined,
27
+ mine: undefined,
28
+ })
29
+ }
14
30
 
15
31
  vi.mock('../../composables/useExperimentSelector', () => ({
16
32
  useExperimentSelector: vi.fn(() => ({
17
33
  experiments,
18
- filters: reactive({
19
- search: undefined,
20
- status: undefined,
21
- project: undefined,
22
- experimentType: undefined,
23
- datePreset: undefined,
24
- }),
34
+ filters,
25
35
  isLoading: ref(false),
26
36
  error: ref(null),
27
- sortKey: ref('created_at:desc'),
28
- experimentTypes: ref([]),
29
- projects: ref([]),
30
- groupedByProject: computed(() => []),
37
+ experimentTypes,
38
+ projects,
39
+ statusCounts,
31
40
  fetch: fetchExperiments,
32
41
  fetchFilterOptions,
42
+ fetchStatusCounts,
33
43
  })),
34
44
  }))
35
45
 
@@ -49,6 +59,7 @@ function experiment(overrides: Partial<ExperimentSummary> = {}): ExperimentSumma
49
59
 
50
60
  interface ModalProps {
51
61
  open: boolean
62
+ experimentType?: string
52
63
  allowedExperimentTypes?: string[] | null
53
64
  currentExperimentId?: number | null
54
65
  }
@@ -62,6 +73,15 @@ function mountModal(props: ModalProps) {
62
73
  props: ['open', 'title', 'size'],
63
74
  template: '<div><slot /></div>',
64
75
  },
76
+ // Renders the trigger and every item inline, without the teleported panel.
77
+ ActionMenu: {
78
+ props: ['label', 'items'],
79
+ emits: ['select'],
80
+ template: `<div class="menu-stub" :data-label="label">
81
+ <slot name="trigger" :open="false" :toggle="() => {}" :keydown="() => {}" />
82
+ <button v-for="item in items" :key="item.id" class="menu-stub__item" :data-checked="item.checked" @click="$emit('select', item)">{{ item.label }}</button>
83
+ </div>`,
84
+ },
65
85
  },
66
86
  },
67
87
  })
@@ -71,8 +91,13 @@ describe('ExperimentSelectorModal', () => {
71
91
  beforeEach(() => {
72
92
  setActivePinia(createPinia())
73
93
  experiments.value = []
94
+ experimentTypes.value = []
95
+ projects.value = []
96
+ statusCounts.value = {}
97
+ filters = newFilters()
74
98
  fetchExperiments.mockClear()
75
99
  fetchFilterOptions.mockClear()
100
+ fetchStatusCounts.mockClear()
76
101
  vi.mocked(useExperimentSelector).mockClear()
77
102
  })
78
103
 
@@ -81,6 +106,7 @@ describe('ExperimentSelectorModal', () => {
81
106
 
82
107
  expect(fetchFilterOptions).toHaveBeenCalledTimes(1)
83
108
  expect(fetchExperiments).toHaveBeenCalledTimes(1)
109
+ expect(fetchStatusCounts).toHaveBeenCalledTimes(1)
84
110
  })
85
111
 
86
112
  it('does not fetch while mounted closed', () => {
@@ -96,6 +122,7 @@ describe('ExperimentSelectorModal', () => {
96
122
  expect(useExperimentSelector).toHaveBeenCalledWith({
97
123
  experimentType: undefined,
98
124
  allowedExperimentTypes: ['mass_spectrometry'],
125
+ withStatusCounts: true,
99
126
  })
100
127
  })
101
128
 
@@ -129,6 +156,12 @@ describe('ExperimentSelectorModal', () => {
129
156
  expect(wrapper.emitted('update:open')).toEqual([[false]])
130
157
  })
131
158
 
159
+ it('should mark the search field autofocus when the modal opens', () => {
160
+ const wrapper = mountModal({ open: true })
161
+
162
+ expect(wrapper.get('input[type="search"]').attributes()).toHaveProperty('autofocus')
163
+ })
164
+
132
165
  it('should show the experiment code as a pill on the second line', () => {
133
166
  experiments.value = [experiment()]
134
167
  const wrapper = mountModal({ open: true })
@@ -168,4 +201,77 @@ describe('ExperimentSelectorModal', () => {
168
201
  expect(store.currentId).toBeNull()
169
202
  expect(wrapper.emitted('update:open')).toEqual([[false]])
170
203
  })
204
+
205
+ it('should list each status with its count and check the current one', async () => {
206
+ statusCounts.value = { ongoing: 3, planned: 1, completed: 0, cancelled: 2 }
207
+ filters.status = 'ongoing'
208
+ const wrapper = mountModal({ open: true })
209
+
210
+ const items = wrapper.get('[data-label="Status"]').findAll('.menu-stub__item')
211
+ expect(items.map(item => item.text())).toEqual([
212
+ 'Any status', 'Ongoing · 3', 'Planned · 1', 'Completed · 0', 'Cancelled · 2',
213
+ ])
214
+ expect(items.map(item => item.attributes('data-checked'))).toEqual(['false', 'true', 'false', 'false', 'false'])
215
+ })
216
+
217
+ it('should show the active status with its dot on the trigger and reset it from Any status', async () => {
218
+ filters.status = 'completed'
219
+ const wrapper = mountModal({ open: true })
220
+ const menu = wrapper.get('[data-label="Status"]')
221
+
222
+ const trigger = menu.get('.mint-experiment-selector__toggle')
223
+ expect(trigger.classes()).toContain('mint-experiment-selector__toggle--active')
224
+ expect(trigger.text()).toBe('Completed')
225
+ expect(trigger.find('.mint-experiment-selector__dot--success').exists()).toBe(true)
226
+
227
+ await menu.findAll('.menu-stub__item')[0].trigger('click')
228
+
229
+ expect(filters.status).toBeUndefined()
230
+ })
231
+
232
+ it('should filter by the chosen project', async () => {
233
+ projects.value = [{ value: 'Screen', label: 'Screen' }]
234
+ const wrapper = mountModal({ open: true })
235
+ const menu = wrapper.get('[data-label="Project"]')
236
+
237
+ await menu.findAll('.menu-stub__item')[1].trigger('click')
238
+
239
+ expect(filters.project).toBe('Screen')
240
+ expect(menu.get('.mint-experiment-selector__toggle').text()).toBe('Screen')
241
+ })
242
+
243
+ it.each([
244
+ { experimentType: undefined, shown: true },
245
+ { experimentType: 'screen', shown: false },
246
+ ])('should show the type menu only when the type is choosable (experimentType=$experimentType)', ({ experimentType, shown }) => {
247
+ experimentTypes.value = [{ value: 'screen', label: 'Screen' }, { value: 'lcms', label: 'LC-MS' }]
248
+ const wrapper = mountModal({ open: true, experimentType })
249
+
250
+ expect(wrapper.find('[data-label="Type"]').exists()).toBe(shown)
251
+ })
252
+
253
+ it('should toggle the mine filter from the Mine button', async () => {
254
+ const wrapper = mountModal({ open: true })
255
+ const mine = wrapper.get('[aria-pressed]')
256
+
257
+ await mine.trigger('click')
258
+
259
+ expect(filters.mine).toBe(true)
260
+ expect(mine.attributes('aria-pressed')).toBe('true')
261
+ })
262
+
263
+ it('should clear status, project and mine but keep the search when Clear N is clicked', async () => {
264
+ const wrapper = mountModal({ open: true })
265
+ expect(wrapper.find('.mint-experiment-selector__clear-filters').exists()).toBe(false)
266
+
267
+ Object.assign(filters, { search: 'glu', status: 'planned', project: 'Screen', mine: true })
268
+ await wrapper.vm.$nextTick()
269
+ const clear = wrapper.get('.mint-experiment-selector__clear-filters')
270
+ expect(clear.text()).toBe('Clear 3')
271
+
272
+ await clear.trigger('click')
273
+
274
+ expect(filters).toMatchObject({ search: 'glu', status: undefined, project: undefined, mine: undefined })
275
+ expect(wrapper.find('.mint-experiment-selector__clear-filters').exists()).toBe(false)
276
+ })
171
277
  })
@@ -7,6 +7,7 @@ import GeneratedPluginView from '../../components/GeneratedPluginView.vue'
7
7
  import GeneratedResultRenderer from '../../components/GeneratedResultRenderer.vue'
8
8
  import JobsStatusTray from '../../components/JobsStatusTray.vue'
9
9
  import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
10
+ import { pluginIdentity, providePluginIdentity } from '../../composables/pluginIdentity'
10
11
 
11
12
  const generated = vi.hoisted(() => ({
12
13
  refresh: vi.fn(),
@@ -16,6 +17,7 @@ const generated = vi.hoisted(() => ({
16
17
  options: undefined as unknown,
17
18
  jobs: [] as Array<Record<string, unknown>>,
18
19
  error: null as string | null,
20
+ ui: {} as Record<string, string>,
19
21
  }))
20
22
 
21
23
  vi.mock('../../composables/useJobsEndpoint', async () => {
@@ -30,7 +32,7 @@ vi.mock('../../composables/useJobsEndpoint', async () => {
30
32
  plugin: {
31
33
  name: 'Demo', version: '1.0.0', description: 'Demo plugin', routes_prefix: '/demo',
32
34
  },
33
- ui: { title: 'Demo analysis', description: 'Backend-only workspace' },
35
+ ui: generated.ui,
34
36
  jobs: [
35
37
  {
36
38
  id: 'run',
@@ -120,6 +122,8 @@ describe('GeneratedPluginView', () => {
120
122
  { ...baseJob, id: 'job-2', job_id: 'job-2', session_id: 'shared-session' },
121
123
  ]
122
124
  generated.error = null
125
+ generated.ui = { title: 'Demo analysis', description: 'Backend-only workspace' }
126
+ providePluginIdentity({ plugin: { name: '' } })
123
127
  generated.refresh.mockResolvedValue(undefined)
124
128
  generated.submit.mockResolvedValue(undefined)
125
129
  generated.result.mockResolvedValue({ kind: 'text', text: 'complete' })
@@ -159,6 +163,23 @@ describe('GeneratedPluginView', () => {
159
163
  })
160
164
  })
161
165
 
166
+ it('should seed the top bar identity from the manifest ui subtitle and icon', async () => {
167
+ generated.ui = {
168
+ title: 'Demo analysis', subtitle: 'Peak triage', description: 'Backend-only workspace', icon: 'M4 4h16v16H4z',
169
+ }
170
+ const wrapper = mount(GeneratedPluginView, {
171
+ props: { baseUrl: '/api/demo' },
172
+ global: {
173
+ plugins: [createPinia()],
174
+ stubs: { 'router-link': { template: '<a><slot /></a>' }, Teleport: true },
175
+ },
176
+ })
177
+ await flushPromises()
178
+
179
+ expect(wrapper.findComponent(PluginWorkspaceView).props('spec')).toMatchObject({ subtitle: 'Peak triage' })
180
+ expect(pluginIdentity).toMatchObject({ name: 'Demo analysis', subtitle: 'Peak triage', icon: 'M4 4h16v16H4z' })
181
+ })
182
+
162
183
  it('should show the load error text when the workspace fails to load', async () => {
163
184
  generated.error = 'Manifest request failed'
164
185
  const wrapper = mount(GeneratedPluginView, {
@@ -18,9 +18,9 @@ describe('InstrumentStateBadge', () => {
18
18
  expect(wrapper.classes()).toContain('mint-instrument-state--pulse')
19
19
  })
20
20
 
21
- it('should use the info tone when the instrument is running', () => {
21
+ it('should use the success tone when the instrument is running', () => {
22
22
  const wrapper = mount(InstrumentStateBadge, { props: { state: 'running' } })
23
- expect(wrapper.classes()).toContain('mint-instrument-state--info')
23
+ expect(wrapper.classes()).toContain('mint-instrument-state--success')
24
24
  })
25
25
 
26
26
  it('should use the success tone when the instrument is connected', () => {
@@ -28,7 +28,7 @@ describe('InstrumentStateBadge', () => {
28
28
  expect(wrapper.classes()).toContain('mint-instrument-state--success')
29
29
  })
30
30
 
31
- it('should use the dash shape when the instrument never reported', () => {
31
+ it('should use the hollow square shape when the instrument never reported', () => {
32
32
  const wrapper = mount(InstrumentStateBadge, { props: { state: 'never' } })
33
33
  expect(wrapper.classes()).toContain('mint-instrument-state--never')
34
34
  })
@@ -38,7 +38,7 @@ describe('InstrumentStateBadge', () => {
38
38
  expect(wrapper.text()).toBe('Never reported')
39
39
  })
40
40
 
41
- it('should use the square shape when the instrument is inactive', () => {
41
+ it('should use the dash shape when the instrument is inactive', () => {
42
42
  const wrapper = mount(InstrumentStateBadge, { props: { state: 'inactive' } })
43
43
  expect(wrapper.classes()).toContain('mint-instrument-state--inactive')
44
44
  })
@@ -131,6 +131,13 @@ function mountIntegratedPageSelectorWorkspace(): VueWrapper {
131
131
  }
132
132
 
133
133
  describe('PluginWorkspaceView', () => {
134
+ it('should leave the top bar title unset when the spec has no title', () => {
135
+ // The top bar then shows the plugin identity, not a generic "Workspace".
136
+ const wrapper = mount(PluginWorkspaceView, { props: { spec: {} }, global: globalOptions })
137
+
138
+ expect(wrapper.findComponent(AppTopBar).props('title')).toBeUndefined()
139
+ })
140
+
134
141
  it('should not forward the retired profile-button prop', () => {
135
142
  const wrapper = mount(PluginWorkspaceView, {
136
143
  global: globalOptions,
@@ -107,17 +107,6 @@ describe('ResourcePickerListInternal', () => {
107
107
  expect(wrapper.emitted('update:search')?.[0]).toEqual(['plasma'])
108
108
  })
109
109
 
110
- it('should head each group with its label and a count', () => {
111
- const wrapper = setup({ groups: [{ label: 'Recent', items }] })
112
- expect(wrapper.get('.mint-resource-picker__group').text()).toBe('Recent2')
113
- })
114
-
115
- it('should hide the rows of a collapsed group', async () => {
116
- const wrapper = setup({ groups: [{ label: 'Recent', items }] })
117
- await wrapper.get('.mint-resource-picker__group').trigger('click')
118
- expect(wrapper.findAll('[role="option"]')).toHaveLength(0)
119
- })
120
-
121
110
  it('should show skeleton rows while loading', () => {
122
111
  expect(setup({ loading: true }).findAll('.mint-resource-picker__skeleton-row')).toHaveLength(4)
123
112
  })
@@ -129,13 +118,4 @@ describe('ResourcePickerListInternal', () => {
129
118
  it('should show the empty title when there are no items', () => {
130
119
  expect(setup({ items: [], emptyTitle: 'Nothing here' }).text()).toContain('Nothing here')
131
120
  })
132
-
133
- it('should leave Enter to a group header while a row is highlighted', async () => {
134
- const wrapper = setup({ groups: [{ label: 'Recent', items }] })
135
- await wrapper.findAll('[role="option"]')[0]!.trigger('mouseenter')
136
-
137
- await wrapper.get('.mint-resource-picker__group').trigger('keydown', { key: 'Enter' })
138
-
139
- expect(wrapper.emitted('select')).toBeUndefined()
140
- })
141
121
  })
@@ -97,7 +97,7 @@ describe('SampleSelector', () => {
97
97
  },
98
98
  })
99
99
 
100
- expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('0 / 2')
100
+ expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('0/2')
101
101
  expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2'])
102
102
  })
103
103
 
@@ -115,9 +115,9 @@ describe('SampleSelector', () => {
115
115
  },
116
116
  })
117
117
 
118
- const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label')
119
- expect(labels.map(label => label.text())).toEqual(['Grouping'])
120
- expect(wrapper.findAll('.mint-sample-selector__actions-row > .mint-sample-selector__action-btn')).toHaveLength(2)
118
+ const buttons = wrapper.findAll('.mint-sample-selector__header-actions > .mint-sample-selector__action-btn')
119
+ expect(buttons.map(button => button.text())).toEqual(['Smart group'])
120
+ expect(wrapper.find('.mint-sample-selector__header').text()).toContain('Samples0 selected')
121
121
 
122
122
  await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click')
123
123
 
@@ -140,8 +140,8 @@ describe('SampleSelector', () => {
140
140
  },
141
141
  })
142
142
 
143
- const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label')
144
- expect(labels.map(label => label.text())).toEqual(['Grouping'])
143
+ const buttons = wrapper.findAll('.mint-sample-selector__header-actions > .mint-sample-selector__action-btn')
144
+ expect(buttons.map(button => button.text())).toEqual(['Group'])
145
145
 
146
146
  await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click')
147
147
 
@@ -472,9 +472,14 @@ describe('SampleSelector checkboxes (7c)', () => {
472
472
  }
473
473
  const stubs = { global: { stubs: { AutoGroupModalInternal: true } } }
474
474
 
475
- it('should show the mono selected / total count when some samples are selected', () => {
475
+ it('should show the mono selected/total count when some samples are selected', () => {
476
476
  const wrapper = mount(SampleSelector, { props: grouped, ...stubs })
477
- expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('1 / 3')
477
+ expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('1/3')
478
+ })
479
+
480
+ it('should offer clear-all-groups in the header when groups exist', () => {
481
+ const wrapper = mount(SampleSelector, { props: { ...grouped, enableGrouping: true }, ...stubs })
482
+ expect(wrapper.find('.mint-sample-selector__header-actions [aria-label="Clear all groups"]').exists()).toBe(true)
478
483
  })
479
484
 
480
485
  it('should mark the select-all checkbox mixed when some samples are selected', () => {
@@ -36,4 +36,16 @@ describe('SequenceProgressBar', () => {
36
36
  expect(wrapper.get('.mint-sequence-progress__label').text()).toBe('2 / 4 injections')
37
37
  expect(wrapper.find('.mint-sequence-progress__footer').exists()).toBe(false)
38
38
  })
39
+
40
+ it.each([
41
+ [0, null],
42
+ [2, '2 failed'],
43
+ ])('should show the failed count when %i injections failed', (failed, expected) => {
44
+ const wrapper = mount(SequenceProgressBar, {
45
+ props: { failed, progress: { current_sample: 3, total_samples: 6 } },
46
+ })
47
+
48
+ const node = wrapper.find('.mint-sequence-progress__failed')
49
+ expect(node.exists() ? node.text() : null).toBe(expected)
50
+ })
39
51
  })
@@ -73,3 +73,14 @@ describe('StepWizard navigation (2c)', () => {
73
73
  expect(wrapper.find('.mint-wizard__counter').text()).toBe('2 / 2')
74
74
  })
75
75
  })
76
+
77
+ describe('StepWizard orientation', () => {
78
+ it.each([
79
+ ['horizontal', false],
80
+ ['vertical', true],
81
+ ] as const)('should toggle the vertical modifier when orientation is %s', (orientation, vertical) => {
82
+ const wrapper = mount(StepWizard, { props: { steps, orientation } })
83
+
84
+ expect(wrapper.classes('mint-wizard--vertical')).toBe(vertical)
85
+ })
86
+ })
@@ -28,15 +28,22 @@ describe('WellPlate per-well decoration', () => {
28
28
  .toEqual(['2px', 'solid', 'var(--mint-warning)'])
29
29
  })
30
30
 
31
- it('should render no glyph when a QC well has no label or id', () => {
31
+ it.each([
32
+ ['qc', {}, 'Q'],
33
+ ['iqc', {}, 'i'],
34
+ ['sample', {}, ''],
35
+ ['qc', { showWellIds: true }, 'A1'],
36
+ ] as const)('should render the %s glyph when the well has no label (spec %o)', (sampleType, spec, text) => {
32
37
  const wrapper = mount(WellPlate, {
33
38
  props: {
34
- spec: { format: 6 },
35
- wells: { A1: { id: 'A1', row: 0, col: 0, state: 'filled', sampleType: 'qc' } },
39
+ spec: { format: 6, ...spec },
40
+ wells: { A1: { id: 'A1', row: 0, col: 0, state: 'filled', sampleType } },
36
41
  },
37
42
  })
38
43
 
39
- expect(wrapper.findAll('.mint-well-plate__well')[0].text()).toBe('')
44
+ const well = wrapper.findAll('.mint-well-plate__well')[0]
45
+ expect(well.text()).toBe(text)
46
+ expect(well.find('.mint-well-plate__indicator').exists()).toBe(text === 'Q' || text === 'i')
40
47
  })
41
48
 
42
49
  it('should draw plain legend swatches when the legend is shown', () => {
@@ -68,7 +68,7 @@ describe('root exports outside L1 and L2', () => {
68
68
 
69
69
  expect(values.sort()).toEqual([
70
70
  'defineControlModel', 'defineDataFrame', 'definePluginWorkspaceView', 'downloadBlob',
71
- 'resolvePluginFrontendBase', 'useExperiment', 'usePlatform', 'useRequest', 'useTheme', 'useToast',
71
+ 'providePluginIdentity', 'resolvePluginFrontendBase', 'useExperiment', 'usePlatform', 'useRequest', 'useTheme', 'useToast',
72
72
  ])
73
73
  })
74
74
 
@@ -490,6 +490,20 @@ describe('useAppExperiment', () => {
490
490
  expect(state.saveLoading.value).toBe(false)
491
491
  })
492
492
 
493
+ it('should show the refetched update time after a successful save', async () => {
494
+ const store = useExperimentStore()
495
+ useAppExperiment({ onSave: vi.fn().mockResolvedValue('Saved') })
496
+ const state = getState()
497
+ store.select(makeExperiment({ updated_at: '2025-01-01T00:00:00Z' }))
498
+
499
+ const fetch = stubExperimentFetch()
500
+ const saving = state.handleSave()
501
+ fetch.resolveWith(makeExperiment({ updated_at: '2026-10-28T09:30:00Z' }))
502
+ await saving
503
+
504
+ expect(state.popover.value.experimentUpdatedAt).toBe('2026-10-28T09:30:00Z')
505
+ })
506
+
493
507
  it('should not set saveSuccessMessage when onSave returns null', async () => {
494
508
  const onSave = vi.fn().mockResolvedValue(null)
495
509
  useAppExperiment({ onSave })
@@ -716,8 +730,9 @@ describe('useAppExperiment', () => {
716
730
  it('exposes live ExperimentPopover props from the provided state', async () => {
717
731
  const saveDisabled = ref(false)
718
732
  const saveDisabledMessage = ref<string | undefined>('Ready to save')
733
+ const dirty = ref(false)
719
734
  const onSave = vi.fn().mockResolvedValue('Saved')
720
- useAppExperiment({ onSave, saveDisabled, saveDisabledMessage })
735
+ useAppExperiment({ onSave, saveDisabled, saveDisabledMessage, dirty })
721
736
  const state = getState()
722
737
 
723
738
  useExperimentStore().select(makeExperiment({
@@ -725,26 +740,37 @@ describe('useAppExperiment', () => {
725
740
  name: 'Dose response',
726
741
  status: 'ongoing',
727
742
  experiment_code: 'EXP-012',
743
+ project: 'legacy-project',
744
+ project_name: 'Hypoxia atlas',
745
+ created_by_user: { id: 3, username: 'jdoe', first_name: 'Jane', last_name: 'Doe' },
746
+ updated_at: '2026-10-28T10:00:00Z',
728
747
  }))
729
748
 
730
749
  expect(state.popover.value).toMatchObject({
731
750
  experimentName: 'Dose response',
732
751
  experimentCode: 'EXP-012',
733
752
  experimentStatus: 'ongoing',
753
+ experimentId: 12,
754
+ experimentProject: 'Hypoxia atlas',
755
+ experimentOwner: 'Jane Doe',
756
+ experimentUpdatedAt: '2026-10-28T10:00:00Z',
734
757
  isResolving: false,
735
758
  showSave: true,
736
759
  showDetach: true,
737
760
  saveDisabled: false,
738
761
  saveDisabledMessage: 'Ready to save',
739
762
  saveLoading: false,
763
+ dirty: false,
740
764
  })
741
765
  expect(state.popover.value.error).toBeUndefined()
742
766
 
743
767
  saveDisabled.value = true
744
768
  saveDisabledMessage.value = 'Locked'
769
+ dirty.value = true
745
770
 
746
771
  expect(state.popover.value.saveDisabled).toBe(true)
747
772
  expect(state.popover.value.saveDisabledMessage).toBe('Locked')
773
+ expect(state.popover.value.dirty).toBe(true)
748
774
 
749
775
  await state.handleSave()
750
776
 
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
2
2
  import { useCalendarGrid } from '../../composables/useCalendarGrid'
3
3
 
4
4
  describe('useCalendarGrid', () => {
5
- it('builds a stable 42-day month grid with previous and next month padding', () => {
5
+ it('builds a stable Monday-first 42-day month grid with previous and next month padding', () => {
6
6
  const calendar = useCalendarGrid()
7
7
  calendar.setCurrentMonthFromDate(new Date('2026-05-07T12:00:00'))
8
8
 
9
- expect(calendar.weekDays).toEqual(['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'])
9
+ expect(calendar.weekDays).toEqual(['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'])
10
10
  expect(calendar.monthYear.value).toBe('May 2026')
11
11
  expect(calendar.calendarDays.value).toHaveLength(42)
12
12
  expect(calendar.calendarDays.value[0].date.getMonth()).toBe(3)
13
+ expect(calendar.calendarDays.value[0].date.getDate()).toBe(27)
14
+ expect(calendar.calendarDays.value[0].date.getDay()).toBe(1)
13
15
  expect(calendar.calendarDays.value.some(day => day.isCurrentMonth && day.date.getDate() === 7)).toBe(true)
14
16
  })
15
17
 
@@ -194,6 +194,117 @@ describe('useExperimentSelector', () => {
194
194
  ])
195
195
  })
196
196
 
197
+ it('should send the scope filters but not status to the stats route when counting', async () => {
198
+ fakeResponse = { total: 3, by_status: { planned: 1, ongoing: 2, completed: 0, cancelled: 0 } } as never
199
+ const hook = useExperimentSelector()
200
+ Object.assign(hook.filters, { search: 'glu', project: 'Screen', mine: true, status: 'planned' })
201
+
202
+ await hook.fetchStatusCounts()
203
+
204
+ const url = new URL(recordedUrls[0], 'http://mint.test')
205
+ expect(url.pathname).toBe('/experiments/stats')
206
+ expect(Object.fromEntries(url.searchParams)).toEqual({ search: 'glu', project: 'Screen', mine: 'true' })
207
+ expect(hook.statusCounts.value).toEqual({ planned: 1, ongoing: 2, completed: 0, cancelled: 0 })
208
+ })
209
+
210
+ it('should send mine=true on the list request when the mine filter is set', async () => {
211
+ const hook = useExperimentSelector()
212
+ hook.filters.mine = true
213
+
214
+ await hook.fetch()
215
+
216
+ expect(recordedUrls[0]).toContain('mine=true')
217
+ })
218
+
219
+ it('should sum the per-type stats when several experiment types are allowed', async () => {
220
+ vi.spyOn(useApi().client, 'get').mockImplementation(async (url: string) => {
221
+ recordedUrls.push(url)
222
+ const type = new URL(url, 'http://mint.test').searchParams.get('experiment_type')
223
+ return { data: { total: 2, by_status: { planned: type === 'drug-response' ? 2 : 0, ongoing: type === 'metabolomics' ? 2 : 0 } } }
224
+ })
225
+ const hook = useExperimentSelector({ allowedExperimentTypes: ['drug-response', 'metabolomics'] })
226
+
227
+ await hook.fetchStatusCounts()
228
+
229
+ expect(recordedUrls).toHaveLength(2)
230
+ expect(hook.statusCounts.value).toEqual({ planned: 2, ongoing: 2 })
231
+ })
232
+
233
+ it('should not query the stats route when the selected type is not allowed', async () => {
234
+ const hook = useExperimentSelector({ experimentType: 'proteomics', allowedExperimentTypes: ['drug-response'] })
235
+
236
+ await hook.fetchStatusCounts()
237
+
238
+ expect(recordedUrls).toEqual([])
239
+ expect(hook.statusCounts.value).toEqual({})
240
+ })
241
+
242
+ it('should refetch status counts with the list when a filter or the search changes and counts are on', async () => {
243
+ vi.useFakeTimers()
244
+ const hook = useExperimentSelector({ withStatusCounts: true })
245
+ const statsUrls = () => recordedUrls.filter(url => url.includes('/experiments/stats'))
246
+
247
+ hook.filters.project = 'Screen'
248
+ await nextTick()
249
+
250
+ expect(statsUrls()).toHaveLength(1)
251
+ expect(recordedUrls.filter(url => url.includes('/experiments?'))).toHaveLength(1)
252
+
253
+ hook.filters.search = 'glu'
254
+ await vi.advanceTimersByTimeAsync(300)
255
+
256
+ expect(statsUrls()).toHaveLength(2)
257
+ expect(statsUrls()[1]).toContain('search=glu')
258
+ })
259
+
260
+ it.each([
261
+ ['status', (hook: ReturnType<typeof useExperimentSelector>) => { hook.filters.status = 'planned' }, 1],
262
+ ['sort', (hook: ReturnType<typeof useExperimentSelector>) => { hook.sortKey.value = 'name:asc' }, 1],
263
+ ['project', (hook: ReturnType<typeof useExperimentSelector>) => { hook.filters.project = 'Screen' }, 2],
264
+ ] as const)('should refetch status counts on a %s change only when it changes the counts', async (_name, change, statsCalls) => {
265
+ // One body that serves both routes, so the first stats request succeeds.
266
+ fakeResponse = { ...fakeResponse, by_status: { planned: 1 } } as never
267
+ const hook = useExperimentSelector({ withStatusCounts: true })
268
+ await hook.fetchStatusCounts()
269
+
270
+ change(hook)
271
+ await nextTick()
272
+
273
+ expect(recordedUrls.filter(url => url.includes('/experiments/stats'))).toHaveLength(statsCalls)
274
+ })
275
+
276
+ it('should refetch status counts on a status change when the previous stats request failed', async () => {
277
+ const stats = vi.fn()
278
+ .mockRejectedValueOnce(new Error('offline'))
279
+ .mockResolvedValue({ data: { total: 1, by_status: { planned: 1 } } })
280
+ vi.spyOn(useApi().client, 'get').mockImplementation(async (url: string) =>
281
+ url.includes('/experiments/stats') ? stats() : { data: fakeResponse })
282
+ const hook = useExperimentSelector({ withStatusCounts: true })
283
+ await hook.fetchStatusCounts()
284
+ expect(hook.statusCounts.value).toEqual({})
285
+
286
+ hook.filters.status = 'planned'
287
+ await nextTick()
288
+
289
+ expect(stats).toHaveBeenCalledTimes(2)
290
+ await vi.waitFor(() => expect(hook.statusCounts.value).toEqual({ planned: 1 }))
291
+ })
292
+
293
+ it('should refetch status counts when a status change flushes a pending search', async () => {
294
+ vi.useFakeTimers()
295
+ const hook = useExperimentSelector({ withStatusCounts: true })
296
+ await hook.fetchStatusCounts()
297
+ const statsUrls = () => recordedUrls.filter(url => url.includes('/experiments/stats'))
298
+
299
+ hook.filters.search = 'glu'
300
+ await nextTick()
301
+ hook.filters.status = 'planned'
302
+ await nextTick()
303
+
304
+ expect(statsUrls()).toHaveLength(2)
305
+ expect(statsUrls()[1]).toContain('search=glu')
306
+ })
307
+
197
308
  it('loadMore appends the next page', async () => {
198
309
  const hook = useExperimentSelector({ limit: 1 })
199
310