@morscherlab/mint-sdk 1.4.13 → 1.5.0-beta.10

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 (132) hide show
  1. package/dist/{AlertBox-CexONPhA.js → AlertBox-Cai0w7gF.js} +3 -3
  2. package/dist/{AlertBox-CexONPhA.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
  3. package/dist/{AppSidebar-CIBehlYL.js → AppSidebar-C-Vq5x4I.js} +354 -93
  4. package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
  5. package/dist/{ConfirmDialog-CjWoxAD1.js → ConfirmDialog-BjFsOpBe.js} +3 -3
  6. package/dist/ConfirmDialog-BjFsOpBe.js.map +1 -0
  7. package/dist/{ExperimentPopover-CzV1--OY.js → ExperimentPopover-DYy_s0kI.js} +25 -37
  8. package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-CUkKmgfr.js → ExperimentSelectorModal-Bfh5bdkv.js} +5 -5
  10. package/dist/{ExperimentSelectorModal-CUkKmgfr.js.map → ExperimentSelectorModal-Bfh5bdkv.js.map} +1 -1
  11. package/dist/{SegmentedControl-C-sckoJZ.js → SegmentedControl-B8KgyOjd.js} +2 -2
  12. package/dist/{SegmentedControl-C-sckoJZ.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
  13. package/dist/{SettingsModal-jW7Z4i_1.js → SettingsModal-DwfrD1ER.js} +7 -7
  14. package/dist/{SettingsModal-jW7Z4i_1.js.map → SettingsModal-DwfrD1ER.js.map} +1 -1
  15. package/dist/__tests__/components/AppTopBarCompact.test.d.ts +1 -0
  16. package/dist/__tests__/components/AppTopBarCompactReset.test.d.ts +1 -0
  17. package/dist/__tests__/components/AutoGroupModal.experimentLoad.test.d.ts +1 -0
  18. package/dist/components/AppTopBar.compact.d.ts +5 -0
  19. package/dist/components/AppTopBar.vue.d.ts +17 -17
  20. package/dist/components/FormBuilder.vue.d.ts +1 -1
  21. package/dist/components/PluginIcon.vue.d.ts +2 -2
  22. package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
  23. package/dist/components/SettingsModal.vue.d.ts +1 -1
  24. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
  25. package/dist/components/internal/FormSchemaInternal.vue.d.ts +9 -9
  26. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  27. package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
  28. package/dist/composables/appLayoutSidebar.d.ts +9 -0
  29. package/dist/composables/useVersionedSave.d.ts +5 -2
  30. package/dist/{controlSchemaModel-C_XqpkgE.js → controlSchemaModel-BtFNZj8t.js} +2 -2
  31. package/dist/{controlSchemaModel-C_XqpkgE.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
  32. package/dist/{data-8G-U6Pb0.js → data-CJztFns8.js} +31 -15
  33. package/dist/{data-8G-U6Pb0.js.map → data-CJztFns8.js.map} +1 -1
  34. package/dist/data.js +8 -8
  35. package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
  36. package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
  37. package/dist/index.d.ts +0 -1
  38. package/dist/index.js +15 -20
  39. package/dist/index.js.map +1 -1
  40. package/dist/{install-CM33AZhf.js → install-Bk02tJ5M.js} +14 -15
  41. package/dist/{install-CM33AZhf.js.map → install-Bk02tJ5M.js.map} +1 -1
  42. package/dist/install.js +1 -1
  43. package/dist/{lab-CPJ7n7e5.js → lab-BYhb_cJI.js} +22 -14
  44. package/dist/lab-BYhb_cJI.js.map +1 -0
  45. package/dist/lab.js +4 -4
  46. package/dist/{records-BAymyHAn.js → records-GtiVMlTQ.js} +198 -62
  47. package/dist/records-GtiVMlTQ.js.map +1 -0
  48. package/dist/{runtime-PntKXQ7K.js → runtime-aZPUPxoB.js} +29 -12
  49. package/dist/runtime-aZPUPxoB.js.map +1 -0
  50. package/dist/runtime.js +6 -6
  51. package/dist/{shell-B-woqThX.js → shell-Bk9mD4Lc.js} +5 -5
  52. package/dist/{shell-B-woqThX.js.map → shell-Bk9mD4Lc.js.map} +1 -1
  53. package/dist/shell.js +5 -5
  54. package/dist/styles.css +139 -15
  55. package/dist/types/components.d.ts +22 -2
  56. package/dist/types/generated-ui.d.ts +2 -0
  57. package/dist/types/index.d.ts +2 -2
  58. package/dist/types/instrument.d.ts +0 -17
  59. package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
  60. package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
  61. package/dist/{useExperimentSamples-DEDAKa2E.js → useExperimentSamples-CpNXvpUn.js} +3 -3
  62. package/dist/{useExperimentSamples-DEDAKa2E.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
  63. package/dist/{useListReorder-W1bzK_bo.js → useListReorder-Bp4EAI2w.js} +3 -3
  64. package/dist/{useListReorder-W1bzK_bo.js.map → useListReorder-Bp4EAI2w.js.map} +1 -1
  65. package/dist/{useRackEditor-BWOnob3i.js → useRackEditor-i2bdPUFf.js} +8 -8
  66. package/dist/{useRackEditor-BWOnob3i.js.map → useRackEditor-i2bdPUFf.js.map} +1 -1
  67. package/dist/{useRealtime-B0u_FvEH.js → useRealtime-BLQEjbuq.js} +3 -3
  68. package/dist/{useRealtime-B0u_FvEH.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
  69. package/dist/useRequest-CXyPw3eh.js +76 -0
  70. package/dist/useRequest-CXyPw3eh.js.map +1 -0
  71. package/dist/utils/deprecation.d.ts +2 -0
  72. package/dist/utils/lucideIcon.d.ts +4 -0
  73. package/package.json +1 -1
  74. package/src/__tests__/components/AppTopBar.test.ts +102 -20
  75. package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
  76. package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
  77. package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
  78. package/src/__tests__/components/BaseModal.test.ts +33 -2
  79. package/src/__tests__/components/ConfirmDialog.test.ts +2 -2
  80. package/src/__tests__/components/ExperimentPopover.test.ts +32 -0
  81. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -1
  82. package/src/__tests__/components/deprecationTags.test.ts +4 -18
  83. package/src/__tests__/composables/useJobCenter.test.ts +35 -0
  84. package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
  85. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  86. package/src/__tests__/styles/menusShellRefresh.test.ts +5 -0
  87. package/src/components/AppLayout.story.vue +5 -5
  88. package/src/components/AppLayout.vue +13 -1
  89. package/src/components/AppSidebar.vue +2 -2
  90. package/src/components/AppTopBar.compact.ts +60 -0
  91. package/src/components/AppTopBar.story.vue +39 -14
  92. package/src/components/AppTopBar.vue +222 -89
  93. package/src/components/BaseModal.vue +16 -5
  94. package/src/components/CollapsibleCard.vue +1 -1
  95. package/src/components/ConfirmDialog.vue +1 -1
  96. package/src/components/ExperimentPopover.story.vue +2 -2
  97. package/src/components/ExperimentPopover.vue +6 -22
  98. package/src/components/GeneratedPluginView.vue +0 -1
  99. package/src/components/PluginIcon.story.vue +2 -2
  100. package/src/components/PluginIcon.vue +2 -2
  101. package/src/components/PluginWorkspaceView.vue +8 -0
  102. package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
  103. package/src/components/internal/AutoGroupModalInternal.vue +14 -3
  104. package/src/components/internal/LucideIconInternal.vue +23 -0
  105. package/src/composables/appLayoutSidebar.ts +11 -0
  106. package/src/composables/index.ts +1 -2
  107. package/src/composables/useJobCenter.ts +21 -1
  108. package/src/composables/usePluginWorkspace.ts +1 -1
  109. package/src/composables/useVersionedSave.ts +32 -7
  110. package/src/index.ts +0 -3
  111. package/src/styles/components/app-layout.css +5 -0
  112. package/src/styles/components/app-top-bar.css +128 -8
  113. package/src/styles/components/plugin-icon.css +1 -1
  114. package/src/styles/variables.css +2 -2
  115. package/src/types/components.ts +23 -2
  116. package/src/types/generated-ui.ts +2 -0
  117. package/src/types/index.ts +1 -1
  118. package/src/types/instrument.ts +0 -18
  119. package/src/utils/deprecation.ts +8 -0
  120. package/src/utils/lucideIcon.ts +25 -0
  121. package/src/utils/lucideIconIndex.d.ts +2 -0
  122. package/dist/AppSidebar-CIBehlYL.js.map +0 -1
  123. package/dist/ConfirmDialog-CjWoxAD1.js.map +0 -1
  124. package/dist/ExperimentPopover-CzV1--OY.js.map +0 -1
  125. package/dist/deprecatedRootExports.d.ts +0 -191
  126. package/dist/lab-CPJ7n7e5.js.map +0 -1
  127. package/dist/records-BAymyHAn.js.map +0 -1
  128. package/dist/runtime-PntKXQ7K.js.map +0 -1
  129. package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
  130. package/dist/useRequest-D9-3BS-3.js +0 -215
  131. package/dist/useRequest-D9-3BS-3.js.map +0 -1
  132. package/src/deprecatedRootExports.ts +0 -226
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { trackTopBarCompact } from '../../components/AppTopBar.compact'
4
+
5
+ let resize: () => void = () => {}
6
+ vi.stubGlobal('ResizeObserver', class {
7
+ constructor(callback: () => void) { resize = callback }
8
+ observe() {}
9
+ disconnect() {}
10
+ })
11
+
12
+ function size(el: Element, box: { width?: number; client?: number; scroll?: number }) {
13
+ if (box.width !== undefined) el.getBoundingClientRect = () => ({ width: box.width }) as DOMRect
14
+ if (box.client !== undefined) Object.defineProperty(el, 'clientWidth', { configurable: true, value: box.client })
15
+ if (box.scroll !== undefined) Object.defineProperty(el, 'scrollWidth', { configurable: true, value: box.scroll })
16
+ }
17
+
18
+ function topBar() {
19
+ const header = document.createElement('header')
20
+ header.innerHTML = `
21
+ <button class="mint-experiment-popover__trigger"><span class="mint-experiment-popover__trigger-text"></span></button>
22
+ <span class="mint-experiment-popover__save-label">Save</span>`
23
+ const [chip, text, label] = ['__trigger', '__trigger-text', '__save-label']
24
+ .map(part => header.querySelector(`.mint-experiment-popover${part}`)!)
25
+ size(label, { width: 40 })
26
+ return { header, chip, text }
27
+ }
28
+
29
+ afterEach(() => { resize = () => {} })
30
+
31
+ describe('trackTopBarCompact', () => {
32
+ it('should compact when the chip is squeezed and expand once the bar has grown by the label width', () => {
33
+ const { header, chip, text } = topBar()
34
+ const compact = ref(false)
35
+ trackTopBarCompact(header, compact)
36
+
37
+ size(header, { client: 800 })
38
+ size(chip, { width: 150 })
39
+ size(text, { client: 60, scroll: 120 })
40
+ resize()
41
+ const squeezed = compact.value
42
+
43
+ size(header, { client: 830 })
44
+ resize()
45
+ const belowLabelWidth = compact.value
46
+
47
+ size(header, { client: 840 })
48
+ resize()
49
+
50
+ expect([squeezed, belowLabelWidth, compact.value]).toEqual([true, true, false])
51
+ })
52
+
53
+ it('should keep the labels when a short name leaves the chip narrow', () => {
54
+ const { header, chip, text } = topBar()
55
+ const compact = ref(false)
56
+ trackTopBarCompact(header, compact)
57
+
58
+ size(header, { client: 800 })
59
+ size(chip, { width: 150 })
60
+ size(text, { client: 60, scroll: 60 })
61
+ resize()
62
+
63
+ expect(compact.value).toBe(false)
64
+ })
65
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { createPinia } from 'pinia'
4
+ import AppTopBar from '../../components/AppTopBar.vue'
5
+
6
+ const reset = vi.fn()
7
+ vi.mock('../../components/AppTopBar.compact', () => ({
8
+ trackTopBarCompact: () => ({ reset, stop: () => {} }),
9
+ }))
10
+
11
+ describe('AppTopBar compact reset', () => {
12
+ it('should re-measure when a job appears but not when its progress label changes', async () => {
13
+ const wrapper = mount(AppTopBar, {
14
+ props: { title: 'Test App', job: null },
15
+ global: { plugins: [createPinia()] },
16
+ })
17
+
18
+ await wrapper.setProps({ job: { status: 'running', label: 'Extracting 10 files' } })
19
+ const afterAppear = reset.mock.calls.length
20
+ await wrapper.setProps({ job: { status: 'running', label: 'Extracting 4 of 10 files' } })
21
+
22
+ expect([afterAppear, reset.mock.calls.length]).toEqual([1, 1])
23
+ })
24
+ })
@@ -0,0 +1,58 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue'
5
+
6
+ const pending = vi.hoisted(() => new Map<string, (data: unknown) => void>())
7
+
8
+ vi.mock('../../composables/useApi', () => ({
9
+ useApi: () => ({
10
+ get: (url: string) => new Promise(resolve => pending.set(url, data => resolve({ data }))),
11
+ }),
12
+ }))
13
+
14
+ const { default: AutoGroupModal } = await import('../../components/internal/AutoGroupModalInternal.vue')
15
+
16
+ const design = (names: string[]) => ({
17
+ samples: names.map(sample_name => ({ sample_name, sample_type: 'sample', conditions: { Genotype: sample_name } })),
18
+ })
19
+
20
+ describe('AutoGroupModal experiment load', () => {
21
+ it('should ignore the response of an earlier open when the modal reopens on another experiment', async () => {
22
+ const wrapper = mount(AutoGroupModal, {
23
+ props: { open: true, experimentId: 1 },
24
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
25
+ })
26
+ await wrapper.setProps({ open: false })
27
+ await wrapper.setProps({ open: true, experimentId: 2 })
28
+ const recipe = () => wrapper.findComponent(SmartGroupFieldRecipe)
29
+
30
+ pending.get('/experiments/1/data')!(design(['O1', 'O2', 'O3']))
31
+ await flushPromises()
32
+ const afterOld = [recipe().props('loading'), recipe().props('headerCount')]
33
+ pending.get('/experiments/2/data')!(design(['N1', 'N2']))
34
+ await flushPromises()
35
+
36
+ expect({
37
+ afterOld,
38
+ afterNew: [recipe().props('loading'), recipe().props('headerCount')],
39
+ }).toEqual({ afterOld: [true, 0], afterNew: [false, 2] })
40
+ })
41
+
42
+ it('should stop loading and keep the pasted samples when the modal reopens without an experiment during a fetch', async () => {
43
+ const wrapper = mount(AutoGroupModal, {
44
+ props: { open: true, experimentId: 3 },
45
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
46
+ })
47
+ await wrapper.setProps({ open: false })
48
+ await wrapper.setProps({ open: true, experimentId: undefined, samples: ['WT_1', 'WT_2', 'KO_1', 'KO_2'] })
49
+
50
+ pending.get('/experiments/3/data')!(design(['O1', 'O2', 'O3']))
51
+ await flushPromises()
52
+ const recipe = wrapper.findComponent(SmartGroupFieldRecipe)
53
+
54
+ const anatomy = (recipe.props('anatomy') as { text: string }[]).map(token => token.text)
55
+
56
+ expect([recipe.props('loading'), anatomy]).toEqual([false, ['WT', '1']])
57
+ })
58
+ })
@@ -1,6 +1,6 @@
1
- import { mount } from '@vue/test-utils'
1
+ import { enableAutoUnmount, mount } from '@vue/test-utils'
2
2
  import { nextTick } from 'vue'
3
- import { describe, expect, it } from 'vitest'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
4
  import BaseModal from '../../components/BaseModal.vue'
5
5
  import type { ModalTab } from '../../types'
6
6
 
@@ -36,6 +36,8 @@ function mountModal(props: Record<string, unknown> = {}, slots: Record<string, s
36
36
  })
37
37
  }
38
38
 
39
+ enableAutoUnmount(afterEach)
40
+
39
41
  describe('BaseModal', () => {
40
42
  describe('plain layout', () => {
41
43
  it('should not render a tab strip when layout is omitted', () => {
@@ -233,4 +235,33 @@ describe('BaseModal', () => {
233
235
  attached.unmount()
234
236
  })
235
237
  })
238
+
239
+ describe('scroll lock', () => {
240
+ afterEach(() => {
241
+ document.body.style.overflow = ''
242
+ })
243
+
244
+ it('should keep body scroll locked until the last nested modal closes when modals stack', async () => {
245
+ document.body.style.overflow = 'scroll'
246
+ const outer = mountModal()
247
+ const inner = mountModal({ title: 'Nested' })
248
+ await inner.setProps({ open: false })
249
+ expect(document.body.style.overflow).toBe('hidden')
250
+ await outer.setProps({ open: false })
251
+ expect(document.body.style.overflow).toBe('scroll')
252
+ })
253
+
254
+ it('should keep body scroll locked when a closed modal unmounts while another is open', async () => {
255
+ const open = mountModal({ open: false })
256
+ await open.setProps({ open: true })
257
+ const closed = mountModal({ open: false })
258
+ closed.unmount()
259
+ expect(document.body.style.overflow).toBe('hidden')
260
+ })
261
+
262
+ it('should lock body scroll when the modal mounts open', () => {
263
+ mountModal()
264
+ expect(document.body.style.overflow).toBe('hidden')
265
+ })
266
+ })
236
267
  })
@@ -7,8 +7,8 @@ import type { ButtonVariant, Tone } from '../../types'
7
7
  describe('ConfirmDialog', () => {
8
8
  it.each<[Tone, ButtonVariant]>([
9
9
  ['error', 'danger'],
10
- ['warning', 'cta'],
11
- ['cta', 'cta'],
10
+ ['warning', 'primary'],
11
+ ['cta', 'primary'],
12
12
  ['success', 'success'],
13
13
  ['info', 'primary'],
14
14
  ['neutral', 'primary'],
@@ -1,5 +1,6 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import ConfirmDialog from '../../components/ConfirmDialog.vue'
3
4
  import ExperimentPopover from '../../components/ExperimentPopover.vue'
4
5
 
5
6
  function mountPopover(props = {}) {
@@ -279,4 +280,35 @@ describe('ExperimentPopover', () => {
279
280
 
280
281
  wrapper.unmount()
281
282
  })
283
+
284
+ it('should drop the success indicator when the parent clears saveSuccessMessage', async () => {
285
+ const wrapper = mountPopover({ experimentName: 'Dose response run', showSave: true })
286
+ const save = () => wrapper.find('.mint-experiment-popover__save')
287
+
288
+ await wrapper.setProps({ saveSuccessMessage: 'Saved' })
289
+ expect(save().classes()).toContain('mint-experiment-popover__save--success')
290
+ expect(save().attributes('title')).toBe('Saved')
291
+
292
+ await wrapper.setProps({ saveSuccessMessage: undefined, saveDisabled: true })
293
+ expect(save().classes()).not.toContain('mint-experiment-popover__save--success')
294
+ expect(save().classes()).toContain('mint-experiment-popover__save--disabled')
295
+ expect(save().attributes('disabled')).toBeDefined()
296
+
297
+ wrapper.unmount()
298
+ })
299
+
300
+ it.each([
301
+ { saveDisabled: true },
302
+ { saveLoading: true },
303
+ ])('should not emit save from the confirm dialog when %o', async (blocked) => {
304
+ const wrapper = mountPopover({ experimentName: 'Dose response run', showSave: true })
305
+
306
+ await wrapper.find('.mint-experiment-popover__save').trigger('click')
307
+ await wrapper.setProps(blocked)
308
+ wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
309
+
310
+ expect(wrapper.emitted('save')).toBeUndefined()
311
+
312
+ wrapper.unmount()
313
+ })
282
314
  })
@@ -174,7 +174,7 @@ describe('PluginWorkspaceView', () => {
174
174
  expect(wrapper.findComponent(AppTopBar).props()).toMatchObject({
175
175
  title: 'MS Uploader',
176
176
  subtitle: 'Import and visualize',
177
- spec: { pills: pillNav, themeToggle: true, settings: {}, standaloneLabel: false },
177
+ spec: { pills: pillNav, settings: {}, standaloneLabel: false },
178
178
  pill: 'data',
179
179
  })
180
180
  expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({
@@ -52,13 +52,11 @@ describe('deprecation tags', () => {
52
52
  describe('root exports outside L1 and L2', () => {
53
53
  const ROOT_MODULES = ['composables/index.ts', 'components/index.ts', 'stores/index.ts', 'types/index.ts', 'apiErrors.ts', 'install.ts']
54
54
  .map(file => resolve(SRC, file))
55
- const SUBPATHS = ['lab', 'shell', 'data', 'runtime']
56
- const DEPRECATION = /^Import from '@morscherlab\/mint-sdk\/(\w+)'\. Removed in MINT 1\.5\.$/
57
55
 
58
- function deprecatedRootNames(): ts.Symbol[] {
56
+ function otherRootNames(): string[] {
59
57
  const kept = new Set(ROOT_MODULES.flatMap(file => exportsOf(file).map(symbol => symbol.getName())))
60
58
  kept.add('MINTSdk').add('default')
61
- return exportsOf(ROOT_ENTRY).filter(symbol => !kept.has(symbol.getName()))
59
+ return exportsOf(ROOT_ENTRY).map(symbol => symbol.getName()).filter(name => !kept.has(name))
62
60
  }
63
61
 
64
62
  it('should keep only the spec 3.3 L1 and L2 composable values in the composables barrel', () => {
@@ -72,19 +70,7 @@ describe('root exports outside L1 and L2', () => {
72
70
  ])
73
71
  })
74
72
 
75
- it('should tag each other root name with the subpath that exports it untagged', () => {
76
- const symbols = deprecatedRootNames()
77
- const problems = symbols.flatMap((symbol) => {
78
- const name = symbol.getName()
79
- const tag = symbol.getJsDocTags(checker).find(item => item.name === 'deprecated')
80
- const subpath = ts.displayPartsToString(tag?.text).match(DEPRECATION)?.[1]
81
- if (!subpath || !SUBPATHS.includes(subpath)) return [`${name}: no deprecation tag naming a subpath`]
82
- const target = exportsOf(resolve(SRC, `${subpath}.ts`)).find(item => item.getName() === name)
83
- if (!target) return [`${name}: not exported from ./${subpath}`]
84
- return isDeprecated(target) ? [`${name}: deprecated in ./${subpath}`] : []
85
- })
86
-
87
- expect(symbols.length).toBeGreaterThan(0)
88
- expect(problems).toEqual([])
73
+ it('should export no root name outside the L1 and L2 modules', () => {
74
+ expect(otherRootNames()).toEqual([])
89
75
  })
90
76
  })
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  combinePluginJobSources,
7
7
  useJobCenter,
8
+ type PluginJobCenterSource,
8
9
  type UseJobCenterOptions,
9
10
  } from '../../composables/useJobCenter'
10
11
  import type { JobStateInput, JobStatus, TopBarJob } from '../../types/jobs'
@@ -228,6 +229,26 @@ describe('useJobCenter', () => {
228
229
  wrapper.unmount()
229
230
  })
230
231
 
232
+ it('should refuse a second cancel when the source still reports the job running after the first cancel', async () => {
233
+ const sourceJobs = shallowRef([job('running', 'fit')])
234
+ const cancelJob = vi.fn(async () => {})
235
+ const { tray } = mountComposable({ source: { jobs: sourceJobs, cancel: cancelJob }, loadOnMount: false })
236
+
237
+ const first = await tray.cancelJob(tray.jobs.value[0]!)
238
+ const second = await tray.cancelJob(tray.jobs.value[0]!)
239
+ const pendingWhileRunning = tray.isPending(tray.jobs.value[0]!)
240
+ sourceJobs.value = [job('cancelled', 'fit')]
241
+ await nextTick()
242
+
243
+ expect({
244
+ first,
245
+ second,
246
+ calls: cancelJob.mock.calls.length,
247
+ pendingWhileRunning,
248
+ pendingAfter: tray.isPending(tray.jobs.value[0]!),
249
+ }).toEqual({ first: true, second: false, calls: 1, pendingWhileRunning: true, pendingAfter: false })
250
+ })
251
+
231
252
  it('keeps awaiting input jobs in the active group', () => {
232
253
  const { tray } = mountComposable({
233
254
  source: { jobs: [job('awaiting_input')] },
@@ -406,6 +427,20 @@ describe('useJobCenter top-bar chip', () => {
406
427
  expect(tray.topBarJob.value).toBeNull()
407
428
  })
408
429
 
430
+ it('should alert only later failures when a replacement source already lists failures', async () => {
431
+ const source = shallowRef<PluginJobCenterSource>({ jobs: [job('completed', 'c')] })
432
+ const { tray } = mountComposable({ source, loadOnMount: false })
433
+ const replacementJobs = shallowRef([job('failed', 'old')])
434
+
435
+ source.value = { jobs: replacementJobs }
436
+ await nextTick()
437
+ const replaced = tray.topBarJob.value
438
+ replacementJobs.value = [job('failed', 'old'), job('failed', 'new')]
439
+ await nextTick()
440
+
441
+ expect([replaced, tray.topBarJob.value]).toEqual([null, { status: 'failed', label: '1 failed' }])
442
+ })
443
+
409
444
  it('should alert only failures after the first job list when nothing was stored', async () => {
410
445
  const jobs = shallowRef<JobStateInput[]>([])
411
446
  const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
@@ -1,3 +1,4 @@
1
+ import { flushPromises } from '@vue/test-utils'
1
2
  import { effectScope, ref } from 'vue'
2
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
4
  import { MintApiError } from '../../apiErrors'
@@ -95,18 +96,47 @@ describe('useVersionedSave', () => {
95
96
  scope.stop()
96
97
  })
97
98
 
98
- it('should keep the conflict and the stale change when the reload brings no fresh record', async () => {
99
+ it.each([
100
+ ['the conflict', 'conflict', { version: 17 }],
101
+ ['the stale change', 'stale', { actors: ['Alice Chen'], fields: ['Notes'], at: '2026-10-23T10:00:00Z' }],
102
+ ] as const)('should keep %s when the reload brings no fresh record', async (_label, banner, expected) => {
99
103
  const { versioned, save, reload, scope } = setup()
100
- save.mockRejectedValueOnce(conflictError({ version: 17 }))
101
- await versioned.save()
102
- emit({ actor_id: '7', fields: ['notes'] })
104
+ if (banner === 'conflict') {
105
+ save.mockRejectedValueOnce(conflictError({ version: 17 }))
106
+ await versioned.save()
107
+ } else {
108
+ emit({ actor_id: '7', fields: ['notes'], at: '2026-10-23T10:00:00Z' })
109
+ }
103
110
  const failure = new Error('Could not reload')
104
111
  reload.mockRejectedValueOnce(failure)
105
112
 
106
113
  await expect(versioned.reload()).rejects.toBe(failure)
107
114
 
108
- expect(versioned.conflict.value).toEqual({ version: 17 })
109
- expect(versioned.stale.value?.actors).toEqual(['Alice Chen'])
115
+ expect(versioned[banner].value).toEqual(expected)
116
+ scope.stop()
117
+ })
118
+
119
+ it('should show only the conflict when a save is refused after a write by someone else', async () => {
120
+ const { versioned, save, scope } = setup()
121
+ emit({ actor_id: '7', fields: ['notes'] })
122
+ save.mockRejectedValueOnce(conflictError({ version: 17 }))
123
+
124
+ await versioned.save()
125
+ emit({ actor_id: '9', fields: ['status'] })
126
+
127
+ expect([versioned.conflict.value, versioned.stale.value]).toEqual([{ version: 17 }, null])
128
+ scope.stop()
129
+ })
130
+
131
+ it('should send one request when save is called again while a save runs', async () => {
132
+ const pending: ((value: string) => void)[] = []
133
+ const { versioned, save, scope } = setup({ save: () => new Promise(resolve => { pending.push(resolve) }) })
134
+
135
+ const first = versioned.save()
136
+ const second = await Promise.race([versioned.save(), Promise.resolve('sent')])
137
+ pending.forEach(resolve => resolve('saved'))
138
+
139
+ expect([await first, second, save.mock.calls.length]).toEqual(['saved', undefined, 1])
110
140
  scope.stop()
111
141
  })
112
142
 
@@ -136,6 +166,41 @@ describe('useVersionedSave', () => {
136
166
  scope.stop()
137
167
  })
138
168
 
169
+ it('should ask for an automatic reload and show the stale change when the user starts editing during the refetch', async () => {
170
+ const { versioned, reload, dirty, scope } = setup()
171
+ dirty.value = false
172
+ let finish: () => void = () => {}
173
+ reload.mockImplementationOnce(() => new Promise<void>(resolve => { finish = resolve }))
174
+
175
+ emit({ actor_id: '7', fields: ['notes'], at: '2026-10-23T10:00:00Z' })
176
+ dirty.value = true
177
+ finish()
178
+ await flushPromises()
179
+
180
+ expect([reload.mock.calls[0], versioned.stale.value]).toEqual([
181
+ [true],
182
+ { actors: ['Alice Chen'], fields: ['Notes'], at: '2026-10-23T10:00:00Z' },
183
+ ])
184
+ scope.stop()
185
+ })
186
+
187
+ it('should not show the stale change when the topic changes during an automatic reload', async () => {
188
+ const { versioned, reload, dirty, topic, scope } = setup()
189
+ dirty.value = false
190
+ let finish: () => void = () => {}
191
+ reload.mockImplementationOnce(() => new Promise<void>(resolve => { finish = resolve }))
192
+
193
+ emit({ actor_id: '7', fields: ['notes'] })
194
+ dirty.value = true
195
+ topic.value = 'experiment:43'
196
+ await Promise.resolve()
197
+ finish()
198
+ await flushPromises()
199
+
200
+ expect(versioned.stale.value).toBeNull()
201
+ scope.stop()
202
+ })
203
+
139
204
  // A failed background refetch only keeps the clean form as it is; the host reports it.
140
205
  it('should not reject when the refetch for an event on a clean form fails', async () => {
141
206
  const { versioned, reload, dirty, scope } = setup()
@@ -470,9 +470,9 @@ describe('frontend docs catalog coverage', () => {
470
470
  'BaseButton',
471
471
  'PluginWorkspaceView',
472
472
  'DataFrame',
473
- 'useForm',
474
- 'useReagentSeries',
475
- 'ConcentrationValue',
473
+ 'useExperiment',
474
+ 'useRequest',
475
+ 'UseExperimentReturn',
476
476
  'FormSectionSchema',
477
477
  ]))
478
478
  })
@@ -21,6 +21,11 @@ describe('menus and app shell styles', () => {
21
21
  expect(css('step-wizard.css')).not.toMatch(/\.mint-wizard__nav-btn/)
22
22
  })
23
23
 
24
+ // A max-content pill column made the grid wider than a phone, pushing theme and settings off the bar.
25
+ it('should let the pill column shrink when the two-row top bar is narrower than the pills', () => {
26
+ expect(css('app-top-bar.css')).toMatch(/\.mint-topbar__container--stackable\s*\{[^}]*grid-template-columns:\s*minmax\(0, max-content\) minmax\(0, 1fr\) auto;/)
27
+ })
28
+
24
29
  it('should pad the top bar to the 1.2 height (62px)', () => {
25
30
  expect(css('app-top-bar.css')).toMatch(/\.mint-topbar\s*\{[^}]*padding:\s*0\.75rem 1rem;/)
26
31
  })
@@ -52,7 +52,7 @@ const logScale = ref(false)
52
52
  >
53
53
  <template #topbar>
54
54
  <AppTopBar
55
- :spec="{ pills: pillNav, themeToggle: true, homePath: '' }"
55
+ :spec="{ pills: pillNav, homePath: '' }"
56
56
  title="My Plugin"
57
57
  subtitle="Laboratory Analysis"
58
58
  :pill="activeView"
@@ -113,7 +113,7 @@ const logScale = ref(false)
113
113
  >
114
114
  <template #topbar>
115
115
  <AppTopBar
116
- :spec="{ pills: pillNav, themeToggle: true, homePath: '' }"
116
+ :spec="{ pills: pillNav, homePath: '' }"
117
117
  title="Plate Analyzer"
118
118
  subtitle="Dashboard"
119
119
  :pill="activeView"
@@ -170,7 +170,7 @@ const logScale = ref(false)
170
170
  <div style="height: 400px;">
171
171
  <AppLayout>
172
172
  <template #topbar>
173
- <AppTopBar :spec="{ themeToggle: true, homePath: '' }" title="Simple Page" />
173
+ <AppTopBar :spec="{ homePath: '' }" title="Simple Page" />
174
174
  </template>
175
175
  <div style="padding: 2rem;">
176
176
  <p style="color: var(--text-muted, #94a3b8);">Layout without a sidebar, just topbar and content.</p>
@@ -254,7 +254,7 @@ const logScale = ref(false)
254
254
  <AppLayout :floating="true" sidebar-width="280px">
255
255
  <template #topbar>
256
256
  <AppTopBar
257
- :spec="{ pills: pillNav, themeToggle: true, homePath: '' }"
257
+ :spec="{ pills: pillNav, homePath: '' }"
258
258
  title="IC50 Calculator"
259
259
  subtitle="Analysis"
260
260
  :pill="activeView"
@@ -302,7 +302,7 @@ const logScale = ref(false)
302
302
  <AppLayout :floating="true">
303
303
  <template #topbar>
304
304
  <AppTopBar
305
- :spec="{ themeToggle: true, homePath: '' }"
305
+ :spec="{ homePath: '' }"
306
306
  title="Plate Analyzer"
307
307
  subtitle="Dashboard"
308
308
  />
@@ -21,7 +21,8 @@
21
21
  * </AppLayout>
22
22
  * ```
23
23
  */
24
- import { computed, ref } from 'vue'
24
+ import { computed, provide, ref, useSlots } from 'vue'
25
+ import { APP_LAYOUT_SIDEBAR_KEY } from '../composables/appLayoutSidebar'
25
26
 
26
27
  interface Props {
27
28
  /** Position of sidebar (left or right side of screen) */
@@ -57,6 +58,7 @@ const emit = defineEmits<{
57
58
  'update:sidebarOpen': [value: boolean]
58
59
  }>()
59
60
 
61
+ const slots = useSlots()
60
62
  const internalSidebarOpen = ref(props.defaultSidebarOpen)
61
63
  const sidebarOpenModel = computed({
62
64
  get: () => props.sidebarOpen ?? internalSidebarOpen.value,
@@ -82,6 +84,16 @@ function toggleSidebar() {
82
84
  sidebarOpenModel.value = !sidebarOpenModel.value
83
85
  }
84
86
 
87
+ // An AppTopBar in the topbar slot takes the drawer button; the floating one then hides (CSS :has).
88
+ if (props.responsiveSidebar && slots.sidebar) {
89
+ provide(APP_LAYOUT_SIDEBAR_KEY, {
90
+ open: computed(() => sidebarOpenModel.value),
91
+ toggle: toggleSidebar,
92
+ openLabel: computed(() => props.sidebarToggleLabel),
93
+ closeLabel: computed(() => props.sidebarCloseLabel),
94
+ })
95
+ }
96
+
85
97
  function closeSidebar() {
86
98
  sidebarOpenModel.value = false
87
99
  }
@@ -208,11 +208,11 @@ function railIconStyle(section: SidebarToolSection): Record<string, string> {
208
208
  }
209
209
 
210
210
  function badgeToneClass(section: SidebarToolSection): string {
211
- return `mint-sidebar__rail-badge--${section.badgeTone ?? 'cta'}`
211
+ return `mint-sidebar__rail-badge--${section.badgeTone ?? 'primary'}`
212
212
  }
213
213
 
214
214
  function tagToneClass(section: SidebarToolSection): string {
215
- return `mint-sidebar__tag--${section.badgeTone ?? 'cta'}`
215
+ return `mint-sidebar__tag--${section.badgeTone ?? 'primary'}`
216
216
  }
217
217
 
218
218
  function actionClasses(action: SidebarToolSectionAction): string[] {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Decides when the top bar trades the Save and Jobs labels for icons.
3
+ *
4
+ * Labels go when the content no longer fits: the experiment chip is squeezed below
5
+ * `CHIP_MIN_PX` with its name cut, the plugin or page name is cut, or the pills or the context group overflow. They come back once the
6
+ * bar has grown by at least the width the labels took, so the bar never flips back and
7
+ * forth at one width. `reset()` re-measures after the content changes.
8
+ */
9
+ import type { Ref } from 'vue'
10
+
11
+ const CHIP_MIN_PX = 192
12
+ const LABELS = '.mint-experiment-popover__save-label, .mint-topbar__job-label'
13
+ /** Identity text that truncates when the left side is squeezed. */
14
+ const IDENTITY_TEXT = '.mint-page-selector__plugin, .mint-page-selector__label, .mint-topbar__plugin-name, .mint-topbar-title, .mint-topbar__title-only'
15
+
16
+ function overflows(el: Element | null): boolean {
17
+ return !!el && el.scrollWidth > el.clientWidth + 1
18
+ }
19
+
20
+ export function trackTopBarCompact(header: HTMLElement, compact: Ref<boolean>): { reset: () => void; stop: () => void } {
21
+ if (typeof ResizeObserver === 'undefined') return { reset: () => {}, stop: () => {} }
22
+ let expandAt = 0
23
+
24
+ function check(): void {
25
+ const width = header.clientWidth
26
+ if (compact.value) {
27
+ if (width >= expandAt) compact.value = false
28
+ return
29
+ }
30
+ // A short name leaves the chip narrow by itself: squeezed means narrow and truncated.
31
+ const chip = header.querySelector('.mint-experiment-popover__trigger')
32
+ const squeezed = (chip && chip.getBoundingClientRect().width < CHIP_MIN_PX
33
+ && overflows(chip.querySelector('.mint-experiment-popover__trigger-text')))
34
+ || [...header.querySelectorAll(IDENTITY_TEXT)].some(overflows)
35
+ || overflows(header.querySelector('.mint-topbar__center'))
36
+ || overflows(header.querySelector('.mint-topbar__context'))
37
+ if (!squeezed) return
38
+ const labelsWidth = [...header.querySelectorAll(LABELS)].reduce((sum, el) => sum + el.getBoundingClientRect().width, 0)
39
+ if (labelsWidth === 0) return
40
+ expandAt = width + labelsWidth
41
+ compact.value = true
42
+ }
43
+
44
+ const observer = new ResizeObserver(check)
45
+ // The groups mount and unmount with their content, so each reset observes them again.
46
+ function observe(): void {
47
+ observer.disconnect()
48
+ observer.observe(header)
49
+ for (const el of header.querySelectorAll('.mint-topbar__center, .mint-topbar__context')) observer.observe(el)
50
+ }
51
+ observe()
52
+ return {
53
+ reset: () => {
54
+ compact.value = false
55
+ expandAt = 0
56
+ requestAnimationFrame(observe)
57
+ },
58
+ stop: () => observer.disconnect(),
59
+ }
60
+ }