@morscherlab/mint-sdk 1.5.0-beta.7 → 1.5.0-beta.9
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.
- package/dist/{AlertBox-CexONPhA.js → AlertBox-Cai0w7gF.js} +3 -3
- package/dist/{AlertBox-CexONPhA.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
- package/dist/{AppSidebar-C4qQLHcH.js → AppSidebar-C-Vq5x4I.js} +361 -93
- package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
- package/dist/{ConfirmDialog-D9QXFpsy.js → ConfirmDialog-BjFsOpBe.js} +2 -2
- package/dist/{ConfirmDialog-D9QXFpsy.js.map → ConfirmDialog-BjFsOpBe.js.map} +1 -1
- package/dist/{ExperimentPopover-DyjoXwry.js → ExperimentPopover-DYy_s0kI.js} +21 -20
- package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-CUkKmgfr.js → ExperimentSelectorModal-Bfh5bdkv.js} +5 -5
- package/dist/{ExperimentSelectorModal-CUkKmgfr.js.map → ExperimentSelectorModal-Bfh5bdkv.js.map} +1 -1
- package/dist/{SegmentedControl-C-sckoJZ.js → SegmentedControl-B8KgyOjd.js} +2 -2
- package/dist/{SegmentedControl-C-sckoJZ.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
- package/dist/{SettingsModal-B1ltysmg.js → SettingsModal-DwfrD1ER.js} +7 -7
- package/dist/{SettingsModal-B1ltysmg.js.map → SettingsModal-DwfrD1ER.js.map} +1 -1
- package/dist/__tests__/components/AppTopBarCompact.test.d.ts +1 -0
- package/dist/__tests__/components/AppTopBarCompactReset.test.d.ts +1 -0
- package/dist/__tests__/components/AutoGroupModal.experimentLoad.test.d.ts +1 -0
- package/dist/components/AppTopBar.compact.d.ts +5 -0
- package/dist/components/AppTopBar.vue.d.ts +17 -17
- package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
- package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
- package/dist/composables/appLayoutSidebar.d.ts +9 -0
- package/dist/composables/usePluginWorkspace.d.ts +2 -0
- package/dist/composables/useVersionedSave.d.ts +5 -2
- package/dist/{controlSchemaModel-C_XqpkgE.js → controlSchemaModel-BtFNZj8t.js} +2 -2
- package/dist/{controlSchemaModel-C_XqpkgE.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
- package/dist/{data-CgIcnf9D.js → data-CJztFns8.js} +31 -15
- package/dist/{data-CgIcnf9D.js.map → data-CJztFns8.js.map} +1 -1
- package/dist/data.js +8 -8
- package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
- package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
- package/dist/index.js +14 -14
- package/dist/{install-DmyjbTJ5.js → install-Bk02tJ5M.js} +14 -15
- package/dist/{install-DmyjbTJ5.js.map → install-Bk02tJ5M.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-BcX5JBSY.js → lab-BYhb_cJI.js} +22 -14
- package/dist/lab-BYhb_cJI.js.map +1 -0
- package/dist/lab.js +4 -4
- package/dist/{records-BAymyHAn.js → records-GtiVMlTQ.js} +198 -62
- package/dist/records-GtiVMlTQ.js.map +1 -0
- package/dist/{runtime-CjfAxufu.js → runtime-aZPUPxoB.js} +40 -19
- package/dist/runtime-aZPUPxoB.js.map +1 -0
- package/dist/runtime.js +6 -6
- package/dist/{shell-XDqDu_UD.js → shell-Bk9mD4Lc.js} +5 -5
- package/dist/{shell-XDqDu_UD.js.map → shell-Bk9mD4Lc.js.map} +1 -1
- package/dist/shell.js +5 -5
- package/dist/styles.css +138 -13
- package/dist/types/components.d.ts +22 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
- package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
- package/dist/{useExperimentSamples-DEDAKa2E.js → useExperimentSamples-CpNXvpUn.js} +3 -3
- package/dist/{useExperimentSamples-DEDAKa2E.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
- package/dist/{useListReorder-W1bzK_bo.js → useListReorder-Bp4EAI2w.js} +3 -3
- package/dist/{useListReorder-W1bzK_bo.js.map → useListReorder-Bp4EAI2w.js.map} +1 -1
- package/dist/{useRackEditor-Bmtuo0aj.js → useRackEditor-i2bdPUFf.js} +8 -8
- package/dist/{useRackEditor-Bmtuo0aj.js.map → useRackEditor-i2bdPUFf.js.map} +1 -1
- package/dist/{useRealtime-jOetF2mM.js → useRealtime-BLQEjbuq.js} +3 -3
- package/dist/{useRealtime-jOetF2mM.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
- package/dist/useRequest-CXyPw3eh.js +76 -0
- package/dist/useRequest-CXyPw3eh.js.map +1 -0
- package/dist/utils/deprecation.d.ts +2 -0
- package/dist/utils/lucideIcon.d.ts +4 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.test.ts +102 -20
- package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
- package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
- package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
- package/src/__tests__/components/BaseModal.test.ts +33 -2
- package/src/__tests__/components/PluginWorkspaceView.test.ts +20 -1
- package/src/__tests__/composables/useJobCenter.test.ts +35 -0
- package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
- package/src/__tests__/styles/menusShellRefresh.test.ts +10 -0
- package/src/components/AppLayout.story.vue +5 -5
- package/src/components/AppLayout.vue +13 -1
- package/src/components/AppTopBar.compact.ts +60 -0
- package/src/components/AppTopBar.story.vue +39 -14
- package/src/components/AppTopBar.vue +222 -89
- package/src/components/BaseModal.vue +16 -5
- package/src/components/ExperimentPopover.vue +1 -1
- package/src/components/GeneratedPluginView.vue +0 -1
- package/src/components/PluginWorkspaceView.vue +10 -1
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
- package/src/components/internal/AutoGroupModalInternal.vue +14 -3
- package/src/components/internal/LucideIconInternal.vue +23 -0
- package/src/composables/appLayoutSidebar.ts +11 -0
- package/src/composables/useJobCenter.ts +21 -1
- package/src/composables/usePluginWorkspace.ts +15 -4
- package/src/composables/useVersionedSave.ts +32 -7
- package/src/styles/components/app-layout.css +5 -0
- package/src/styles/components/app-top-bar.css +130 -9
- package/src/types/components.ts +23 -2
- package/src/types/index.ts +1 -0
- package/src/utils/deprecation.ts +8 -0
- package/src/utils/lucideIcon.ts +25 -0
- package/src/utils/lucideIconIndex.d.ts +2 -0
- package/dist/AppSidebar-C4qQLHcH.js.map +0 -1
- package/dist/ExperimentPopover-DyjoXwry.js.map +0 -1
- package/dist/lab-BcX5JBSY.js.map +0 -1
- package/dist/records-BAymyHAn.js.map +0 -1
- package/dist/runtime-CjfAxufu.js.map +0 -1
- package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
- package/dist/useRequest-D9-3BS-3.js +0 -215
- package/dist/useRequest-D9-3BS-3.js.map +0 -1
|
@@ -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
|
})
|
|
@@ -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,
|
|
177
|
+
spec: { pills: pillNav, settings: {}, standaloneLabel: false },
|
|
178
178
|
pill: 'data',
|
|
179
179
|
})
|
|
180
180
|
expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({
|
|
@@ -392,6 +392,25 @@ describe('PluginWorkspaceView', () => {
|
|
|
392
392
|
expect(wrapper.get('[data-test="sidebar-footer"]').text()).toBe('Run')
|
|
393
393
|
})
|
|
394
394
|
|
|
395
|
+
it.each([
|
|
396
|
+
{ name: 'bound v-model', sidebarCollapsed: true, collapsedWidth: undefined, expected: '3rem' },
|
|
397
|
+
{ name: 'its own state', sidebarCollapsed: undefined, collapsedWidth: '3.5rem', expected: '3.5rem' },
|
|
398
|
+
])('should give AppLayout the collapsed width when the sidebar collapses through $name', async ({ sidebarCollapsed, collapsedWidth, expected }) => {
|
|
399
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
400
|
+
props: {
|
|
401
|
+
sidebarCollapsed,
|
|
402
|
+
spec: { title: 'Tools', sidebar: { width: '20rem', collapsible: true, collapsedWidth, whenEmpty: true } },
|
|
403
|
+
},
|
|
404
|
+
global: globalOptions,
|
|
405
|
+
})
|
|
406
|
+
expect(wrapper.findComponent(AppLayout).props('sidebarWidth')).toBe(sidebarCollapsed ? expected : '20rem')
|
|
407
|
+
|
|
408
|
+
wrapper.findComponent(AppSidebar).vm.$emit('update:collapsed', true)
|
|
409
|
+
await nextTick()
|
|
410
|
+
|
|
411
|
+
expect(wrapper.findComponent(AppLayout).props('sidebarWidth')).toBe(expected)
|
|
412
|
+
})
|
|
413
|
+
|
|
395
414
|
it('can provide AppTopBar experiment selection and save wiring from the shared store', async () => {
|
|
396
415
|
vi.mocked(usePlatform).mockReturnValueOnce({
|
|
397
416
|
integrated: computed(() => true),
|
|
@@ -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(
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
109
|
-
|
|
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()
|
|
@@ -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
|
})
|
|
@@ -40,6 +45,11 @@ describe('menus and app shell styles', () => {
|
|
|
40
45
|
expect(css('app-page-selector.css')).toMatch(new RegExp(`\\.mint-page-selector__${part}\\s*\\{[^}]*box-sizing:\\s*border-box;`))
|
|
41
46
|
})
|
|
42
47
|
|
|
48
|
+
// Beside a nav slot, a title without a subtitle broke at its hyphen ("LEAF-" over "cal").
|
|
49
|
+
it('should keep a title-only top-bar label on one line', () => {
|
|
50
|
+
expect(css('app-top-bar.css')).toMatch(/\.mint-topbar__title-only[^{]*\{[^}]*white-space:\s*nowrap;/)
|
|
51
|
+
})
|
|
52
|
+
|
|
43
53
|
// Shrinking the tile beside the identity also shrank structured icons inside their solid chip, drawing a ring.
|
|
44
54
|
it('should keep the plugin tile beside the static plugin identity (5a)', () => {
|
|
45
55
|
expect(css('app-top-bar.css')).not.toMatch(/:has\(\+ \.mint-topbar__plugin-identity\)/)
|
|
@@ -52,7 +52,7 @@ const logScale = ref(false)
|
|
|
52
52
|
>
|
|
53
53
|
<template #topbar>
|
|
54
54
|
<AppTopBar
|
|
55
|
-
:spec="{ pills: pillNav,
|
|
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,
|
|
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="{
|
|
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,
|
|
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="{
|
|
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
|
}
|
|
@@ -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
|
+
}
|