@morscherlab/mint-sdk 1.5.0-beta.1 → 1.5.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AlertBox-CIhqsWbN.js → AlertBox-Cai0w7gF.js} +3 -3
- package/dist/{AlertBox-CIhqsWbN.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
- package/dist/{AppSidebar-BDrd7Kwn.js → AppSidebar-C-Vq5x4I.js} +410 -127
- package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
- package/dist/{ConfirmDialog-CjWoxAD1.js → ConfirmDialog-BjFsOpBe.js} +3 -3
- package/dist/ConfirmDialog-BjFsOpBe.js.map +1 -0
- package/dist/{ExperimentPopover-BM92LlrQ.js → ExperimentPopover-DYy_s0kI.js} +25 -37
- package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-LsB1ABmh.js → ExperimentSelectorModal-Bfh5bdkv.js} +37 -8
- package/dist/ExperimentSelectorModal-Bfh5bdkv.js.map +1 -0
- package/dist/{SegmentedControl-Dz41vAFM.js → SegmentedControl-B8KgyOjd.js} +2 -2
- package/dist/{SegmentedControl-Dz41vAFM.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
- package/dist/{SettingsModal-C-MSR-bk.js → SettingsModal-DwfrD1ER.js} +7 -7
- package/dist/{SettingsModal-C-MSR-bk.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/FormBuilder.vue.d.ts +1 -1
- package/dist/components/PluginIcon.vue.d.ts +2 -2
- package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
- package/dist/components/SectionCard.vue.d.ts +13 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
- package/dist/components/internal/FormSchemaInternal.vue.d.ts +9 -9
- package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
- package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
- package/dist/composables/appLayoutSidebar.d.ts +9 -0
- package/dist/composables/useExperimentSelector.d.ts +2 -0
- package/dist/composables/useOnboarding.d.ts +1 -1
- package/dist/composables/usePluginWorkspace.d.ts +2 -0
- package/dist/composables/useVersionedSave.d.ts +5 -2
- package/dist/{controlSchemaModel-BbXbMgc6.js → controlSchemaModel-BtFNZj8t.js} +2 -2
- package/dist/{controlSchemaModel-BbXbMgc6.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
- package/dist/{data-LzdkEZJ-.js → data-CJztFns8.js} +31 -15
- package/dist/{data-LzdkEZJ-.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.d.ts +0 -1
- package/dist/index.js +15 -20
- package/dist/index.js.map +1 -1
- package/dist/{install-UTqrAgHe.js → install-Bk02tJ5M.js} +15 -40
- package/dist/install-Bk02tJ5M.js.map +1 -0
- package/dist/install.js +1 -1
- package/dist/{lab-CbdDLAVr.js → lab-BYhb_cJI.js} +47 -117
- package/dist/lab-BYhb_cJI.js.map +1 -0
- package/dist/lab.js +4 -4
- package/dist/{records-CWRztE5M.js → records-GtiVMlTQ.js} +207 -171
- package/dist/records-GtiVMlTQ.js.map +1 -0
- package/dist/{runtime-DrL6lKAb.js → runtime-aZPUPxoB.js} +112 -54
- package/dist/runtime-aZPUPxoB.js.map +1 -0
- package/dist/runtime.js +6 -6
- package/dist/{shell-Ch7cJnjo.js → shell-Bk9mD4Lc.js} +5 -5
- package/dist/{shell-Ch7cJnjo.js.map → shell-Bk9mD4Lc.js.map} +1 -1
- package/dist/shell.js +5 -5
- package/dist/styles.css +315 -134
- package/dist/styles.d.ts +1 -0
- package/dist/types/components.d.ts +22 -2
- package/dist/types/generated-ui.d.ts +2 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/instrument.d.ts +5 -17
- package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
- package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
- package/dist/{useExperimentSamples-Cz2v3V_q.js → useExperimentSamples-CpNXvpUn.js} +3 -3
- package/dist/{useExperimentSamples-Cz2v3V_q.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
- package/dist/{useListReorder-QqJc1dpX.js → useListReorder-Bp4EAI2w.js} +16 -4
- package/dist/useListReorder-Bp4EAI2w.js.map +1 -0
- package/dist/{useRackEditor-BHGM1Ngz.js → useRackEditor-i2bdPUFf.js} +156 -27
- package/dist/useRackEditor-i2bdPUFf.js.map +1 -0
- package/dist/{useRealtime-CNQbrS01.js → useRealtime-BLQEjbuq.js} +3 -3
- package/dist/{useRealtime-CNQbrS01.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 +7 -4
- 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/AppTopBarOnboarding.test.ts +10 -0
- package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
- package/src/__tests__/components/BaseModal.test.ts +33 -2
- package/src/__tests__/components/ConfirmDialog.test.ts +2 -2
- package/src/__tests__/components/ExperimentPopover.test.ts +32 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +16 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +20 -1
- package/src/__tests__/components/SectionCard.test.ts +33 -0
- package/src/__tests__/components/deprecationTags.test.ts +4 -18
- package/src/__tests__/composables/useJobCenter.test.ts +35 -0
- package/src/__tests__/composables/useJobsEndpoint.test.ts +50 -0
- package/src/__tests__/composables/useOnboarding.test.ts +2 -2
- package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
- 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/AppSidebar.vue +2 -2
- 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/CollapsibleCard.vue +1 -1
- package/src/components/ConfirmDialog.vue +1 -1
- package/src/components/ExperimentPopover.story.vue +2 -2
- package/src/components/ExperimentPopover.vue +6 -22
- package/src/components/ExperimentSelectorModal.vue +35 -0
- package/src/components/FileUploader.vue +3 -35
- package/src/components/GeneratedPluginView.vue +34 -81
- package/src/components/GeneratedResultRenderer.vue +2 -1
- package/src/components/InstrumentStatusPanels.vue +3 -3
- package/src/components/PlotlyChart.vue +1 -1
- package/src/components/PluginIcon.story.vue +2 -2
- package/src/components/PluginIcon.vue +2 -2
- package/src/components/PluginWorkspaceView.vue +10 -1
- package/src/components/SectionCard.vue +42 -6
- package/src/components/internal/AppTopBarOnboardingInternal.vue +30 -14
- 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/index.ts +1 -2
- package/src/composables/useExperimentSelector.ts +4 -0
- package/src/composables/useJobCenter.ts +21 -1
- package/src/composables/useJobsEndpoint.ts +15 -4
- package/src/composables/useOnboarding.ts +3 -3
- package/src/composables/usePluginWorkspace.ts +15 -4
- package/src/composables/useVersionedSave.ts +32 -7
- package/src/generated-ui/main.ts +3 -1
- package/src/index.ts +0 -3
- package/src/styles/components/alert-box.css +1 -1
- package/src/styles/components/app-layout.css +5 -0
- package/src/styles/components/app-sidebar.css +5 -5
- package/src/styles/components/app-top-bar.css +130 -9
- package/src/styles/components/button.css +3 -3
- package/src/styles/components/checkbox.css +1 -1
- package/src/styles/components/collapsible-card.css +1 -1
- package/src/styles/components/confirm-dialog.css +2 -2
- package/src/styles/components/file-uploader.css +3 -3
- package/src/styles/components/icon-button.css +3 -3
- package/src/styles/components/loading-spinner.css +1 -1
- package/src/styles/components/onboarding.css +39 -0
- package/src/styles/components/pill.css +1 -1
- package/src/styles/components/plugin-icon.css +2 -2
- package/src/styles/components/presence-avatars.css +4 -4
- package/src/styles/components/progress-bar.css +1 -1
- package/src/styles/components/section-card.css +65 -6
- package/src/styles/components/sequence-input.css +1 -1
- package/src/styles/components/slider.css +1 -1
- package/src/styles/components/toggle.css +1 -1
- package/src/styles/variables.css +10 -4
- package/src/types/components.ts +23 -2
- package/src/types/generated-ui.ts +2 -0
- package/src/types/index.ts +1 -1
- package/src/types/instrument.ts +5 -18
- 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-BDrd7Kwn.js.map +0 -1
- package/dist/ConfirmDialog-CjWoxAD1.js.map +0 -1
- package/dist/ExperimentPopover-BM92LlrQ.js.map +0 -1
- package/dist/ExperimentSelectorModal-LsB1ABmh.js.map +0 -1
- package/dist/deprecatedRootExports.d.ts +0 -191
- package/dist/install-UTqrAgHe.js.map +0 -1
- package/dist/lab-CbdDLAVr.js.map +0 -1
- package/dist/records-CWRztE5M.js.map +0 -1
- package/dist/runtime-DrL6lKAb.js.map +0 -1
- package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
- package/dist/useListReorder-QqJc1dpX.js.map +0 -1
- package/dist/useRackEditor-BHGM1Ngz.js.map +0 -1
- package/dist/useRequest-Bfsh7gIm.js +0 -215
- package/dist/useRequest-Bfsh7gIm.js.map +0 -1
- 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
|
+
})
|
|
@@ -56,6 +56,16 @@ describe('AppTopBar onboarding', () => {
|
|
|
56
56
|
if (shown) expect(wrapper.find('.mint-onboarding-help__chip').text()).toContain('0/1')
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
it('should hide the checklist chip when its close button is clicked', async () => {
|
|
60
|
+
const wrapper = mountTopBar(definition)
|
|
61
|
+
|
|
62
|
+
await wrapper.get('[aria-label="Hide checklist"]').trigger('click')
|
|
63
|
+
|
|
64
|
+
expect(wrapper.find('.mint-onboarding-help__chip').exists()).toBe(false)
|
|
65
|
+
expect(JSON.parse(localStorage.getItem('mint.onboarding.test.1')!)).toContain('hide:checklist')
|
|
66
|
+
expect(document.activeElement).toBe(wrapper.get('[data-onboarding="topbar-help"]').element)
|
|
67
|
+
})
|
|
68
|
+
|
|
59
69
|
it('should run a tour picked in the Help panel and end it with Escape', async () => {
|
|
60
70
|
const wrapper = mountTopBar(definition)
|
|
61
71
|
await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
|
|
@@ -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', '
|
|
11
|
-
['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
|
})
|
|
@@ -96,6 +96,7 @@ describe('ExperimentSelectorModal', () => {
|
|
|
96
96
|
projects.value = []
|
|
97
97
|
statusCounts.value = {}
|
|
98
98
|
filters = newFilters()
|
|
99
|
+
localStorage.clear()
|
|
99
100
|
fetchExperiments.mockClear()
|
|
100
101
|
fetchFilterOptions.mockClear()
|
|
101
102
|
fetchStatusCounts.mockClear()
|
|
@@ -124,6 +125,7 @@ describe('ExperimentSelectorModal', () => {
|
|
|
124
125
|
experimentType: undefined,
|
|
125
126
|
allowedExperimentTypes: ['mass_spectrometry'],
|
|
126
127
|
withStatusCounts: true,
|
|
128
|
+
initialFilters: {},
|
|
127
129
|
})
|
|
128
130
|
})
|
|
129
131
|
|
|
@@ -293,6 +295,20 @@ describe('ExperimentSelectorModal', () => {
|
|
|
293
295
|
expect(mine.attributes('aria-pressed')).toBe('true')
|
|
294
296
|
})
|
|
295
297
|
|
|
298
|
+
it('should reopen with the status, project and mine filters of the last visit but not the search or type', async () => {
|
|
299
|
+
const first = mountModal({ open: true })
|
|
300
|
+
Object.assign(filters, { search: 'glu', status: 'ongoing', project: 'Screen', experimentType: 'screen', mine: true })
|
|
301
|
+
await first.vm.$nextTick()
|
|
302
|
+
first.unmount()
|
|
303
|
+
|
|
304
|
+
vi.mocked(useExperimentSelector).mockClear()
|
|
305
|
+
filters = newFilters()
|
|
306
|
+
mountModal({ open: true })
|
|
307
|
+
|
|
308
|
+
expect(vi.mocked(useExperimentSelector).mock.calls[0][0]?.initialFilters)
|
|
309
|
+
.toEqual({ status: 'ongoing', project: 'Screen', mine: true })
|
|
310
|
+
})
|
|
311
|
+
|
|
296
312
|
it('should clear status, project and mine but keep the search when Clear N is clicked', async () => {
|
|
297
313
|
const wrapper = mountModal({ open: true })
|
|
298
314
|
expect(wrapper.find('.mint-experiment-selector__clear-filters').exists()).toBe(false)
|
|
@@ -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),
|
|
@@ -178,4 +178,37 @@ describe('SectionCard', () => {
|
|
|
178
178
|
expect(withoutFooter.find('.mint-section-card__footer').exists()).toBe(false)
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
|
+
|
|
182
|
+
describe('collapsible', () => {
|
|
183
|
+
it('should hide the body and emit the collapsed state when a collapsible header is clicked', async () => {
|
|
184
|
+
const wrapper = mount(SectionCard, {
|
|
185
|
+
props: { title: 'Alerts', count: 3, collapsible: true },
|
|
186
|
+
slots: { default: '<p class="body-probe">rows</p>', actions: '<button class="action-probe">Export</button>' },
|
|
187
|
+
attachTo: document.body,
|
|
188
|
+
})
|
|
189
|
+
const toggle = wrapper.find('button.mint-section-card__toggle')
|
|
190
|
+
expect(toggle.attributes('aria-expanded')).toBe('true')
|
|
191
|
+
expect(wrapper.find('h2.mint-section-card__title > button').element).toBe(toggle.element)
|
|
192
|
+
expect(toggle.text()).toContain('Alerts')
|
|
193
|
+
expect(toggle.find('.action-probe').exists()).toBe(false)
|
|
194
|
+
|
|
195
|
+
await toggle.trigger('click')
|
|
196
|
+
|
|
197
|
+
expect(toggle.attributes('aria-expanded')).toBe('false')
|
|
198
|
+
expect(wrapper.find('.mint-section-card__body').isVisible()).toBe(false)
|
|
199
|
+
expect(wrapper.emitted('update:collapsed')).toEqual([[true]])
|
|
200
|
+
wrapper.unmount()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should keep the body visible with no toggle when collapsed is set without collapsible', () => {
|
|
204
|
+
const wrapper = mount(SectionCard, {
|
|
205
|
+
props: { title: 'Alerts', collapsed: true },
|
|
206
|
+
slots: { default: '<p class="body-probe">rows</p>' },
|
|
207
|
+
attachTo: document.body,
|
|
208
|
+
})
|
|
209
|
+
expect(wrapper.find('.mint-section-card__toggle').exists()).toBe(false)
|
|
210
|
+
expect(wrapper.find('.mint-section-card__body').isVisible()).toBe(true)
|
|
211
|
+
wrapper.unmount()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
181
214
|
})
|
|
@@ -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
|
|
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).
|
|
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
|
|
76
|
-
|
|
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 })
|
|
@@ -1595,6 +1595,56 @@ describe('useJobsEndpoint', () => {
|
|
|
1595
1595
|
}
|
|
1596
1596
|
})
|
|
1597
1597
|
|
|
1598
|
+
it.each([
|
|
1599
|
+
{ name: 'resolves when the server still has the job', answers: [() => response(completedJob('submitted-job'))], outcome: 'completed' },
|
|
1600
|
+
{ name: 'rejects when the server no longer has the job', answers: [() => response({ detail: 'Job not found' }, 404)], outcome: 'Job submitted-job is no longer available' },
|
|
1601
|
+
{ name: 'checks again on the next snapshot after a retryable error', answers: [() => response({ detail: 'Busy' }, 503), () => response(completedJob('submitted-job'))], outcome: 'completed' },
|
|
1602
|
+
])('should check a waited job that a stale stream snapshot omits and $name', async ({ answers, outcome }) => {
|
|
1603
|
+
const encoder = new TextEncoder()
|
|
1604
|
+
let pushFrame!: (frame: string) => void
|
|
1605
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1606
|
+
start(controller) { pushFrame = frame => controller.enqueue(encoder.encode(frame)) },
|
|
1607
|
+
})
|
|
1608
|
+
let lookups = 0
|
|
1609
|
+
const transport = vi.fn<GeneratedAnalysisTransport>(async (input, init) => {
|
|
1610
|
+
const url = String(input)
|
|
1611
|
+
if (url.endsWith('/jobs/sessions') && init?.method === 'GET') return response({ sessions: [] })
|
|
1612
|
+
if (url.endsWith('/jobs/sessions')) {
|
|
1613
|
+
return response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [] })
|
|
1614
|
+
}
|
|
1615
|
+
if (url.endsWith('/jobs/events')) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } })
|
|
1616
|
+
if (url.endsWith('/jobs') && init?.method === 'POST') return response(activeJob('submitted-job'))
|
|
1617
|
+
if (url.endsWith('/jobs')) return response({ jobs: [] })
|
|
1618
|
+
if (url.endsWith('/jobs/submitted-job')) {
|
|
1619
|
+
lookups += 1
|
|
1620
|
+
return lookups === 1 ? response(activeJob('submitted-job', 'running')) : answers[lookups - 2]!()
|
|
1621
|
+
}
|
|
1622
|
+
throw new Error(`Unexpected request: ${url}`)
|
|
1623
|
+
})
|
|
1624
|
+
const scope = effectScope()
|
|
1625
|
+
const generated = scope.run(() => useJobsEndpoint({
|
|
1626
|
+
baseUrl: '/api/demo',
|
|
1627
|
+
transport,
|
|
1628
|
+
storage: memoryStorage(),
|
|
1629
|
+
createId: () => 'tab-1',
|
|
1630
|
+
streamReconnectDelay: 60_000,
|
|
1631
|
+
}))!
|
|
1632
|
+
|
|
1633
|
+
try {
|
|
1634
|
+
const waiting = generated.wait(await generated.submit('run', {}))
|
|
1635
|
+
.then(job => job.status, (cause: Error) => cause.message)
|
|
1636
|
+
// Each snapshot was built before the submit reached the server.
|
|
1637
|
+
for (const [index] of answers.entries()) {
|
|
1638
|
+
await vi.waitFor(() => expect(lookups).toBe(index + 1))
|
|
1639
|
+
pushFrame(`event: snapshot\ndata: ${JSON.stringify({ jobs: [] })}\n\n`)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
expect(await waiting).toBe(outcome)
|
|
1643
|
+
} finally {
|
|
1644
|
+
scope.stop()
|
|
1645
|
+
}
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1598
1648
|
it('rejects pending waiters when their job is deleted or the runtime is disposed', async () => {
|
|
1599
1649
|
const queued = activeJob('delete-wait-job')
|
|
1600
1650
|
const disposeJobRequest = deferred<Response>()
|
|
@@ -98,7 +98,7 @@ describe('provideOnboarding', () => {
|
|
|
98
98
|
expect(state.active.value).toMatchObject({ step: { id: 'b' }, index: 0, count: 1 })
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
it('should mark every step seen on dismiss and not start
|
|
101
|
+
it('should mark every step of every tour seen on dismiss and not start a tour again', async () => {
|
|
102
102
|
const state = setup()
|
|
103
103
|
await flush()
|
|
104
104
|
state.dismiss()
|
|
@@ -107,7 +107,7 @@ describe('provideOnboarding', () => {
|
|
|
107
107
|
const again = setup()
|
|
108
108
|
await flush()
|
|
109
109
|
|
|
110
|
-
expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b']))
|
|
110
|
+
expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b', 'step:list/new']))
|
|
111
111
|
expect(again.active.value).toBeNull()
|
|
112
112
|
})
|
|
113
113
|
|
|
@@ -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()
|