@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.0
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/README.md +30 -0
- package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
- package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DCXmwKNS.js} +5 -5
- package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
- package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
- package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
- package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
- package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
- package/dist/__tests__/apiErrors.test.d.ts +1 -0
- package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
- package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
- package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
- package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
- package/dist/apiErrors.d.ts +26 -0
- package/dist/components/BaseInput.vue.d.ts +1 -1
- package/dist/components/BaseSelect.vue.d.ts +1 -1
- package/dist/components/BaseTextarea.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
- package/dist/components/ConcentrationInput.vue.d.ts +1 -1
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +1 -1
- package/dist/components/FileBrowserModal.vue.d.ts +99 -0
- package/dist/components/FilePicker.vue.d.ts +32 -0
- package/dist/components/FileUploader.vue.d.ts +5 -2
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
- package/dist/components/MobileSupportGate.vue.d.ts +1 -1
- package/dist/components/MoleculeInput.vue.d.ts +1 -1
- package/dist/components/NumberInput.vue.d.ts +1 -1
- package/dist/components/PlotlyChart.vue.d.ts +59 -0
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SecretInput.vue.d.ts +1 -1
- package/dist/components/SequenceInput.vue.d.ts +1 -1
- package/dist/components/StatusIndicator.vue.d.ts +1 -1
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/TimePicker.vue.d.ts +1 -1
- package/dist/components/TimeRangeInput.vue.d.ts +1 -1
- package/dist/components/UnitInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +1 -1
- package/dist/components/fileBrowserPresentation.d.ts +10 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +8 -8
- package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
- package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
- package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
- package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
- package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
- package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
- package/dist/components-DIum5hkx.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +3 -0
- package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
- package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
- package/dist/composables/filePicker/validation.d.ts +5 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +10 -10
- package/dist/composables/useApi.d.ts +14 -7
- package/dist/composables/useFileBrowser.d.ts +55 -0
- package/dist/composables/useFilePicker.d.ts +189 -0
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
- package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
- package/dist/composables-mFXsTA4g.js.map +1 -0
- package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
- package/dist/errors-BLuqMee5.js.map +1 -0
- package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
- package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -15
- package/dist/install.js +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/styles.css +2369 -1377
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
- package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
- package/dist/types/fileBrowserTypes.d.ts +77 -0
- package/dist/types/filePicker.d.ts +77 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
- package/dist/useControlSchema-DldpxNOP.js.map +1 -0
- package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
- package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
- package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
- package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
- package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
- package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
- package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
- package/dist/validation-uwoJGEb6.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/apiErrors.test.ts +81 -0
- package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
- package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
- package/src/__tests__/components/FilePicker.state.test.ts +287 -0
- package/src/__tests__/components/FilePicker.test.ts +487 -0
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
- package/src/__tests__/components/PlotlyChart.test.ts +305 -0
- package/src/__tests__/components/SegmentedControl.test.ts +19 -0
- package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
- package/src/__tests__/composables/useApi.test.ts +92 -9
- package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
- package/src/__tests__/composables/usePluginClient.test.ts +3 -0
- package/src/apiErrors.ts +85 -0
- package/src/components/ExperimentPopover.vue +10 -2
- package/src/components/FileBrowserModal.story.vue +203 -0
- package/src/components/FileBrowserModal.vue +384 -0
- package/src/components/FilePicker.story.vue +44 -0
- package/src/components/FilePicker.vue +284 -0
- package/src/components/FileUploader.vue +32 -14
- package/src/components/GeneratedPlotlyResult.vue +34 -69
- package/src/components/GeneratedResultRenderer.vue +0 -5
- package/src/components/PlotlyChart.story.vue +61 -0
- package/src/components/PlotlyChart.vue +233 -0
- package/src/components/SegmentedControl.vue +2 -2
- package/src/components/fileBrowserPresentation.ts +39 -0
- package/src/components/index.ts +5 -0
- package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
- package/src/components/internal/FileBrowserListingInternal.vue +197 -0
- package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
- package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
- package/src/components/internal/FilePickerTreeInternal.vue +238 -0
- package/src/composables/filePicker/localFiles.ts +58 -0
- package/src/composables/filePicker/usePickerNavigation.ts +220 -0
- package/src/composables/filePicker/usePickerSelection.ts +259 -0
- package/src/composables/filePicker/validation.ts +41 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/useApi.ts +57 -13
- package/src/composables/useFileBrowser.ts +314 -0
- package/src/composables/useFilePicker.ts +366 -0
- package/src/composables/useGeneratedAnalysis.ts +10 -17
- package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
- package/src/composables/usePluginClient.ts +3 -0
- package/src/index.ts +3 -0
- package/src/styles/components/file-browser-modal.css +489 -0
- package/src/styles/components/file-picker.css +502 -0
- package/src/styles/components/icon-button.css +9 -0
- package/src/styles/index.css +3 -0
- package/src/types/fileBrowserTypes.ts +85 -0
- package/src/types/filePicker.ts +72 -0
- package/src/types/index.ts +14 -0
- package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
- package/dist/components-CgAUzF8F.js.map +0 -1
- package/dist/composables-B_pia3hO.js.map +0 -1
- package/dist/errors-_W0-LbA6.js.map +0 -1
- package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
- package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
|
2
|
+
import { reactive } from 'vue'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import PlotlyChart from '../../components/PlotlyChart.vue'
|
|
6
|
+
|
|
7
|
+
const plotly = vi.hoisted(() => ({
|
|
8
|
+
Plots: { resize: vi.fn() },
|
|
9
|
+
purge: vi.fn(),
|
|
10
|
+
react: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('plotly.js-dist-min', () => ({ default: plotly }))
|
|
14
|
+
enableAutoUnmount(afterEach)
|
|
15
|
+
|
|
16
|
+
describe('PlotlyChart', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
plotly.react.mockReset().mockResolvedValue(undefined)
|
|
20
|
+
plotly.Plots.resize.mockReset().mockResolvedValue(undefined)
|
|
21
|
+
document.documentElement.classList.remove('dark')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.unstubAllGlobals()
|
|
26
|
+
document.documentElement.classList.remove('dark')
|
|
27
|
+
document.documentElement.removeAttribute('style')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should merge MINT defaults with native Plotly props', async () => {
|
|
31
|
+
const trace = { type: 'scatter' as const, x: [1], y: [2] }
|
|
32
|
+
mount(PlotlyChart, {
|
|
33
|
+
props: {
|
|
34
|
+
data: [trace],
|
|
35
|
+
layout: { title: { text: 'QC' } },
|
|
36
|
+
config: { toImageButtonOptions: { filename: 'qc-export' } },
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
await flushPromises()
|
|
40
|
+
|
|
41
|
+
expect(plotly.react).toHaveBeenCalledWith(
|
|
42
|
+
expect.any(HTMLElement),
|
|
43
|
+
[trace],
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
autosize: true,
|
|
46
|
+
title: { text: 'QC' },
|
|
47
|
+
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
48
|
+
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
49
|
+
}),
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
responsive: true,
|
|
52
|
+
displaylogo: false,
|
|
53
|
+
toImageButtonOptions: { filename: 'qc-export' },
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should render the shared chart empty state when explicitly empty', async () => {
|
|
59
|
+
const wrapper = mount(PlotlyChart, {
|
|
60
|
+
props: { data: [], empty: true, emptyMessage: 'No peaks found' },
|
|
61
|
+
})
|
|
62
|
+
await flushPromises()
|
|
63
|
+
|
|
64
|
+
expect(wrapper.text()).toContain('No peaks found')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should show a safe error when Plotly rejects rendering', async () => {
|
|
68
|
+
plotly.react.mockRejectedValue(new Error('<script>unsafe details</script>'))
|
|
69
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
70
|
+
await flushPromises()
|
|
71
|
+
|
|
72
|
+
expect(wrapper.get('[role="alert"]').text()).toBe('Unable to display this chart.')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should rerender when immutable native props change', async () => {
|
|
76
|
+
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
|
|
77
|
+
await flushPromises()
|
|
78
|
+
|
|
79
|
+
await wrapper.setProps({ data: [{ x: [3], y: [4] }] })
|
|
80
|
+
await flushPromises()
|
|
81
|
+
|
|
82
|
+
expect(plotly.react).toHaveBeenCalledTimes(2)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should rerender when the MINT dark theme changes', async () => {
|
|
86
|
+
let themeChanged: MutationCallback | undefined
|
|
87
|
+
vi.stubGlobal('MutationObserver', class {
|
|
88
|
+
constructor(callback: MutationCallback) {
|
|
89
|
+
themeChanged = callback
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
observe(): void {}
|
|
93
|
+
disconnect(): void {}
|
|
94
|
+
takeRecords(): MutationRecord[] { return [] }
|
|
95
|
+
})
|
|
96
|
+
mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
|
|
97
|
+
await flushPromises()
|
|
98
|
+
|
|
99
|
+
document.documentElement.classList.add('dark')
|
|
100
|
+
themeChanged?.([], {} as MutationObserver)
|
|
101
|
+
await flushPromises()
|
|
102
|
+
|
|
103
|
+
expect(plotly.react).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should resize the Plotly canvas when its container changes size', async () => {
|
|
107
|
+
let resize: ResizeObserverCallback | undefined
|
|
108
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
109
|
+
constructor(callback: ResizeObserverCallback) {
|
|
110
|
+
resize = callback
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
observe(): void {}
|
|
114
|
+
disconnect(): void {}
|
|
115
|
+
})
|
|
116
|
+
mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
|
|
117
|
+
await flushPromises()
|
|
118
|
+
|
|
119
|
+
resize?.([], {} as ResizeObserver)
|
|
120
|
+
|
|
121
|
+
expect(plotly.Plots.resize).toHaveBeenCalledTimes(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should observe the Plotly canvas after leaving the empty state', async () => {
|
|
125
|
+
const observe = vi.fn()
|
|
126
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
127
|
+
observe = observe
|
|
128
|
+
disconnect(): void {}
|
|
129
|
+
})
|
|
130
|
+
const wrapper = mount(PlotlyChart, {
|
|
131
|
+
props: { data: [], empty: true },
|
|
132
|
+
})
|
|
133
|
+
await flushPromises()
|
|
134
|
+
|
|
135
|
+
await wrapper.setProps({ empty: false })
|
|
136
|
+
await flushPromises()
|
|
137
|
+
|
|
138
|
+
expect(observe).toHaveBeenCalledWith(expect.any(HTMLElement))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should render once when data arrives as loading completes', async () => {
|
|
142
|
+
const wrapper = mount(PlotlyChart, {
|
|
143
|
+
props: { data: [], loading: true },
|
|
144
|
+
})
|
|
145
|
+
await flushPromises()
|
|
146
|
+
|
|
147
|
+
await wrapper.setProps({ data: [{ x: [1], y: [2] }], loading: false })
|
|
148
|
+
await flushPromises()
|
|
149
|
+
|
|
150
|
+
expect(plotly.react).toHaveBeenCalledTimes(1)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should serialize renders and use the latest props after an in-flight render', async () => {
|
|
154
|
+
let finish!: () => void
|
|
155
|
+
plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
|
|
156
|
+
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
|
|
157
|
+
await flushPromises()
|
|
158
|
+
await wrapper.setProps({ data: [{ x: [2] }] })
|
|
159
|
+
await wrapper.setProps({ data: [{ x: [3] }] })
|
|
160
|
+
await flushPromises()
|
|
161
|
+
expect(plotly.react).toHaveBeenCalledTimes(1)
|
|
162
|
+
|
|
163
|
+
finish()
|
|
164
|
+
await flushPromises()
|
|
165
|
+
expect(plotly.react).toHaveBeenCalledTimes(2)
|
|
166
|
+
expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [3] }])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should skip rendering when unmounted before the initial DOM update', async () => {
|
|
170
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
171
|
+
wrapper.unmount()
|
|
172
|
+
await flushPromises()
|
|
173
|
+
expect(plotly.react).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should recover from a stale failure without overwriting the newer render', async () => {
|
|
177
|
+
let reject!: (error: Error) => void
|
|
178
|
+
plotly.react.mockImplementationOnce(() => new Promise<void>((_resolve, fail) => { reject = fail }))
|
|
179
|
+
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
|
|
180
|
+
await flushPromises()
|
|
181
|
+
await wrapper.setProps({ data: [{ x: [2] }] })
|
|
182
|
+
reject(new Error('Old render failed'))
|
|
183
|
+
await flushPromises()
|
|
184
|
+
expect(plotly.react).toHaveBeenCalledTimes(2)
|
|
185
|
+
expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [2] }])
|
|
186
|
+
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should purge an in-flight canvas removed by loading and then render its replacement', async () => {
|
|
190
|
+
let finish!: () => void
|
|
191
|
+
plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
|
|
192
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
193
|
+
await flushPromises()
|
|
194
|
+
const element = wrapper.get('.mint-plotly-chart__plot').element
|
|
195
|
+
await wrapper.setProps({ loading: true })
|
|
196
|
+
await wrapper.setProps({ loading: false, data: [{ x: [3] }] })
|
|
197
|
+
finish()
|
|
198
|
+
await flushPromises()
|
|
199
|
+
expect(plotly.purge).toHaveBeenCalledWith(element)
|
|
200
|
+
expect(plotly.react).toHaveBeenCalledTimes(2)
|
|
201
|
+
expect(plotly.react.mock.lastCall?.[0]).toBe(wrapper.get('.mint-plotly-chart__plot').element)
|
|
202
|
+
expect(plotly.react.mock.lastCall?.[0]).not.toBe(element)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should clean up a render that completes after unmount', async () => {
|
|
206
|
+
let finish!: () => void
|
|
207
|
+
plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
|
|
208
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
209
|
+
await flushPromises()
|
|
210
|
+
const element = wrapper.get('.mint-plotly-chart__plot').element
|
|
211
|
+
wrapper.unmount()
|
|
212
|
+
plotly.purge.mockClear()
|
|
213
|
+
finish()
|
|
214
|
+
await flushPromises()
|
|
215
|
+
expect(plotly.purge).toHaveBeenCalledWith(element)
|
|
216
|
+
expect(plotly.react).toHaveBeenCalledTimes(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should purge the removed canvas and render new data after loading or empty states', async () => {
|
|
220
|
+
const disconnect = vi.fn()
|
|
221
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
222
|
+
observe = vi.fn()
|
|
223
|
+
unobserve = vi.fn()
|
|
224
|
+
disconnect = disconnect
|
|
225
|
+
})
|
|
226
|
+
const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
|
|
227
|
+
await flushPromises()
|
|
228
|
+
for (const state of ['loading', 'empty'] as const) {
|
|
229
|
+
const element = wrapper.get('.mint-plotly-chart__plot').element
|
|
230
|
+
await wrapper.setProps({ [state]: true })
|
|
231
|
+
await flushPromises()
|
|
232
|
+
expect(plotly.purge).toHaveBeenCalledWith(element)
|
|
233
|
+
expect(disconnect).toHaveBeenCalled()
|
|
234
|
+
await wrapper.setProps({ [state]: false, data: [{ x: [2] }] })
|
|
235
|
+
await flushPromises()
|
|
236
|
+
expect(wrapper.get('.mint-plotly-chart__plot').element).not.toBe(element)
|
|
237
|
+
}
|
|
238
|
+
expect(plotly.react).toHaveBeenCalledTimes(3)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('should use MINT colors and protect reactive native data and nested layout from Plotly mutations', async () => {
|
|
242
|
+
document.documentElement.style.setProperty('--text-primary', '#f8fafc')
|
|
243
|
+
document.documentElement.style.setProperty('--border-color', '#334155')
|
|
244
|
+
const data = reactive([{ x: [1], y: [2] }])
|
|
245
|
+
const layout = reactive({ title: { text: 'Original' }, xaxis: { title: { text: 'Sample' } } })
|
|
246
|
+
plotly.react.mockImplementationOnce((_element, traces, options) => {
|
|
247
|
+
traces[0].x.push(9)
|
|
248
|
+
options.title.text = 'Changed'
|
|
249
|
+
options.xaxis.title.text = 'Changed'
|
|
250
|
+
return Promise.resolve()
|
|
251
|
+
})
|
|
252
|
+
mount(PlotlyChart, { attachTo: document.body, props: { data, layout } })
|
|
253
|
+
await flushPromises()
|
|
254
|
+
expect(plotly.react.mock.lastCall?.[2].font.color).toBe('#f8fafc')
|
|
255
|
+
expect(plotly.react.mock.lastCall?.[2].xaxis.gridcolor).toBe('#334155')
|
|
256
|
+
expect(data[0].x).toEqual([1])
|
|
257
|
+
expect(layout.title.text).toBe('Original')
|
|
258
|
+
expect(layout.xaxis.title.text).toBe('Sample')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should accept nested reactive arrays, typed arrays, dates, and native export callbacks', async () => {
|
|
262
|
+
const x = reactive([new Date('2026-01-01')])
|
|
263
|
+
const y = new Float64Array([2])
|
|
264
|
+
const click = vi.fn()
|
|
265
|
+
mount(PlotlyChart, {
|
|
266
|
+
props: {
|
|
267
|
+
data: [{ x, y }],
|
|
268
|
+
config: { modeBarButtonsToAdd: [{ name: 'Export', title: 'Export', icon: 'camera', click }] },
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
await flushPromises()
|
|
272
|
+
expect(plotly.react).toHaveBeenCalledOnce()
|
|
273
|
+
const [, traces, , config] = plotly.react.mock.lastCall!
|
|
274
|
+
expect(traces[0].x).toEqual(x)
|
|
275
|
+
expect(traces[0].x).not.toBe(x)
|
|
276
|
+
expect(traces[0].x[0]).toBeInstanceOf(Date)
|
|
277
|
+
expect(traces[0].y).toEqual(y)
|
|
278
|
+
expect(traces[0].y).not.toBe(y)
|
|
279
|
+
expect(config.modeBarButtonsToAdd[0].click).toBe(click)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should ignore resize rejection when the canvas becomes hidden', async () => {
|
|
283
|
+
let resize!: ResizeObserverCallback
|
|
284
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
285
|
+
constructor(callback: ResizeObserverCallback) { resize = callback }
|
|
286
|
+
observe(): void {}
|
|
287
|
+
disconnect(): void {}
|
|
288
|
+
})
|
|
289
|
+
plotly.Plots.resize.mockRejectedValueOnce(new Error('Resize must be passed a displayed plot div'))
|
|
290
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
291
|
+
await flushPromises()
|
|
292
|
+
resize([], {} as ResizeObserver)
|
|
293
|
+
await flushPromises()
|
|
294
|
+
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should purge the Plotly canvas when unmounted', async () => {
|
|
298
|
+
const wrapper = mount(PlotlyChart, { props: { data: [] } })
|
|
299
|
+
await flushPromises()
|
|
300
|
+
|
|
301
|
+
wrapper.unmount()
|
|
302
|
+
|
|
303
|
+
expect(plotly.purge).toHaveBeenCalledTimes(1)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -3,6 +3,25 @@ import { describe, expect, it } from 'vitest'
|
|
|
3
3
|
import SegmentedControl from '../../components/SegmentedControl.vue'
|
|
4
4
|
|
|
5
5
|
describe('SegmentedControl', () => {
|
|
6
|
+
it('defaults to card while allowing an explicit simple variant', async () => {
|
|
7
|
+
const wrapper = mount(SegmentedControl, {
|
|
8
|
+
props: {
|
|
9
|
+
modelValue: 'table',
|
|
10
|
+
options: [{ value: 'table', label: 'Table', description: 'View rows' }],
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(wrapper.classes()).toContain('mint-segmented-control--card')
|
|
15
|
+
expect(wrapper.get('button').classes()).toContain('mint-segmented-control__option--card')
|
|
16
|
+
expect(wrapper.get('.mint-segmented-control__description').text()).toBe('View rows')
|
|
17
|
+
|
|
18
|
+
await wrapper.setProps({ variant: 'simple' })
|
|
19
|
+
|
|
20
|
+
expect(wrapper.classes()).toContain('mint-segmented-control--simple')
|
|
21
|
+
expect(wrapper.get('button').classes()).toContain('mint-segmented-control__option--simple')
|
|
22
|
+
expect(wrapper.find('.mint-segmented-control__description').exists()).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
6
25
|
it('accepts string and number shorthand options', async () => {
|
|
7
26
|
const wrapper = mount(SegmentedControl, {
|
|
8
27
|
props: {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// jsdom has no layout, so this pins the stylesheet contract instead: a bare
|
|
6
|
+
// <svg> passed to IconButton's slot must be sized by the icon box, otherwise it
|
|
7
|
+
// collapses to zero width as a flex item (FilePicker source toggles, beta.14).
|
|
8
|
+
describe('IconButton icon sizing', () => {
|
|
9
|
+
it('should size a bare slot svg to the icon box when the caller passes no dimensions', () => {
|
|
10
|
+
const css = readFileSync(
|
|
11
|
+
resolve(__dirname, '../../styles/components/icon-button.css'),
|
|
12
|
+
'utf8',
|
|
13
|
+
)
|
|
14
|
+
expect(css).toMatch(
|
|
15
|
+
/\.mint-icon-button__icon--sm > svg,\s*\.mint-icon-button__icon--md > svg,\s*\.mint-icon-button__icon--lg > svg \{\s*width: 100%;\s*height: 100%;\s*\}/,
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
|
|
3
3
|
import { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
4
4
|
|
|
5
5
|
import { AuthenticationRequiredError, useApi } from '../../composables/useApi'
|
|
6
|
+
import { MintApiError } from '../../apiErrors'
|
|
6
7
|
import { useAuthStore } from '../../stores/auth'
|
|
7
8
|
|
|
8
9
|
function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
|
|
@@ -32,6 +33,7 @@ function unauthorizedAdapter(config: AxiosRequestConfig): Promise<never> {
|
|
|
32
33
|
describe('useApi URL helpers', () => {
|
|
33
34
|
beforeEach(() => {
|
|
34
35
|
setActivePinia(createPinia())
|
|
36
|
+
useAuthStore().clearToken()
|
|
35
37
|
})
|
|
36
38
|
|
|
37
39
|
afterEach(() => {
|
|
@@ -98,25 +100,35 @@ describe('useApi URL helpers', () => {
|
|
|
98
100
|
expect(authorization).toBeUndefined()
|
|
99
101
|
})
|
|
100
102
|
|
|
101
|
-
it('normalizes authenticated 401 responses and clears the rejected token', async () => {
|
|
103
|
+
it.each([false, true])('normalizes authenticated 401 responses and clears the rejected token (typedErrors=%s)', async (typedErrors) => {
|
|
102
104
|
const authStore = useAuthStore()
|
|
103
105
|
authStore.setToken('expired-token', 3600)
|
|
104
|
-
const api = useApi({ baseUrl: '/api' })
|
|
106
|
+
const api = useApi({ baseUrl: '/api', typedErrors })
|
|
105
107
|
|
|
106
108
|
const adapter = vi.fn(unauthorizedAdapter)
|
|
107
109
|
const error = await api.get('/protected', { adapter }).catch((reason: unknown) => reason)
|
|
108
110
|
|
|
109
|
-
expect({
|
|
111
|
+
expect({
|
|
112
|
+
attempts: adapter.mock.calls.length,
|
|
113
|
+
errorType: error?.constructor,
|
|
114
|
+
typed: error instanceof MintApiError,
|
|
115
|
+
status: (error as MintApiError).status,
|
|
116
|
+
code: (error as MintApiError).code,
|
|
117
|
+
token: authStore.token,
|
|
118
|
+
}).toEqual({
|
|
110
119
|
attempts: 1,
|
|
111
120
|
errorType: AuthenticationRequiredError,
|
|
121
|
+
typed: true,
|
|
122
|
+
status: 401,
|
|
123
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
112
124
|
token: null,
|
|
113
125
|
})
|
|
114
126
|
})
|
|
115
127
|
|
|
116
|
-
it('does not clear auth state for public requests that return 401', async () => {
|
|
128
|
+
it.each([false, true])('does not clear auth state for public requests that return 401 (typedErrors=%s)', async (typedErrors) => {
|
|
117
129
|
const authStore = useAuthStore()
|
|
118
130
|
authStore.setToken('still-valid-token', 3600)
|
|
119
|
-
const api = useApi({ baseUrl: '/api', withAuth: false })
|
|
131
|
+
const api = useApi({ baseUrl: '/api', typedErrors, withAuth: false })
|
|
120
132
|
|
|
121
133
|
const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason)
|
|
122
134
|
|
|
@@ -126,10 +138,10 @@ describe('useApi URL helpers', () => {
|
|
|
126
138
|
})
|
|
127
139
|
})
|
|
128
140
|
|
|
129
|
-
it('does not clear a replacement token after an older request returns 401', async () => {
|
|
141
|
+
it.each([false, true])('does not clear a replacement token after an older request returns 401 (typedErrors=%s)', async (typedErrors) => {
|
|
130
142
|
const authStore = useAuthStore()
|
|
131
143
|
authStore.setToken('rejected-token', 3600)
|
|
132
|
-
const api = useApi({ baseUrl: '/api' })
|
|
144
|
+
const api = useApi({ baseUrl: '/api', typedErrors })
|
|
133
145
|
|
|
134
146
|
await api.get('/protected', {
|
|
135
147
|
adapter: async (config) => {
|
|
@@ -141,9 +153,10 @@ describe('useApi URL helpers', () => {
|
|
|
141
153
|
expect(authStore.token).toBe('replacement-token')
|
|
142
154
|
})
|
|
143
155
|
|
|
144
|
-
it('does not clear a token created while an unauthenticated request is in flight', async () => {
|
|
156
|
+
it.each([false, true])('does not clear a token created while an unauthenticated request is in flight (typedErrors=%s)', async (typedErrors) => {
|
|
145
157
|
const authStore = useAuthStore()
|
|
146
|
-
const api = useApi({ baseUrl: '/api' })
|
|
158
|
+
const api = useApi({ baseUrl: '/api', typedErrors })
|
|
159
|
+
expect(authStore.token).toBeNull()
|
|
147
160
|
|
|
148
161
|
await api.get('/protected', {
|
|
149
162
|
adapter: async (config) => {
|
|
@@ -154,4 +167,74 @@ describe('useApi URL helpers', () => {
|
|
|
154
167
|
|
|
155
168
|
expect(authStore.token).toBe('new-login-token')
|
|
156
169
|
})
|
|
170
|
+
|
|
171
|
+
it('normalizes a typed response when typed errors are enabled', async () => {
|
|
172
|
+
const api = useApi({ baseUrl: '/api', typedErrors: true })
|
|
173
|
+
const adapter = vi.fn(async (config: AxiosRequestConfig) => Promise.reject(new AxiosError(
|
|
174
|
+
'Not Found',
|
|
175
|
+
AxiosError.ERR_BAD_REQUEST,
|
|
176
|
+
config as InternalAxiosRequestConfig,
|
|
177
|
+
undefined,
|
|
178
|
+
{
|
|
179
|
+
data: {
|
|
180
|
+
code: 'leaf.ARTIFACT_NOT_FOUND',
|
|
181
|
+
message: 'Artifact is not available',
|
|
182
|
+
status: 404,
|
|
183
|
+
request_id: 'req-generated',
|
|
184
|
+
details: { artifact_id: 67 },
|
|
185
|
+
detail: 'Artifact is not available',
|
|
186
|
+
},
|
|
187
|
+
status: 404,
|
|
188
|
+
statusText: 'Not Found',
|
|
189
|
+
headers: { 'x-request-id': 'req-generated' },
|
|
190
|
+
config: config as InternalAxiosRequestConfig,
|
|
191
|
+
},
|
|
192
|
+
)))
|
|
193
|
+
|
|
194
|
+
const error = await api.get('/artifacts/67', { adapter }).catch((reason: unknown) => reason)
|
|
195
|
+
|
|
196
|
+
const typedError = error as MintApiError
|
|
197
|
+
expect({ attempts: adapter.mock.calls.length, type: typedError.constructor, code: typedError.code }).toEqual({
|
|
198
|
+
attempts: 1,
|
|
199
|
+
type: MintApiError,
|
|
200
|
+
code: 'leaf.ARTIFACT_NOT_FOUND',
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it.each([true, false])('normalizes Blob download errors (JSON=%s)', async (json) => {
|
|
205
|
+
const api = useApi({ typedErrors: true })
|
|
206
|
+
const body = { code: 'artifact.unavailable', message: 'No artifact', details: { id: 67 } }
|
|
207
|
+
const blob = new Blob([json ? JSON.stringify(body) : 'Bad gateway'], {
|
|
208
|
+
type: json ? 'application/json' : 'text/plain',
|
|
209
|
+
})
|
|
210
|
+
let cause: AxiosError | undefined
|
|
211
|
+
const error = await api.get('/download', {
|
|
212
|
+
responseType: 'blob',
|
|
213
|
+
adapter: async (config) => {
|
|
214
|
+
cause = new AxiosError('Bad gateway', AxiosError.ERR_BAD_RESPONSE, config, undefined, {
|
|
215
|
+
data: blob, status: 502, statusText: 'Bad gateway',
|
|
216
|
+
headers: { 'x-request-id': 'blob-request' }, config,
|
|
217
|
+
})
|
|
218
|
+
throw cause
|
|
219
|
+
},
|
|
220
|
+
}).catch((reason: unknown) => reason)
|
|
221
|
+
|
|
222
|
+
expect(error).toBeInstanceOf(MintApiError)
|
|
223
|
+
expect(error).toMatchObject({
|
|
224
|
+
code: json ? body.code : 'unknown',
|
|
225
|
+
message: json ? body.message : 'Bad gateway',
|
|
226
|
+
status: 502, requestId: 'blob-request', cause,
|
|
227
|
+
})
|
|
228
|
+
expect((error as MintApiError).body).toEqual(json ? body : blob)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('leaves cancellations and network failures unchanged in typed mode', async () => {
|
|
232
|
+
const api = useApi({ typedErrors: true })
|
|
233
|
+
const cause = new AxiosError('Offline', AxiosError.ERR_NETWORK)
|
|
234
|
+
await expect(api.get('/offline', { adapter: async () => { throw cause } })).rejects.toBe(cause)
|
|
235
|
+
const controller = new AbortController()
|
|
236
|
+
controller.abort()
|
|
237
|
+
await expect(api.get('/cancelled', { signal: controller.signal })).rejects.toMatchObject({ code: 'ERR_CANCELED' })
|
|
238
|
+
})
|
|
239
|
+
|
|
157
240
|
})
|