@morscherlab/mint-sdk 1.4.0-beta.9 → 1.4.1

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 (198) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-DwgNTEqf.js} +3 -3
  3. package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  4. package/dist/{AppSidebar-Dq9W1jE2.js → AppSidebar-CZZaxni0.js} +1103 -144
  5. package/dist/AppSidebar-CZZaxni0.js.map +1 -0
  6. package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  7. package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  8. package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-DujiuQIO.js} +5 -5
  9. package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  10. package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-B4j4eUOJ.js} +114 -98
  11. package/dist/ExperimentSelectorModal-B4j4eUOJ.js.map +1 -0
  12. package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-DyUPH3I5.js} +2 -2
  13. package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  14. package/dist/{SettingsModal-483nyI6b.js → SettingsModal-D2MOOtuO.js} +26 -25
  15. package/dist/SettingsModal-D2MOOtuO.js.map +1 -0
  16. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  17. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  18. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  20. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  21. package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
  22. package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
  23. package/dist/components/AppTopBar.vue.d.ts +20 -5
  24. package/dist/components/BaseButton.vue.d.ts +4 -2
  25. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  26. package/dist/components/JobsStatusTray.vue.d.ts +15 -4
  27. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  28. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  29. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -3
  30. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  31. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  32. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  33. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  34. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  35. package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
  36. package/dist/composables/useFileImport.d.ts +7 -0
  37. package/dist/composables/useFloatingPanel.d.ts +29 -0
  38. package/dist/composables/useJobCenter.d.ts +41 -3
  39. package/dist/composables/useOnboarding.d.ts +119 -0
  40. package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  41. package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  42. package/dist/{data-CgILxDLW.js → data-Boeh4uPI.js} +13 -12
  43. package/dist/{data-CgILxDLW.js.map → data-Boeh4uPI.js.map} +1 -1
  44. package/dist/data.d.ts +1 -1
  45. package/dist/data.js +9 -9
  46. package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
  47. package/dist/experiment-DyO54FQB.js.map +1 -0
  48. package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-DCn2QKG7.js} +2 -2
  49. package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  50. package/dist/index.js +20 -20
  51. package/dist/{install-BtLWTk6H.js → install-BEHp7Mo-.js} +15 -15
  52. package/dist/{install-BtLWTk6H.js.map → install-BEHp7Mo-.js.map} +1 -1
  53. package/dist/install.js +1 -1
  54. package/dist/{lab-MHzp-_J8.js → lab-D61eaX6f.js} +68 -61
  55. package/dist/{lab-MHzp-_J8.js.map → lab-D61eaX6f.js.map} +1 -1
  56. package/dist/lab.js +4 -4
  57. package/dist/{records-B6gpM_3e.js → records-D4aQXyZu.js} +193 -133
  58. package/dist/records-D4aQXyZu.js.map +1 -0
  59. package/dist/{runtime-7vfl_bI3.js → runtime-q-R0CNLh.js} +400 -33
  60. package/dist/runtime-q-R0CNLh.js.map +1 -0
  61. package/dist/runtime.d.ts +1 -1
  62. package/dist/runtime.js +7 -7
  63. package/dist/{shell-Cs9OJMnm.js → shell-6CGFvUUw.js} +10 -5
  64. package/dist/{shell-Cs9OJMnm.js.map → shell-6CGFvUUw.js.map} +1 -1
  65. package/dist/shell.d.ts +1 -0
  66. package/dist/shell.js +6 -6
  67. package/dist/stores/index.d.ts +1 -1
  68. package/dist/stores/index.js +2 -2
  69. package/dist/stores/settings.d.ts +4 -12
  70. package/dist/styles.css +1191 -479
  71. package/dist/types/components.d.ts +2 -0
  72. package/dist/types/index.d.ts +1 -1
  73. package/dist/types/jobs.d.ts +9 -0
  74. package/dist/types/platform.d.ts +1 -1
  75. package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-uFIz6Yv8.js} +16 -4
  76. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  77. package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-QEFoq4tf.js} +48 -6
  78. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  79. package/dist/{useListReorder-BitdUhxn.js → useListReorder-DsuzwWGS.js} +122 -172
  80. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  81. package/dist/{useRackEditor-DiwCt77M.js → useRackEditor-KpZ4iJ6o.js} +22 -32
  82. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  83. package/dist/useRealtime-B58Lo-Tm.js +508 -0
  84. package/dist/useRealtime-B58Lo-Tm.js.map +1 -0
  85. package/dist/{useRequest-D506eHi8.js → useRequest-BuDmW8Jc.js} +8 -37
  86. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  87. package/package.json +3 -2
  88. package/src/__stories__/experiment-helpers.ts +3 -3
  89. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  90. package/src/__tests__/components/AppTopBar.test.ts +206 -1
  91. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  92. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  93. package/src/__tests__/components/BaseButton.test.ts +19 -0
  94. package/src/__tests__/components/BaseModal.test.ts +13 -0
  95. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  96. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  98. package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
  99. package/src/__tests__/components/PluginWorkspaceView.test.ts +63 -1
  100. package/src/__tests__/components/SampleSelector.test.ts +20 -0
  101. package/src/__tests__/components/SettingsModal.test.ts +9 -2
  102. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  103. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  104. package/src/__tests__/composables/useJobCenter.test.ts +262 -3
  105. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  106. package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
  107. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  108. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  109. package/src/__tests__/stores/settings.test.ts +21 -21
  110. package/src/__tests__/styles/cssTokens.test.ts +4 -1
  111. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  112. package/src/__tests__/styles/paletteTokens.test.ts +44 -0
  113. package/src/__tests__/styles/v2AuditRules.test.ts +104 -0
  114. package/src/components/ActionMenu.vue +6 -37
  115. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  116. package/src/components/AppTopBar.story.vue +59 -4
  117. package/src/components/AppTopBar.vue +132 -10
  118. package/src/components/BaseButton.vue +16 -2
  119. package/src/components/BaseModal.vue +2 -1
  120. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  121. package/src/components/ExperimentSelectorModal.vue +20 -1
  122. package/src/components/GeneratedPluginView.vue +4 -9
  123. package/src/components/GeneratedResultRenderer.vue +3 -4
  124. package/src/components/JobsStatusTray.story.vue +97 -43
  125. package/src/components/JobsStatusTray.vue +287 -290
  126. package/src/components/LayoutResizeHandle.story.vue +65 -4
  127. package/src/components/PluginWorkspaceView.props.ts +3 -0
  128. package/src/components/PluginWorkspaceView.vue +17 -0
  129. package/src/components/SampleSelector.story.vue +21 -6
  130. package/src/components/SampleSelector.vue +10 -3
  131. package/src/components/SectionCard.story.vue +33 -0
  132. package/src/components/SettingsModal.vue +7 -12
  133. package/src/components/Tooltip.vue +9 -32
  134. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  135. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  136. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  137. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  138. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  139. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  140. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  141. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  142. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  143. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  144. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  145. package/src/composables/pluginWorkspaceModel.ts +2 -2
  146. package/src/composables/useFileImport.ts +51 -1
  147. package/src/composables/useFloatingPanel.ts +107 -0
  148. package/src/composables/useJobCenter.ts +188 -6
  149. package/src/composables/useOnboarding.ts +465 -0
  150. package/src/composables/usePluginWorkspace.ts +1 -1
  151. package/src/data.ts +1 -0
  152. package/src/runtime.ts +2 -1
  153. package/src/shell.ts +17 -0
  154. package/src/stores/index.ts +0 -2
  155. package/src/stores/settings.ts +20 -50
  156. package/src/styles/components/action-menu.css +1 -1
  157. package/src/styles/components/app-layout.css +6 -3
  158. package/src/styles/components/app-page-selector.css +120 -45
  159. package/src/styles/components/app-top-bar.css +165 -3
  160. package/src/styles/components/button.css +8 -0
  161. package/src/styles/components/dataframe.css +1 -1
  162. package/src/styles/components/date-picker.css +4 -2
  163. package/src/styles/components/datetime-picker.css +4 -4
  164. package/src/styles/components/experiment-selector-modal.css +13 -0
  165. package/src/styles/components/jobs-status-tray.css +134 -296
  166. package/src/styles/components/layout-resize-handle.css +59 -16
  167. package/src/styles/components/modal.css +3 -3
  168. package/src/styles/components/number-input.css +1 -1
  169. package/src/styles/components/onboarding.css +368 -0
  170. package/src/styles/components/progress-bar.css +3 -3
  171. package/src/styles/components/sample-selector.css +43 -0
  172. package/src/styles/components/searchable-select.css +3 -7
  173. package/src/styles/components/section-card.css +30 -6
  174. package/src/styles/components/settings-modal.css +7 -2
  175. package/src/styles/components/time-picker.css +3 -6
  176. package/src/styles/components/toast.css +1 -1
  177. package/src/styles/components/tooltip.css +2 -2
  178. package/src/styles/components/unit-input.css +1 -1
  179. package/src/styles/components/well-edit-popup.css +2 -2
  180. package/src/styles/index.css +1 -0
  181. package/src/styles/variables.css +153 -15
  182. package/src/types/components.ts +2 -0
  183. package/src/types/index.ts +1 -0
  184. package/src/types/jobs.ts +10 -0
  185. package/src/types/platform.ts +1 -1
  186. package/dist/AppSidebar-Dq9W1jE2.js.map +0 -1
  187. package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
  188. package/dist/SettingsModal-483nyI6b.js.map +0 -1
  189. package/dist/experiment-43OJtH7R.js.map +0 -1
  190. package/dist/records-B6gpM_3e.js.map +0 -1
  191. package/dist/runtime-7vfl_bI3.js.map +0 -1
  192. package/dist/useDropdownState-8yWMu-7_.js.map +0 -1
  193. package/dist/useExperimentSamples-DHnxKFAF.js.map +0 -1
  194. package/dist/useListReorder-BitdUhxn.js.map +0 -1
  195. package/dist/useRackEditor-DiwCt77M.js.map +0 -1
  196. package/dist/useRealtime-C3iy9QkU.js +0 -699
  197. package/dist/useRealtime-C3iy9QkU.js.map +0 -1
  198. package/dist/useRequest-D506eHi8.js.map +0 -1
@@ -1,9 +1,8 @@
1
- import { a as BaseButton_default, t as useDropdownState } from "./useDropdownState-8yWMu-7_.js";
2
- import { r as readErrorMessage } from "./experiment-43OJtH7R.js";
3
- import { E as useMenuKeyboard } from "./records-B6gpM_3e.js";
4
- import { Fragment, Teleport, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onMounted, openBlock, ref, renderList, renderSlot, toDisplayString, unref, watch } from "vue";
1
+ import { a as BaseButton_default, t as useDropdownState } from "./useDropdownState-uFIz6Yv8.js";
2
+ import { r as readErrorMessage } from "./experiment-DyO54FQB.js";
3
+ import { E as useMenuKeyboard, W as useFloatingPanel } from "./records-D4aQXyZu.js";
4
+ import { Fragment, Teleport, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onMounted, openBlock, ref, renderList, renderSlot, toDisplayString, unref } from "vue";
5
5
  import { Check, Ellipsis } from "lucide-vue-next";
6
- import { computePosition, flip, offset, shift } from "@floating-ui/dom";
7
6
  //#region src/components/ActionMenu.vue?vue&type=script&setup=true&lang.ts
8
7
  var _hoisted_1 = {
9
8
  key: 0,
@@ -44,16 +43,12 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
44
43
  const props = __props;
45
44
  const emit = __emit;
46
45
  const panelRef = ref(null);
47
- const panelStyle = ref({ visibility: "hidden" });
48
46
  const { isOpen, rootRef, triggerRef, open, close } = useDropdownState({
49
- closeOnScroll: true,
50
47
  insideRefs: [panelRef],
51
- onOpen: () => {
52
- panelStyle.value = { visibility: "hidden" };
53
- emit("open");
54
- },
48
+ onOpen: () => emit("open"),
55
49
  onClose: () => emit("close")
56
50
  });
51
+ const { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: "bottom-end" });
57
52
  onMounted(() => {
58
53
  triggerRef.value = rootRef.value?.querySelector("button") ?? null;
59
54
  });
@@ -61,25 +56,6 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
61
56
  if (!panelRef.value) return [];
62
57
  return Array.from(panelRef.value.querySelectorAll(".mint-action-menu__item:not(:disabled)"));
63
58
  }
64
- async function positionPanel() {
65
- const trigger = triggerRef.value;
66
- const panel = panelRef.value;
67
- if (!trigger || !panel) return;
68
- const position = await computePosition(trigger, panel, {
69
- placement: "bottom-end",
70
- strategy: "fixed",
71
- middleware: [
72
- offset(4),
73
- flip(),
74
- shift({ padding: 8 })
75
- ]
76
- });
77
- panelStyle.value = {
78
- left: `${position.x}px`,
79
- top: `${position.y}px`,
80
- visibility: "visible"
81
- };
82
- }
83
59
  /** `focusFirst` is false when the keyboard composable owns the initial focus. */
84
60
  function openMenu(focusFirst = true) {
85
61
  if (props.disabled || isOpen.value) return;
@@ -102,11 +78,6 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
102
78
  emit("select", item);
103
79
  close();
104
80
  }
105
- watch(isOpen, async (opened) => {
106
- if (!opened) return;
107
- await nextTick();
108
- await positionPanel();
109
- });
110
81
  return (_ctx, _cache) => {
111
82
  return openBlock(), createElementBlock(Fragment, null, [createElementVNode("span", {
112
83
  ref_key: "rootRef",
@@ -137,7 +108,7 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
137
108
  ref_key: "panelRef",
138
109
  ref: panelRef,
139
110
  class: "mint-popover mint-action-menu__panel",
140
- style: normalizeStyle(panelStyle.value),
111
+ style: normalizeStyle(unref(panelStyle)),
141
112
  role: "menu",
142
113
  onKeydown: _cache[0] || (_cache[0] = (...args) => unref(handlePanelKeydown) && unref(handlePanelKeydown)(...args))
143
114
  }, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.items, (item, index) => {
@@ -241,4 +212,4 @@ function useRequest(defaultErrorMessage = "Request failed.") {
241
212
  //#endregion
242
213
  export { ActionMenu_default as n, useRequest as t };
243
214
 
244
- //# sourceMappingURL=useRequest-D506eHi8.js.map
215
+ //# sourceMappingURL=useRequest-BuDmW8Jc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRequest-BuDmW8Jc.js","names":[],"sources":["../src/components/ActionMenu.vue","../src/components/ActionMenu.vue","../src/composables/useRequest.ts"],"sourcesContent":["<script setup lang=\"ts\">\n/** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */\nimport { Check, Ellipsis } from 'lucide-vue-next'\nimport { nextTick, onMounted, ref } from 'vue'\nimport { useDropdownState } from '../composables/useDropdownState'\nimport { useFloatingPanel } from '../composables/useFloatingPanel'\nimport { useMenuKeyboard } from '../composables/useMenuKeyboard'\nimport type { ActionMenuItem } from '../types/components'\nimport BaseButton from './BaseButton.vue'\n\ninterface Props {\n label: string\n items: ActionMenuItem[]\n disabled?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n disabled: false,\n})\n\nconst emit = defineEmits<{\n select: [item: ActionMenuItem]\n open: []\n close: []\n}>()\n\ndefineSlots<{\n /** Replaces the ellipsis button; `toggle` and `keydown` go on the slotted button. */\n trigger?: (props: { open: boolean; toggle: () => void; keydown: (event: KeyboardEvent) => void }) => unknown\n}>()\n\nconst panelRef = ref<HTMLElement | null>(null)\n\n// Disclosure state (outside-click, Escape, focus return) and placement come from\n// the shared composables; the open menu follows its trigger on scroll.\nconst { isOpen, rootRef, triggerRef, open, close } = useDropdownState({\n insideRefs: [panelRef],\n onOpen: () => emit('open'),\n onClose: () => emit('close'),\n})\nconst { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })\n\n// BaseButton owns the actual <button>, so the trigger element is resolved from\n// the wrapper once mounted rather than bound with a template ref.\nonMounted(() => {\n triggerRef.value = rootRef.value?.querySelector('button') ?? null\n})\n\nfunction menuItems(): HTMLElement[] {\n if (!panelRef.value) return []\n return Array.from(\n panelRef.value.querySelectorAll<HTMLElement>('.mint-action-menu__item:not(:disabled)'),\n )\n}\n\n/** `focusFirst` is false when the keyboard composable owns the initial focus. */\nfunction openMenu(focusFirst = true) {\n if (props.disabled || isOpen.value) return\n open()\n if (focusFirst) {\n void nextTick(() => menuItems()[0]?.focus())\n }\n}\n\nfunction toggleMenu() {\n if (props.disabled) return\n if (isOpen.value) close()\n else openMenu()\n}\n\nconst { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({\n items: menuItems,\n isOpen: () => isOpen.value,\n open: () => openMenu(false),\n close,\n})\n\nfunction handleSelect(item: ActionMenuItem) {\n if (item.disabled) return\n emit('select', item)\n close()\n}\n</script>\n\n<template>\n <span ref=\"rootRef\" class=\"mint-action-menu\">\n <!-- A custom trigger must render one <button> and wire toggle + keydown to it. -->\n <slot name=\"trigger\" :open=\"isOpen\" :toggle=\"toggleMenu\" :keydown=\"handleTriggerKeydown\">\n <BaseButton\n variant=\"secondary\"\n size=\"sm\"\n :icon=\"Ellipsis\"\n :label=\"label\"\n :disabled=\"disabled\"\n aria-haspopup=\"menu\"\n :aria-expanded=\"isOpen\"\n @click=\"toggleMenu\"\n @keydown=\"handleTriggerKeydown\"\n />\n </slot>\n </span>\n\n <Teleport to=\"body\">\n <div\n v-if=\"isOpen\"\n ref=\"panelRef\"\n class=\"mint-popover mint-action-menu__panel\"\n :style=\"panelStyle\"\n role=\"menu\"\n @keydown=\"handlePanelKeydown\"\n >\n <template v-for=\"(item, index) in items\" :key=\"item.id\">\n <div\n v-if=\"item.danger && index > 0 && !items[index - 1].danger\"\n class=\"mint-menu-separator\"\n role=\"separator\"\n />\n <a\n v-if=\"item.href !== undefined && !item.disabled\"\n :href=\"item.href\"\n :target=\"item.target\"\n :rel=\"item.target === '_blank' ? 'noopener noreferrer' : undefined\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n role=\"menuitem\"\n @click=\"handleSelect(item)\"\n >\n {{ item.label }}\n </a>\n <button\n v-else\n type=\"button\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n :role=\"item.checked === undefined ? 'menuitem' : 'menuitemradio'\"\n :aria-checked=\"item.checked\"\n :disabled=\"item.disabled\"\n @click=\"handleSelect(item)\"\n >\n <Check\n v-if=\"item.checked !== undefined\"\n class=\"mint-action-menu__check\"\n :class=\"{ 'mint-action-menu__check--off': !item.checked }\"\n :size=\"14\"\n aria-hidden=\"true\"\n />\n {{ item.label }}\n </button>\n </template>\n </div>\n </Teleport>\n</template>\n","<script setup lang=\"ts\">\n/** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */\nimport { Check, Ellipsis } from 'lucide-vue-next'\nimport { nextTick, onMounted, ref } from 'vue'\nimport { useDropdownState } from '../composables/useDropdownState'\nimport { useFloatingPanel } from '../composables/useFloatingPanel'\nimport { useMenuKeyboard } from '../composables/useMenuKeyboard'\nimport type { ActionMenuItem } from '../types/components'\nimport BaseButton from './BaseButton.vue'\n\ninterface Props {\n label: string\n items: ActionMenuItem[]\n disabled?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n disabled: false,\n})\n\nconst emit = defineEmits<{\n select: [item: ActionMenuItem]\n open: []\n close: []\n}>()\n\ndefineSlots<{\n /** Replaces the ellipsis button; `toggle` and `keydown` go on the slotted button. */\n trigger?: (props: { open: boolean; toggle: () => void; keydown: (event: KeyboardEvent) => void }) => unknown\n}>()\n\nconst panelRef = ref<HTMLElement | null>(null)\n\n// Disclosure state (outside-click, Escape, focus return) and placement come from\n// the shared composables; the open menu follows its trigger on scroll.\nconst { isOpen, rootRef, triggerRef, open, close } = useDropdownState({\n insideRefs: [panelRef],\n onOpen: () => emit('open'),\n onClose: () => emit('close'),\n})\nconst { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })\n\n// BaseButton owns the actual <button>, so the trigger element is resolved from\n// the wrapper once mounted rather than bound with a template ref.\nonMounted(() => {\n triggerRef.value = rootRef.value?.querySelector('button') ?? null\n})\n\nfunction menuItems(): HTMLElement[] {\n if (!panelRef.value) return []\n return Array.from(\n panelRef.value.querySelectorAll<HTMLElement>('.mint-action-menu__item:not(:disabled)'),\n )\n}\n\n/** `focusFirst` is false when the keyboard composable owns the initial focus. */\nfunction openMenu(focusFirst = true) {\n if (props.disabled || isOpen.value) return\n open()\n if (focusFirst) {\n void nextTick(() => menuItems()[0]?.focus())\n }\n}\n\nfunction toggleMenu() {\n if (props.disabled) return\n if (isOpen.value) close()\n else openMenu()\n}\n\nconst { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({\n items: menuItems,\n isOpen: () => isOpen.value,\n open: () => openMenu(false),\n close,\n})\n\nfunction handleSelect(item: ActionMenuItem) {\n if (item.disabled) return\n emit('select', item)\n close()\n}\n</script>\n\n<template>\n <span ref=\"rootRef\" class=\"mint-action-menu\">\n <!-- A custom trigger must render one <button> and wire toggle + keydown to it. -->\n <slot name=\"trigger\" :open=\"isOpen\" :toggle=\"toggleMenu\" :keydown=\"handleTriggerKeydown\">\n <BaseButton\n variant=\"secondary\"\n size=\"sm\"\n :icon=\"Ellipsis\"\n :label=\"label\"\n :disabled=\"disabled\"\n aria-haspopup=\"menu\"\n :aria-expanded=\"isOpen\"\n @click=\"toggleMenu\"\n @keydown=\"handleTriggerKeydown\"\n />\n </slot>\n </span>\n\n <Teleport to=\"body\">\n <div\n v-if=\"isOpen\"\n ref=\"panelRef\"\n class=\"mint-popover mint-action-menu__panel\"\n :style=\"panelStyle\"\n role=\"menu\"\n @keydown=\"handlePanelKeydown\"\n >\n <template v-for=\"(item, index) in items\" :key=\"item.id\">\n <div\n v-if=\"item.danger && index > 0 && !items[index - 1].danger\"\n class=\"mint-menu-separator\"\n role=\"separator\"\n />\n <a\n v-if=\"item.href !== undefined && !item.disabled\"\n :href=\"item.href\"\n :target=\"item.target\"\n :rel=\"item.target === '_blank' ? 'noopener noreferrer' : undefined\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n role=\"menuitem\"\n @click=\"handleSelect(item)\"\n >\n {{ item.label }}\n </a>\n <button\n v-else\n type=\"button\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n :role=\"item.checked === undefined ? 'menuitem' : 'menuitemradio'\"\n :aria-checked=\"item.checked\"\n :disabled=\"item.disabled\"\n @click=\"handleSelect(item)\"\n >\n <Check\n v-if=\"item.checked !== undefined\"\n class=\"mint-action-menu__check\"\n :class=\"{ 'mint-action-menu__check--off': !item.checked }\"\n :size=\"14\"\n aria-hidden=\"true\"\n />\n {{ item.label }}\n </button>\n </template>\n </div>\n </Teleport>\n</template>\n","import { ref, type Ref } from 'vue'\nimport { readErrorMessage as readSharedErrorMessage } from '../utils/errors'\n\nexport type RequestKind = 'load' | 'save' | 'run'\n\n/**\n * The unit of work `run()` drives.\n *\n * It receives an `AbortSignal` that fires when the run is superseded by a\n * newer `run()` or by `cancel()`. Zero-argument operations stay valid and\n * simply ignore it.\n */\nexport type RequestOperation<T> = (signal: AbortSignal) => Promise<T>\n\nexport interface RequestRunOptions {\n /** Which timestamp a successful run moves: `lastLoadedAt`, `lastSavedAt` or `lastRunAt`. */\n kind?: RequestKind\n errorMessage?: string\n /**\n * Abort the previously started run when this one starts. Default `true`;\n * pass `false` to let overlapping runs finish their own work (the stale\n * guard on shared state still applies either way).\n */\n abortPrevious?: boolean\n /** Caller-owned signal; aborting it aborts this run's signal too. */\n signal?: AbortSignal\n}\n\nexport interface UseRequestReturn {\n /** Whether a request is in progress. */\n isLoading: Ref<boolean>\n /** Message from the last failed request, or null. */\n error: Ref<string | null>\n /** Timestamp of the last successful load operation, or null. */\n lastLoadedAt: Ref<Date | null>\n /** Timestamp of the last successful save operation, or null. */\n lastSavedAt: Ref<Date | null>\n /** Timestamp of the last successful run operation, or null. */\n lastRunAt: Ref<Date | null>\n /** Clear the current error without changing loading or timestamps. */\n clearError: () => void\n /** Convert thrown values into a developer-facing error message. */\n readErrorMessage: (value: unknown, fallback?: string) => string\n /** Store and return a normalized error message. */\n setError: (value: unknown, fallback?: string) => string\n /**\n * Run a request with shared loading/error handling and optional sync marking.\n *\n * ## Stale-response semantics\n *\n * Every call takes a monotonically increasing token. Starting a run\n * supersedes any run started before it, and a superseded run writes **no**\n * shared state when it finally settles: it does not clear `isLoading`, does not\n * set `error`, and does not move `lastLoadedAt` / `lastSavedAt` / `lastRunAt`.\n * Only the newest run owns those.\n *\n * The caller's own promise is untouched by the guard — a superseded run still\n * resolves with its value or rejects with its own error, exactly as the\n * operation did. The guard never invents a rejection, so a caller awaiting\n * `run()` never sees a \"superseded\" error it did not cause.\n *\n * The operation additionally receives an `AbortSignal` that fires when its\n * run is superseded or `cancel()` is called, so an operation that forwards\n * the signal (`fetch(url, { signal })`) stops work instead of racing.\n */\n run: <T>(operation: RequestOperation<T>, options?: RequestRunOptions) => Promise<T>\n /**\n * Abort the in-flight run and drop `isLoading`. The aborted run's completion\n * is treated as stale and writes no state.\n */\n cancel: () => void\n}\n\n/** Loading, error and timestamp state for one request at a time; `run()` drives it. */\nexport function useRequest(\n defaultErrorMessage = 'Request failed.',\n): UseRequestReturn {\n const isLoading = ref(false)\n const error = ref<string | null>(null)\n const lastLoadedAt = ref<Date | null>(null)\n const lastSavedAt = ref<Date | null>(null)\n const lastRunAt = ref<Date | null>(null)\n\n function clearError(): void {\n error.value = null\n }\n\n function readErrorMessage(value: unknown, fallback = defaultErrorMessage): string {\n return readSharedErrorMessage(value, fallback)\n }\n\n function setError(value: unknown, fallback?: string): string {\n const message = readErrorMessage(value, fallback)\n error.value = message\n return message\n }\n\n // Monotonic token guarding every write to the shared loading/error/timestamp\n // state. Overlapping runs settle in any order, and a superseded one must not\n // clear a newer run's loading flag or overwrite its error.\n let runToken = 0\n let activeController: AbortController | null = null\n\n function cancel(): void {\n runToken += 1\n activeController?.abort()\n activeController = null\n isLoading.value = false\n }\n\n async function run<T>(\n operation: RequestOperation<T>,\n options: RequestRunOptions = {},\n ): Promise<T> {\n if (options.abortPrevious !== false) {\n activeController?.abort()\n }\n\n const token = (runToken += 1)\n const controller = new AbortController()\n activeController = controller\n\n const callerSignal = options.signal\n if (callerSignal) {\n if (callerSignal.aborted) controller.abort(callerSignal.reason)\n else callerSignal.addEventListener(\n 'abort',\n () => controller.abort(callerSignal.reason),\n { once: true },\n )\n }\n\n const isCurrent = (): boolean => token === runToken\n\n isLoading.value = true\n clearError()\n try {\n const response = await operation(controller.signal)\n if (isCurrent() && options.kind) {\n const settledAt = new Date()\n if (options.kind === 'load') lastLoadedAt.value = settledAt\n else if (options.kind === 'save') lastSavedAt.value = settledAt\n else lastRunAt.value = settledAt\n }\n return response\n } catch (err) {\n if (isCurrent()) {\n setError(err, options.errorMessage)\n }\n throw err\n } finally {\n if (isCurrent()) {\n isLoading.value = false\n }\n if (activeController === controller) {\n activeController = null\n }\n }\n }\n\n return {\n isLoading,\n error,\n lastLoadedAt,\n lastSavedAt,\n lastRunAt,\n clearError,\n readErrorMessage,\n setError,\n run,\n cancel,\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBA,MAAM,QAAQ;EAId,MAAM,OAAO;EAWb,MAAM,WAAW,IAAwB,IAAI;EAI7C,MAAM,EAAE,QAAQ,SAAS,YAAY,MAAM,UAAU,iBAAiB;GACpE,YAAY,CAAC,QAAQ;GACrB,cAAc,KAAK,MAAM;GACzB,eAAe,KAAK,OAAO;EAC7B,CAAC;EACD,MAAM,EAAE,OAAO,eAAe,iBAAiB,YAAY,UAAU,QAAQ,EAAE,WAAW,aAAa,CAAC;EAIxG,gBAAgB;GACd,WAAW,QAAQ,QAAQ,OAAO,cAAc,QAAQ,KAAK;EAC/D,CAAC;EAED,SAAS,YAA2B;GAClC,IAAI,CAAC,SAAS,OAAO,OAAO,CAAC;GAC7B,OAAO,MAAM,KACX,SAAS,MAAM,iBAA8B,wCAAwC,CACvF;EACF;;EAGA,SAAS,SAAS,aAAa,MAAM;GACnC,IAAI,MAAM,YAAY,OAAO,OAAO;GACpC,KAAK;GACL,IAAI,YACF,eAAoB,UAAU,CAAC,CAAC,EAAE,EAAE,MAAM,CAAC;EAE/C;EAEA,SAAS,aAAa;GACpB,IAAI,MAAM,UAAU;GACpB,IAAI,OAAO,OAAO,MAAM;QACnB,SAAS;EAChB;EAEA,MAAM,EAAE,sBAAsB,uBAAuB,gBAAgB;GACnE,OAAO;GACP,cAAc,OAAO;GACrB,YAAY,SAAS,KAAK;GAC1B;EACF,CAAC;EAED,SAAS,aAAa,MAAsB;GAC1C,IAAI,KAAK,UAAU;GACnB,KAAK,UAAU,IAAI;GACnB,MAAM;EACR;;GAIE,OAAA,UAAA,GAAA,mBAAA,UAAA,MAAA,CAAA,mBAeO,QAAA;IAfG,SAAA;IAAJ,KAAI;IAAU,OAAM;GAExB,GAAA,CAAA,WAYO,KAAA,QAAA,WAAA;IAZe,MAAM,MAAA,MAAA;IAAS,QAAQ;IAAa,SAAS,MAAA,oBAAA;GAAnE,SAYO,CAXL,YAUE,oBAAA;IATA,SAAQ;IACR,MAAK;IACJ,MAAM,MAAA,QAAA;IACN,OAAO,QAAA;IACP,UAAU,QAAA;IACX,iBAAc;IACb,iBAAe,MAAA,MAAA;IACf,SAAO;IACP,WAAS,MAAA,oBAAA;;;;;;;GAKhB,CAAA,CAAA,CAAA,CAAA,GAAA,GAAA,IAAA,UAAA,GAAA,YA8CW,UAAA,EA9CD,IAAG,OAAM,GAAA,CAET,MAAA,MAAA,KADR,UAAA,GAAA,mBA4CM,OAAA;;IA1CA,SAAA;IAAJ,KAAI;IACJ,OAAM;IACL,OAAK,eAAE,MAAA,UAAA,CAAU;IAClB,MAAK;IACJ,WAAO,OAAA,OAAA,OAAA,MAAE,GAAA,SAAA,MAAA,kBAAA,KAAA,MAAA,kBAAA,CAAA,CAAA,GAAA,IAAA;GAEV,GAAA,EAAA,UAAA,IAAA,GAAA,mBAmCW,UAAA,MAAA,WAnCuB,QAAA,QAAhB,MAAM,UAAK;IAAkB,OAAA,UAAA,GAAA,mBAAA,UAAA,EAAA,KAAA,KAAK,GAAA,GAAA,CAE1C,KAAK,UAAU,QAAK,KAAA,CAAS,QAAA,MAAM,QAAK,EAAA,CAAM,UADtD,UAAA,GAAA,mBAIE,OAJF,UAIE,KAAA,mBAAA,IAAA,IAAA,GAEM,KAAK,SAAS,KAAA,KAAS,CAAK,KAAK,YADzC,UAAA,GAAA,mBAUI,KAAA;;KARD,MAAM,KAAK;KACX,QAAQ,KAAK;KACb,KAAK,KAAK,WAAM,WAAA,wBAAwC,KAAA;KACxD,OAAK,eAAA,CAAA,yCAAA,EAAA,yDAAuG,KAAK,OAAM,CAAA,CAAA;KACxH,MAAK;KACJ,UAAK,WAAE,aAAa,IAAI;IAEtB,GAAA,gBAAA,KAAK,KAAK,GAAA,IAAA,UAAA,MAEf,UAAA,GAAA,mBAiBS,UAAA;;KAfP,MAAK;KACJ,OAAK,eAAA,CAAA,yCAAA,EAAA,yDAAuG,KAAK,OAAM,CAAA,CAAA;KACvH,MAAM,KAAK,YAAY,KAAA,IAAS,aAAA;KAChC,gBAAc,KAAK;KACnB,UAAU,KAAK;KACf,UAAK,WAAE,aAAa,IAAI;IAGjB,GAAA,CAAA,KAAK,YAAY,KAAA,KADzB,UAAA,GAAA,YAME,MAAA,KAAA,GAAA;;KAJA,OAAK,eAAA,CAAC,2BAAyB,EAAA,gCAAA,CACY,KAAK,QAAO,CAAA,CAAA;KACtD,MAAM;KACP,eAAY;IACZ,GAAA,MAAA,GAAA,CAAA,OAAA,CAAA,KAAA,mBAAA,IAAA,IAAA,GAAA,gBAAA,MACF,gBAAG,KAAK,KAAK,GAAA,CAAA,CAAA,GAAA,IAAA,UAAA,EAAA,GAAA,EAAA;;;;;;;;AEtEvB,SAAgB,WACd,sBAAsB,mBACJ;CAClB,MAAM,YAAY,IAAI,KAAK;CAC3B,MAAM,QAAQ,IAAmB,IAAI;CACrC,MAAM,eAAe,IAAiB,IAAI;CAC1C,MAAM,cAAc,IAAiB,IAAI;CACzC,MAAM,YAAY,IAAiB,IAAI;CAEvC,SAAS,aAAmB;EAC1B,MAAM,QAAQ;CAChB;CAEA,SAAS,mBAAiB,OAAgB,WAAW,qBAA6B;EAChF,OAAO,iBAAuB,OAAO,QAAQ;CAC/C;CAEA,SAAS,SAAS,OAAgB,UAA2B;EAC3D,MAAM,UAAU,mBAAiB,OAAO,QAAQ;EAChD,MAAM,QAAQ;EACd,OAAO;CACT;CAKA,IAAI,WAAW;CACf,IAAI,mBAA2C;CAE/C,SAAS,SAAe;EACtB,YAAY;EACZ,kBAAkB,MAAM;EACxB,mBAAmB;EACnB,UAAU,QAAQ;CACpB;CAEA,eAAe,IACb,WACA,UAA6B,CAAC,GAClB;EACZ,IAAI,QAAQ,kBAAkB,OAC5B,kBAAkB,MAAM;EAG1B,MAAM,QAAS,YAAY;EAC3B,MAAM,aAAa,IAAI,gBAAgB;EACvC,mBAAmB;EAEnB,MAAM,eAAe,QAAQ;EAC7B,IAAI,cAAc;GAChB,IAAI,aAAa,SAAS,WAAW,MAAM,aAAa,MAAM;QACzD,aAAa,iBAChB,eACM,WAAW,MAAM,aAAa,MAAM,GAC1C,EAAE,MAAM,KAAK,CACf;EACF;EAEA,MAAM,kBAA2B,UAAU;EAE3C,UAAU,QAAQ;EAClB,WAAW;EACX,IAAI;GACF,MAAM,WAAW,MAAM,UAAU,WAAW,MAAM;GAClD,IAAI,UAAU,KAAK,QAAQ,MAAM;IAC/B,MAAM,4BAAY,IAAI,KAAK;IAC3B,IAAI,QAAQ,SAAS,QAAQ,aAAa,QAAQ;SAC7C,IAAI,QAAQ,SAAS,QAAQ,YAAY,QAAQ;SACjD,UAAU,QAAQ;GACzB;GACA,OAAO;EACT,SAAS,KAAK;GACZ,IAAI,UAAU,GACZ,SAAS,KAAK,QAAQ,YAAY;GAEpC,MAAM;EACR,UAAU;GACR,IAAI,UAAU,GACZ,UAAU,QAAQ;GAEpB,IAAI,qBAAqB,YACvB,mBAAmB;EAEvB;CACF;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA,kBAAA;EACA;EACA;EACA;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.4.0-beta.9",
3
+ "version": "1.4.1",
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",
@@ -84,7 +84,8 @@
84
84
  "@floating-ui/dom": "^1.8.0",
85
85
  "axios": "1.20.0",
86
86
  "lucide-vue-next": "1.0.0",
87
- "plotly.js-dist-min": "^4.1.1"
87
+ "plotly.js-dist-min": "^4.1.1",
88
+ "read-excel-file": "9.3.14"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@histoire/plugin-vue": "1.0.0-beta.1",
@@ -65,19 +65,19 @@ export function installMockInterceptor() {
65
65
  * Props:
66
66
  * preselect (boolean, default true) - pre-select the first mock experiment
67
67
  * plugin (PluginInfo, optional) - the integrated plugin, shown as AppTopBar's static identity
68
+ * save (boolean, default true) - show the Save half of the experiment chip
68
69
  */
69
70
  export const ExperimentProvider = defineComponent({
70
71
  props: {
71
72
  preselect: { type: Boolean, default: true },
72
73
  plugin: { type: Object as PropType<PluginInfo>, default: undefined },
74
+ save: { type: Boolean, default: true },
73
75
  },
74
76
  setup(props, { slots }) {
75
77
  ;(window as any).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'system', plugin: props.plugin }
76
78
  installMockInterceptor()
77
79
  const experimentStore = useExperimentStore()
78
- useAppExperiment({
79
- onSave: async () => 'Saved!',
80
- })
80
+ useAppExperiment(props.save ? { onSave: async () => 'Saved!' } : {})
81
81
  if (props.preselect && mockExperiments[0]) {
82
82
  experimentStore.select(mockExperiments[0])
83
83
  }
@@ -132,7 +132,7 @@ describe('ActionMenu', () => {
132
132
  expect(document.body.querySelector('.mint-menu-separator')).toBeNull()
133
133
  })
134
134
 
135
- it('should close an open menu when its scroll context moves', async () => {
135
+ it('should keep an open menu open when its scroll context moves', async () => {
136
136
  const wrapper = mount(ActionMenu, {
137
137
  attachTo: document.body,
138
138
  props: { label: 'More user actions', items },
@@ -142,7 +142,7 @@ describe('ActionMenu', () => {
142
142
  window.dispatchEvent(new Event('scroll'))
143
143
  await wrapper.vm.$nextTick()
144
144
 
145
- expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
145
+ expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
146
146
  })
147
147
 
148
148
  it('should open from the trigger keyboard shortcuts and focus the first item', async () => {
@@ -12,6 +12,7 @@ import { defineControlModel, defineControls } from '../../composables/useControl
12
12
  import ThemeToggle from '../../components/ThemeToggle.vue'
13
13
  import SettingsModal from '../../components/SettingsModal.vue'
14
14
  import type { TopBarSettingsConfig } from '../../types/components'
15
+ import type { TopBarJob } from '../../types/jobs'
15
16
 
16
17
  // Mock the usePlatform composable
17
18
  vi.mock('../../composables/usePlatform', () => ({
@@ -908,7 +909,7 @@ describe('AppTopBar', () => {
908
909
  const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
909
910
  expect(imageIcons).toHaveLength(1)
910
911
  expect(imageIcons[0].attributes('src')).toBe(pngIcon)
911
- expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
912
+ expect(wrapper.get('.mint-page-selector__item-hint').text()).toBe('Overview')
912
913
  })
913
914
  })
914
915
 
@@ -1237,6 +1238,26 @@ describe('AppTopBar', () => {
1237
1238
  expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
1238
1239
  })
1239
1240
 
1241
+ it.each([
1242
+ { case: 'integrated with display_name', integrated: true, plugin: { ...integratedPlugin, display_name: 'LC-MS Peak Review' }, props: { title: 'Ignored' }, seed: false, expected: 'LC-MS Peak Review' },
1243
+ { case: 'integrated without display_name', integrated: true, plugin: integratedPlugin, props: {}, seed: false, expected: 'LC-MS Analysis' },
1244
+ { case: 'standalone with a title', integrated: false, plugin: undefined, props: { title: 'Planner Dev' }, seed: true, expected: 'Planner Dev' },
1245
+ { case: 'standalone seeded', integrated: false, plugin: undefined, props: {}, seed: true, expected: 'MS Planner' },
1246
+ { case: 'standalone without a name', integrated: false, plugin: undefined, props: {}, seed: false, expected: null },
1247
+ ])('should put the plugin name in the page selector crumb when $case', ({ integrated, plugin, props, seed, expected }) => {
1248
+ mockPlatformCtx({ isIntegrated: integrated, plugin })
1249
+ if (seed) providePluginIdentity({ plugin: { name: 'ms-planner', displayName: 'MS Planner' } })
1250
+
1251
+ try {
1252
+ const wrapper = createWrapper({ ...props, pageSelector: pages, currentPageSelectorId: 'peaks' })
1253
+
1254
+ const name = wrapper.find('.mint-page-selector__plugin')
1255
+ expect(name.exists() ? name.text() : null).toBe(expected)
1256
+ } finally {
1257
+ providePluginIdentity({ plugin: { name: '' } })
1258
+ }
1259
+ })
1260
+
1240
1261
  it('should hide the plugin name when a page selector is shown', () => {
1241
1262
  mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
1242
1263
 
@@ -1483,4 +1504,188 @@ describe('AppTopBar', () => {
1483
1504
  expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.objectContaining({ capture: true }))
1484
1505
  })
1485
1506
  })
1507
+ describe('run slot', () => {
1508
+ function mountRunSlot(
1509
+ job: TopBarJob | null | undefined,
1510
+ { experiment, jobsDone, jobsOpen, jobsPanelId }: { experiment: boolean; jobsDone?: number; jobsOpen?: boolean; jobsPanelId?: string },
1511
+ ) {
1512
+ vi.mocked(usePlatform).mockReturnValueOnce({
1513
+ integrated: computed(() => experiment),
1514
+ isAdmin: computed(() => true),
1515
+ can: () => true,
1516
+ plugin: computed(() => undefined),
1517
+ user: computed(() => undefined),
1518
+ theme: computed(() => 'system' as const),
1519
+ features: computed(() => undefined),
1520
+ })
1521
+ const appExperiment = {
1522
+ popover: computed(() => ({ experimentName: 'Serine tracing', experimentCode: 'EXP-0142', showSave: true })),
1523
+ selectorModal: computed(() => ({ open: false })),
1524
+ openModal: vi.fn(),
1525
+ closeModal: vi.fn(),
1526
+ handleSave: vi.fn(),
1527
+ handleDetach: vi.fn(),
1528
+ } as unknown as AppExperimentState
1529
+ return mount(AppTopBar, {
1530
+ props: job === undefined
1531
+ ? { title: 'LEAF', jobsDone, jobsOpen, jobsPanelId }
1532
+ : { title: 'LEAF', job, jobsDone, jobsOpen, jobsPanelId },
1533
+ global: {
1534
+ plugins: [createPinia()],
1535
+ provide: { [APP_EXPERIMENT_KEY as symbol]: appExperiment },
1536
+ stubs: { 'router-link': { template: '<a><slot /></a>' }, BaseModal: { template: '<div><slot /></div>' }, transition: false },
1537
+ },
1538
+ })
1539
+ }
1540
+
1541
+ const running: TopBarJob = { status: 'running', label: 'Extracting', percent: 40 }
1542
+
1543
+ it.each([
1544
+ ['no job prop, experiment', undefined, true, { popover: true, chip: false, idle: false, joined: false }],
1545
+ ['idle, experiment', null, true, { popover: true, chip: true, idle: true, joined: false }],
1546
+ ['running, experiment', running, true, { popover: true, chip: true, idle: false, joined: true }],
1547
+ ['idle, standalone', null, false, { popover: false, chip: true, idle: true, joined: false }],
1548
+ ['running, standalone', running, false, { popover: false, chip: true, idle: false, joined: true }],
1549
+ ] as const)('should render the run slot for %s', async (_case, job, experiment, expected) => {
1550
+ const wrapper = mountRunSlot(job, { experiment })
1551
+ await waitForAsyncComponents(wrapper)
1552
+
1553
+ const chip = wrapper.find('.mint-topbar__job')
1554
+ expect({
1555
+ popover: wrapper.findComponent(ExperimentPopover).exists(),
1556
+ chip: chip.exists(),
1557
+ idle: chip.exists() && chip.classes('mint-topbar__job--idle'),
1558
+ joined: wrapper.find('.mint-topbar__run-slot--joined').exists(),
1559
+ }).toEqual(expected)
1560
+ })
1561
+
1562
+ it.each([
1563
+ [undefined, true],
1564
+ [null, true],
1565
+ [running, false],
1566
+ ])('should pass showSave to the experiment chip when the job is %s', async (job, showSave) => {
1567
+ const wrapper = mountRunSlot(job, { experiment: true })
1568
+ await waitForAsyncComponents(wrapper)
1569
+
1570
+ expect(wrapper.findComponent(ExperimentPopover).props('showSave')).toBe(showSave)
1571
+ })
1572
+
1573
+ it('should keep the slot joined until the job has left, then show the idle button', async () => {
1574
+ const wrapper = mountRunSlot(running, { experiment: true })
1575
+ await waitForAsyncComponents(wrapper)
1576
+ const joined = () => wrapper.find('.mint-topbar__run-slot--joined').exists()
1577
+
1578
+ await wrapper.setProps({ job: null })
1579
+ expect(joined()).toBe(true)
1580
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job--idle').exists()).toBe(true))
1581
+ expect(joined()).toBe(false)
1582
+
1583
+ await wrapper.setProps({ job: running })
1584
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job--primary').exists()).toBe(true))
1585
+ expect(joined()).toBe(true)
1586
+
1587
+ await wrapper.setProps({ job: undefined })
1588
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__job').exists()).toBe(false))
1589
+ expect(joined()).toBe(false)
1590
+ })
1591
+
1592
+ it('should join the slot when the job and the slot mount together', async () => {
1593
+ const wrapper = mountRunSlot(undefined, { experiment: false })
1594
+ await waitForAsyncComponents(wrapper)
1595
+
1596
+ await wrapper.setProps({ job: running })
1597
+
1598
+ await vi.waitFor(() => expect(wrapper.find('.mint-topbar__run-slot--joined').exists()).toBe(true))
1599
+ })
1600
+
1601
+ it.each([
1602
+ [120, '100%', 'Extracting 100%'],
1603
+ [-5, '0%', 'Extracting 0%'],
1604
+ ])('should clamp percent %s to %s', async (percent, width, name) => {
1605
+ const wrapper = mountRunSlot({ ...running, percent }, { experiment: false })
1606
+ await waitForAsyncComponents(wrapper)
1607
+
1608
+ const chip = wrapper.get('.mint-topbar__job')
1609
+ expect(chip.get('.mint-topbar__job-bar > span').attributes('style')).toContain(`width: ${width}`)
1610
+ expect(chip.attributes('aria-label')).toBe(name)
1611
+ })
1612
+
1613
+ it.each([
1614
+ [running, 'Job running'],
1615
+ [{ status: 'awaiting_input', label: 'Decoding' } as TopBarJob, 'Job needs input'],
1616
+ [{ status: 'failed', label: '1 failed' } as TopBarJob, 'Job failed'],
1617
+ [null, ''],
1618
+ ])('should announce the status of %o as %s', async (job, text) => {
1619
+ const wrapper = mountRunSlot(job, { experiment: false })
1620
+ await waitForAsyncComponents(wrapper)
1621
+
1622
+ const live = wrapper.get('.mint-topbar__job-status')
1623
+ expect(live.attributes('aria-live')).toBe('polite')
1624
+ expect(live.text()).toBe(text)
1625
+ })
1626
+
1627
+ it.each([
1628
+ ['queued', 'primary'],
1629
+ ['running', 'primary'],
1630
+ ['awaiting_input', 'warning'],
1631
+ ['completed', 'success'],
1632
+ ['failed', 'error'],
1633
+ ['cancelled', 'neutral'],
1634
+ ] as const)('should tone a %s job as %s', async (status, tone) => {
1635
+ const wrapper = mountRunSlot({ status, label: 'Job' }, { experiment: false })
1636
+ await waitForAsyncComponents(wrapper)
1637
+
1638
+ expect(wrapper.get('.mint-topbar__job').classes()).toContain(`mint-topbar__job--${tone}`)
1639
+ })
1640
+
1641
+ it.each([
1642
+ [running, 'Extracting 40%', true],
1643
+ [{ status: 'completed', label: 'Saved to EXP-0142' } as TopBarJob, 'Saved to EXP-0142', false],
1644
+ ])('should show only the status for %o and name it %s', async (job, name, bar) => {
1645
+ const wrapper = mountRunSlot(job, { experiment: false })
1646
+ await waitForAsyncComponents(wrapper)
1647
+
1648
+ const chip = wrapper.get('.mint-topbar__job')
1649
+ expect(chip.text()).toBe('')
1650
+ expect(chip.attributes('aria-label')).toBe(name)
1651
+ expect(chip.attributes('title')).toBe(name)
1652
+ expect(chip.find('.mint-topbar__job-bar').exists()).toBe(bar)
1653
+ if (bar) expect(chip.get('.mint-topbar__job-bar > span').attributes('style')).toContain('width: 40%')
1654
+ })
1655
+
1656
+ it.each([
1657
+ [null, undefined, null, undefined],
1658
+ [null, 0, null, undefined],
1659
+ [null, 2, '2', 'Jobs, 2 completed'],
1660
+ [running, 2, null, 'Extracting 40%'],
1661
+ ])('should show the completed count only on the idle button when the job is %o and jobsDone %s', async (job, jobsDone, count, name) => {
1662
+ const wrapper = mountRunSlot(job, { experiment: false, jobsDone })
1663
+ await waitForAsyncComponents(wrapper)
1664
+
1665
+ const chip = wrapper.get('.mint-topbar__job')
1666
+ expect([chip.find('.mint-topbar__job-count').exists() ? chip.get('.mint-topbar__job-count').text() : null, chip.attributes('aria-label')])
1667
+ .toEqual([count, name])
1668
+ })
1669
+
1670
+ it.each([
1671
+ [null, undefined, undefined, [undefined, undefined]],
1672
+ [null, true, 'jobs-panel', ['true', 'jobs-panel']],
1673
+ [running, false, 'jobs-panel', ['false', 'jobs-panel']],
1674
+ ])('should expose the tray state on the job control when it is %o and open is %s', async (job, jobsOpen, jobsPanelId, expected) => {
1675
+ const wrapper = mountRunSlot(job, { experiment: false, jobsOpen, jobsPanelId })
1676
+ await waitForAsyncComponents(wrapper)
1677
+
1678
+ const chip = wrapper.get('.mint-topbar__job')
1679
+ expect([chip.attributes('aria-expanded'), chip.attributes('aria-controls')]).toEqual(expected)
1680
+ })
1681
+
1682
+ it.each([null, running])('should emit job-click when the job control is %s', async (job) => {
1683
+ const wrapper = mountRunSlot(job, { experiment: false })
1684
+ await waitForAsyncComponents(wrapper)
1685
+
1686
+ await wrapper.get('.mint-topbar__job').trigger('click')
1687
+
1688
+ expect(wrapper.emitted('job-click')).toEqual([[]])
1689
+ })
1690
+ })
1486
1691
  })
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
3
+ import { computed, defineComponent, h, nextTick } from 'vue'
4
+ import { createPinia } from 'pinia'
5
+ import AppTopBar from '../../components/AppTopBar.vue'
6
+ import { provideOnboarding, type OnboardingDefinition } from '../../composables/useOnboarding'
7
+
8
+ vi.mock('../../composables/usePlatform', () => ({
9
+ usePlatform: vi.fn(() => ({
10
+ integrated: computed(() => false),
11
+ isAdmin: computed(() => true),
12
+ can: () => true,
13
+ plugin: computed(() => undefined),
14
+ user: computed(() => undefined),
15
+ theme: computed(() => 'system' as const),
16
+ features: computed(() => undefined),
17
+ })),
18
+ }))
19
+
20
+ const definition: OnboardingDefinition = {
21
+ tours: [{
22
+ id: 'intro',
23
+ title: 'Top bar tour',
24
+ steps: [
25
+ { id: 'settings', anchor: 'topbar-settings', title: 'Settings live here', body: 'Theme and palette.' },
26
+ { id: 'help', anchor: 'topbar-help', title: 'Help', body: 'Replay tours.', advanceOn: 'click' },
27
+ ],
28
+ }],
29
+ checklist: [{ id: 'create', title: 'Create an experiment' }],
30
+ }
31
+
32
+ enableAutoUnmount(afterEach)
33
+
34
+ function mountTopBar(onboarding?: OnboardingDefinition) {
35
+ return mount(defineComponent({
36
+ setup() {
37
+ if (onboarding) provideOnboarding(onboarding, { appId: 'test', userId: 1, autoStart: false })
38
+ return () => h(AppTopBar, { spec: { settings: { tabs: [] } } })
39
+ },
40
+ }), { global: { plugins: [createPinia()], stubs: { 'router-link': true } }, attachTo: document.body })
41
+ }
42
+
43
+ const card = () => document.querySelector('.mint-onboarding__card')
44
+
45
+ describe('AppTopBar onboarding', () => {
46
+ beforeEach(() => localStorage.clear())
47
+
48
+ it.each([
49
+ [undefined, false],
50
+ [definition, true],
51
+ ])('should show the Help button and checklist chip when onboarding is %s', (onboarding, shown) => {
52
+ const wrapper = mountTopBar(onboarding)
53
+
54
+ expect(wrapper.find('[data-onboarding="topbar-help"]').exists()).toBe(shown)
55
+ expect(wrapper.find('.mint-onboarding-help__chip').exists()).toBe(shown)
56
+ if (shown) expect(wrapper.find('.mint-onboarding-help__chip').text()).toContain('0/1')
57
+ })
58
+
59
+ it('should run a tour picked in the Help panel and end it with Escape', async () => {
60
+ const wrapper = mountTopBar(definition)
61
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
62
+ const tour = [...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!
63
+ tour.click()
64
+ await flushPromises()
65
+
66
+ expect(document.querySelector('.mint-onboarding-help__panel')).toBeNull()
67
+ expect(card()?.getAttribute('role')).toBe('dialog')
68
+ expect(card()?.textContent).toContain('Settings live here')
69
+ expect(card()?.textContent).toContain('Next')
70
+
71
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
72
+ await nextTick()
73
+
74
+ expect(card()).toBeNull()
75
+ expect(JSON.parse(localStorage.getItem('mint.onboarding.test.1')!)).toEqual(
76
+ expect.arrayContaining(['step:intro/settings', 'step:intro/help']),
77
+ )
78
+ })
79
+
80
+ it('should focus the card, not the Next button, when a step opens', async () => {
81
+ const wrapper = mountTopBar(definition)
82
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
83
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
84
+ await flushPromises()
85
+
86
+ expect(document.activeElement).toBe(card())
87
+ })
88
+
89
+ it.each([
90
+ [{ key: 'Tab', shiftKey: true }, 'Next'],
91
+ [{ key: 'Tab', shiftKey: false }, ''],
92
+ ])('should keep %o from the focused card inside the card', async (init, label) => {
93
+ const wrapper = mountTopBar(definition)
94
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
95
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
96
+ await flushPromises()
97
+
98
+ document.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true }))
99
+
100
+ expect(card()!.contains(document.activeElement)).toBe(true)
101
+ expect((document.activeElement as HTMLElement).textContent?.trim()).toBe(label)
102
+ })
103
+
104
+ it('should leave Escape and Tab to a control outside the tour that has focus', async () => {
105
+ const wrapper = mountTopBar(definition)
106
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
107
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
108
+ await flushPromises()
109
+ const input = document.body.appendChild(document.createElement('input'))
110
+ input.focus()
111
+
112
+ for (const key of ['Tab', 'Escape']) document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
113
+ await nextTick()
114
+
115
+ expect(document.activeElement).toBe(input)
116
+ expect(card()).not.toBeNull()
117
+ input.remove()
118
+ })
119
+
120
+ it('should go to the next step when Enter is pressed on the focused card', async () => {
121
+ const wrapper = mountTopBar(definition)
122
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
123
+ ;[...document.querySelectorAll<HTMLElement>('.mint-menu-item')].find((item) => item.textContent?.trim() === 'Top bar tour')!.click()
124
+ await flushPromises()
125
+
126
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
127
+ await flushPromises()
128
+
129
+ expect(card()?.textContent).toContain('Replay tours.')
130
+ })
131
+
132
+ it('should show no Next button on an advanceOn click step', async () => {
133
+ const wrapper = mountTopBar(definition)
134
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
135
+ document.querySelector<HTMLElement>('.mint-menu-item')!.click()
136
+ await flushPromises()
137
+ ;(document.querySelector('.mint-onboarding__card .mint-button') as HTMLElement).click()
138
+ await flushPromises()
139
+
140
+ expect(card()?.textContent).toContain('Click the highlighted control.')
141
+ expect(card()?.textContent).not.toContain('Done')
142
+ })
143
+
144
+ it('should keep Tab on the anchor and the card on an advanceOn click step', async () => {
145
+ const wrapper = mountTopBar(definition)
146
+ await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
147
+ document.querySelector<HTMLElement>('.mint-menu-item')!.click()
148
+ await flushPromises()
149
+ ;(document.querySelector('.mint-onboarding__card .mint-button') as HTMLElement).click()
150
+ await flushPromises()
151
+ const anchor = document.querySelector<HTMLElement>('[data-onboarding="topbar-help"]')!
152
+ expect(document.activeElement).toBe(anchor)
153
+
154
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
155
+
156
+ expect(card()?.contains(document.activeElement)).toBe(true)
157
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }))
158
+ expect(document.activeElement).toBe(anchor)
159
+ })
160
+ })